@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/catalog.js CHANGED
@@ -171,6 +171,23 @@ function _contentType(s) {
171
171
  function _genId() { return b.uuid.v7(); }
172
172
  function _now() { return Date.now(); }
173
173
 
174
+ // Build the LIKE pattern for a single term — lowercased + the
175
+ // `\`-first metacharacter neutralisation so a term containing `%` /
176
+ // `_` matches only the literal character (otherwise `100%` would
177
+ // match every row). The `\\` substitution MUST run first, before the
178
+ // `%` / `_` passes, so the `\` we insert in front of a wildcard
179
+ // isn't itself re-escaped on the next pass and the wildcard survives
180
+ // un-neutralised. `products.search` and `batch.searchDecorate` both
181
+ // compose this so the escape can't drift between the two surfaces;
182
+ // it mirrors the edge `_likePattern` (worker/data/catalog.js).
183
+ function _likePattern(term) {
184
+ var escaped = String(term).toLowerCase()
185
+ .replace(/\\/g, "\\\\")
186
+ .replace(/%/g, "\\%")
187
+ .replace(/_/g, "\\_");
188
+ return "%" + escaped + "%";
189
+ }
190
+
174
191
  // ---- factory -------------------------------------------------------------
175
192
 
176
193
  function create(opts) {
@@ -206,6 +223,13 @@ function create(opts) {
206
223
  // failure never rolls back the stock op that triggered it.
207
224
  var inventory = _inventoryModule(query, { onStockChange: opts.onStockChange || null });
208
225
  var media = _mediaModule(query);
226
+ // Batched read helpers — one or a few `IN (...)` / window-function
227
+ // queries that replace the per-item loops the container storefront
228
+ // ran (N+1 over the D1 bridge). Each is ported from the named edge
229
+ // function in worker/data/catalog.js with the SAME SQL shape +
230
+ // output field names, so the dual-rendered HTML stays byte-identical
231
+ // across the edge + container substrates.
232
+ var batch = _batchModule(query);
209
233
 
210
234
  return {
211
235
  products: products,
@@ -213,6 +237,7 @@ function create(opts) {
213
237
  prices: prices,
214
238
  inventory: inventory,
215
239
  media: media,
240
+ batch: batch,
216
241
  };
217
242
  }
218
243
 
@@ -381,17 +406,12 @@ function _productsModule(query, cursorSecret) {
381
406
  if (qTrim.length === 0) {
382
407
  return { rows: [], next_cursor: null };
383
408
  }
384
- var qLower = qTrim.toLowerCase();
385
409
  // Neutralize LIKE metacharacters so a search containing `%`
386
410
  // or `_` matches only the literal character (otherwise `100%`
387
- // would match every row). `\` is the escape character; it has
388
- // to be escaped first so we don't double-escape the
389
- // `\` we insert in front of `%` / `_`.
390
- var qEscaped = qLower
391
- .replace(/\\/g, "\\\\")
392
- .replace(/%/g, "\\%")
393
- .replace(/_/g, "\\_");
394
- var pattern = "%" + qEscaped + "%";
411
+ // would match every row). The shared `_likePattern` lowercases
412
+ // and escapes `\` first so the wildcard neutralisation can't be
413
+ // re-escaped the same helper `batch.searchDecorate` composes.
414
+ var pattern = _likePattern(qTrim);
395
415
  var cursorVals = null;
396
416
  if (opts.cursor != null) {
397
417
  if (typeof opts.cursor !== "string") {
@@ -851,6 +871,548 @@ function _mediaModule(query) {
851
871
  };
852
872
  }
853
873
 
874
+ // ---- batch (N+1-collapsing read helpers) ---------------------------------
875
+ //
876
+ // These mirror worker/data/catalog.js — the edge worker already
877
+ // produces, in one or a few `IN (...)` / window-function queries, what
878
+ // the container storefront re-derived with per-item loops (each a
879
+ // full D1-bridge hop). Porting the edge SQL verbatim keeps the two
880
+ // substrates in lockstep: the field names + row shapes below are the
881
+ // ones the storefront renderers already accept, so the rendered HTML /
882
+ // JSON-LD is unchanged.
883
+ //
884
+ // Every helper composes the module's `query(sql, params)` closure +
885
+ // the existing validators. Each is missing-table-resilient (catch
886
+ // `/no such table/i` → empty) to match the edge + the never-500-the-
887
+ // page stance, EXCEPT entry-point validation (a malformed id throws
888
+ // TypeError, which the route catches → 404/empty). The IN(...) lists
889
+ // use positional placeholders generated from array length, values
890
+ // bound positionally — never interpolated.
891
+
892
+ // First-variant / first-media decorated SELECT — ported verbatim from
893
+ // the edge `DECORATED_SELECT` (worker/data/catalog.js). Window
894
+ // functions pick the first variant (position ASC, created_at ASC) +
895
+ // first media per product in one round trip; the price is joined off
896
+ // the hero variant for the requested currency. `?1` is the currency.
897
+ var _DECORATED_SELECT =
898
+ "SELECT p.*, " +
899
+ " v.id AS hero_variant_id, " +
900
+ " pr.amount_minor AS starting_price_minor, " +
901
+ " pr.currency AS starting_price_currency, " +
902
+ " m.r2_key AS hero_r2_key, " +
903
+ " m.alt_text AS hero_alt_text " +
904
+ "FROM products p " +
905
+ "LEFT JOIN ( " +
906
+ " SELECT iv.*, " +
907
+ " ROW_NUMBER() OVER (PARTITION BY iv.product_id ORDER BY iv.position ASC, iv.created_at ASC) AS rn " +
908
+ " FROM variants iv " +
909
+ ") v ON v.product_id = p.id AND v.rn = 1 " +
910
+ "LEFT JOIN prices pr ON pr.variant_id = v.id AND pr.currency = ?1 AND pr.effective_until IS NULL " +
911
+ "LEFT JOIN ( " +
912
+ " SELECT im.*, " +
913
+ " ROW_NUMBER() OVER (PARTITION BY im.product_id ORDER BY im.position ASC, im.created_at ASC) AS rn " +
914
+ " FROM media im " +
915
+ " WHERE im.product_id IS NOT NULL " +
916
+ ") m ON m.product_id = p.id AND m.rn = 1 ";
917
+
918
+ // Shape a decorated row to the exact output `_shapeDecoratedRow`
919
+ // produces at the edge (worker/data/catalog.js): the product columns
920
+ // plus `starting_price_minor` / `starting_price_currency` /
921
+ // `hero_media`. No arithmetic — `starting_price_minor` is the raw
922
+ // integer minor-unit amount passed through.
923
+ function _shapeDecoratedRow(r) {
924
+ var hero = (r.hero_r2_key != null)
925
+ ? { r2_key: r.hero_r2_key, alt_text: r.hero_alt_text || "" }
926
+ : null;
927
+ return {
928
+ id: r.id,
929
+ slug: r.slug,
930
+ title: r.title,
931
+ description: r.description,
932
+ status: r.status,
933
+ created_at: r.created_at,
934
+ updated_at: r.updated_at,
935
+ starting_price_minor: r.starting_price_minor != null ? r.starting_price_minor : null,
936
+ starting_price_currency: r.starting_price_currency || "USD",
937
+ hero_media: hero,
938
+ };
939
+ }
940
+
941
+ function _batchModule(query) {
942
+ // Default to USD when an unset / wrong-shape currency reaches a
943
+ // helper, matching the edge's `(len === 3) ? cur : "USD"` guard.
944
+ function _ccy(c) {
945
+ return (typeof c === "string" && c.length === 3) ? c : "USD";
946
+ }
947
+
948
+ return {
949
+ // Decorate a set of product ids in one query (backs PERF-5 home
950
+ // grid + PERF-1 related). Returns `{ rows, byId }`: `rows` carries
951
+ // one decorated row per matching id in `created_at`-stable order
952
+ // and `byId` maps id → row so the caller can re-impose its own
953
+ // order. Mirrors the edge `DECORATED_SELECT` + `_shapeDecoratedRow`.
954
+ decoratedProducts: async function (productIds, currency) {
955
+ if (!Array.isArray(productIds) || productIds.length === 0) return { rows: [], byId: {} };
956
+ var ids = productIds.map(function (id) { return _id(id, "product id"); });
957
+ var cur = _ccy(currency);
958
+ var placeholders = ids.map(function (_v, n) { return "?" + (n + 2); }).join(", ");
959
+ var params = [cur].concat(ids);
960
+ var r = await query(
961
+ _DECORATED_SELECT +
962
+ "WHERE p.id IN (" + placeholders + ") " +
963
+ "ORDER BY p.created_at ASC, p.id ASC",
964
+ params,
965
+ );
966
+ var rows = [];
967
+ var byId = {};
968
+ for (var i = 0; i < r.rows.length; i += 1) {
969
+ var shaped = _shapeDecoratedRow(r.rows[i]);
970
+ rows.push(shaped);
971
+ byId[shaped.id] = shaped;
972
+ }
973
+ return { rows: rows, byId: byId };
974
+ },
975
+
976
+ // Active products decorated in one query (backs PERF-5 / UX-8 home
977
+ // grid). Mirrors the edge `listActiveProducts`: status='active',
978
+ // ORDER BY updated_at DESC, id DESC, LIMIT/OFFSET. Returns
979
+ // `{ rows }`.
980
+ decoratedActive: async function (opts) {
981
+ opts = opts || {};
982
+ var cur = _ccy(opts.currency);
983
+ var limit = opts.limit == null ? 24 : opts.limit;
984
+ var offset = opts.offset == null ? 0 : opts.offset;
985
+ _positiveInt(limit, "limit");
986
+ _nonNegInt(offset, "offset");
987
+ var r = await query(
988
+ _DECORATED_SELECT +
989
+ "WHERE p.status = 'active' " +
990
+ "ORDER BY p.updated_at DESC, p.id DESC " +
991
+ "LIMIT ?2 OFFSET ?3",
992
+ [cur, limit, offset],
993
+ );
994
+ var rows = [];
995
+ for (var i = 0; i < r.rows.length; i += 1) rows.push(_shapeDecoratedRow(r.rows[i]));
996
+ return { rows: rows };
997
+ },
998
+
999
+ // Variants + their current price for a product in one query (backs
1000
+ // PERF-3 PDP). Mirrors the edge `listVariantsWithPrices`: LEFT JOIN
1001
+ // the active price (effective_until IS NULL) for `currency`,
1002
+ // ordered position ASC, created_at ASC. Returns `{ rows, prices }`:
1003
+ // - `rows` — the variant rows shaped like `variants.listForProduct`
1004
+ // (options_json parsed to `options`), price columns dropped
1005
+ // so the shape is byte-identical to the per-item path.
1006
+ // - `prices` — { variantId: priceRow } for variants that HAVE a
1007
+ // price in `currency`, each priceRow shaped like
1008
+ // `prices.current` output.
1009
+ variantsWithPrices: async function (productId, currency) {
1010
+ _id(productId, "product_id");
1011
+ var cur = _ccy(currency);
1012
+ var r = await query(
1013
+ "SELECT v.*, " +
1014
+ " pr.id AS price_id, " +
1015
+ " pr.amount_minor AS price_amount_minor, " +
1016
+ " pr.currency AS price_currency, " +
1017
+ " pr.effective_from AS price_effective_from, " +
1018
+ " pr.effective_until AS price_effective_until, " +
1019
+ " pr.created_at AS price_created_at " +
1020
+ "FROM variants v " +
1021
+ "LEFT JOIN prices pr ON pr.variant_id = v.id AND pr.currency = ?1 AND pr.effective_until IS NULL " +
1022
+ "WHERE v.product_id = ?2 " +
1023
+ "ORDER BY v.position ASC, v.created_at ASC",
1024
+ [cur, productId],
1025
+ );
1026
+ var rows = [];
1027
+ var prices = {};
1028
+ for (var i = 0; i < r.rows.length; i += 1) {
1029
+ var raw = r.rows[i];
1030
+ // Lift the joined price into the per-variant prices map, shaped
1031
+ // exactly like `prices.current` output (only when a price for
1032
+ // `currency` exists), then strip the joined price columns off
1033
+ // the variant row so the row shape is byte-identical to
1034
+ // `variants.listForProduct` (the variant columns + parsed
1035
+ // `options`). Deleting the extra keys in place preserves the
1036
+ // row's identity/prototype so the two paths deep-equal.
1037
+ if (raw.price_amount_minor != null) {
1038
+ prices[raw.id] = {
1039
+ id: raw.price_id,
1040
+ variant_id: raw.id,
1041
+ currency: raw.price_currency,
1042
+ amount_minor: raw.price_amount_minor,
1043
+ effective_from: raw.price_effective_from,
1044
+ effective_until: raw.price_effective_until,
1045
+ created_at: raw.price_created_at,
1046
+ };
1047
+ }
1048
+ delete raw.price_id;
1049
+ delete raw.price_amount_minor;
1050
+ delete raw.price_currency;
1051
+ delete raw.price_effective_from;
1052
+ delete raw.price_effective_until;
1053
+ delete raw.price_created_at;
1054
+ raw.options = JSON.parse(raw.options_json);
1055
+ rows.push(raw);
1056
+ }
1057
+ return { rows: rows, prices: prices };
1058
+ },
1059
+
1060
+ // Inventory rows for a set of SKUs in one query, keyed by SKU
1061
+ // (backs PERF-6 cart-line stock). Mirrors the edge
1062
+ // `listInventoryForSkus`: the trimmed `{ stock_on_hand, stock_held }`
1063
+ // shape — sufficient for the cart, which derives its low-stock band
1064
+ // from an operator config value, not the per-SKU column. A SKU with
1065
+ // no inventory row is absent from the map (the renderer treats
1066
+ // absence as available). Missing-table-resilient → {}.
1067
+ inventoryForSkus: async function (skus) {
1068
+ if (!Array.isArray(skus) || skus.length === 0) return {};
1069
+ var out = {};
1070
+ try {
1071
+ var placeholders = skus.map(function (_s, i) { return "?" + (i + 1); }).join(", ");
1072
+ var r = await query(
1073
+ "SELECT sku, stock_on_hand, stock_held FROM inventory WHERE sku IN (" + placeholders + ")",
1074
+ skus,
1075
+ );
1076
+ for (var i = 0; i < r.rows.length; i += 1) {
1077
+ out[r.rows[i].sku] = { stock_on_hand: r.rows[i].stock_on_hand, stock_held: r.rows[i].stock_held };
1078
+ }
1079
+ } catch (e) {
1080
+ if (e && /no such table/i.test(e.message || "")) return {};
1081
+ throw e;
1082
+ }
1083
+ return out;
1084
+ },
1085
+
1086
+ // Full inventory rows for a set of SKUs in one query, keyed by SKU
1087
+ // (backs PERF-3 PDP availability). Returns the SAME full-row shape
1088
+ // `inventory.get` does (`SELECT *` → sku, stock_on_hand, stock_held,
1089
+ // low_stock_threshold, updated_at), keeping the PDP's per-SKU
1090
+ // inventory map byte-identical to the prior per-variant
1091
+ // `inventory.get` loop. The PDP's availability resolver reads
1092
+ // `low_stock_threshold` to surface the "Only N left" nudge, so the
1093
+ // PDP needs the full row — the trimmed `inventoryForSkus` above is
1094
+ // for the cart, which doesn't. A SKU with no inventory row is absent
1095
+ // (renderer treats absence as available). Missing-table-resilient
1096
+ // → {}.
1097
+ inventoryRowsForSkus: async function (skus) {
1098
+ if (!Array.isArray(skus) || skus.length === 0) return {};
1099
+ var out = {};
1100
+ try {
1101
+ var placeholders = skus.map(function (_s, i) { return "?" + (i + 1); }).join(", ");
1102
+ var r = await query(
1103
+ "SELECT * FROM inventory WHERE sku IN (" + placeholders + ")",
1104
+ skus,
1105
+ );
1106
+ for (var i = 0; i < r.rows.length; i += 1) {
1107
+ out[r.rows[i].sku] = r.rows[i];
1108
+ }
1109
+ } catch (e) {
1110
+ if (e && /no such table/i.test(e.message || "")) return {};
1111
+ throw e;
1112
+ }
1113
+ return out;
1114
+ },
1115
+
1116
+ // The facetable product universe for a set of search terms (backs
1117
+ // PERF-2). Ported from the edge `searchFacetableProducts`: one
1118
+ // decorated SELECT with an OR-of-LIKE term clause, then a
1119
+ // collection_members `IN` (MANUAL membership only — see PD-1) +
1120
+ // a grouped-inventory `IN`. Terms are deduped case-insensitively.
1121
+ // Returns `{ rows }` where each row is the decorated shape PLUS
1122
+ // `collection` (array of slugs), `price_minor` (int|null), and
1123
+ // `in_stock` (bool) — the shape the faceting walker + search-card
1124
+ // renderer consume. The collection + inventory reads are
1125
+ // missing-table-resilient (a partially-migrated deploy degrades to
1126
+ // the fields it has rather than 500ing search).
1127
+ searchDecorate: async function (opts) {
1128
+ opts = opts || {};
1129
+ var terms = Array.isArray(opts.terms) ? opts.terms : [];
1130
+ var dedup = {};
1131
+ var cleaned = [];
1132
+ for (var ti = 0; ti < terms.length; ti += 1) {
1133
+ var term = typeof terms[ti] === "string" ? terms[ti].trim() : "";
1134
+ if (!term.length || dedup[term.toLowerCase()]) continue;
1135
+ dedup[term.toLowerCase()] = true;
1136
+ cleaned.push(term);
1137
+ }
1138
+ if (!cleaned.length) return { rows: [] };
1139
+ var cur = _ccy(opts.currency);
1140
+ // Cap the candidate universe wider than the page so facet counts
1141
+ // reflect the full match set. MAX_LIMIT keeps the in-memory facet
1142
+ // walk bounded — matches the edge's MAX_LIMIT (100) cap.
1143
+ var universeLimit = 100;
1144
+
1145
+ // One OR-of-LIKE clause per term. Positional params: ?1 is the
1146
+ // price currency, ?2 the universe limit; term patterns start at ?3.
1147
+ var likeClauses = [];
1148
+ var params = [cur, universeLimit];
1149
+ var ph = 3;
1150
+ for (var c = 0; c < cleaned.length; c += 1) {
1151
+ var pat = _likePattern(cleaned[c]);
1152
+ likeClauses.push("lower(p.title) LIKE ?" + ph + " ESCAPE '\\'");
1153
+ params.push(pat);
1154
+ ph += 1;
1155
+ likeClauses.push("lower(p.description) LIKE ?" + ph + " ESCAPE '\\'");
1156
+ params.push(pat);
1157
+ ph += 1;
1158
+ }
1159
+ var r = await query(
1160
+ _DECORATED_SELECT +
1161
+ "WHERE p.status = 'active' AND (" + likeClauses.join(" OR ") + ") " +
1162
+ "ORDER BY p.updated_at DESC, p.id DESC LIMIT ?2",
1163
+ params,
1164
+ );
1165
+ var raw = r.rows;
1166
+ if (!raw.length) return { rows: [] };
1167
+
1168
+ var ids = raw.map(function (row) { return row.id; });
1169
+ var byId = {};
1170
+ var rows = [];
1171
+ for (var i = 0; i < raw.length; i += 1) {
1172
+ var shaped = _shapeDecoratedRow(raw[i]);
1173
+ shaped.collection = [];
1174
+ shaped.price_minor = shaped.starting_price_minor != null ? shaped.starting_price_minor : null;
1175
+ shaped.in_stock = false;
1176
+ byId[shaped.id] = shaped;
1177
+ rows.push(shaped);
1178
+ }
1179
+
1180
+ var placeholders = ids.map(function (_v, n) { return "?" + (n + 1); }).join(", ");
1181
+
1182
+ // Collection memberships — categorical facet field `collection`.
1183
+ // MANUAL membership only (collection_members joined to a live
1184
+ // collection), matching the edge. Smart-collection rule matches
1185
+ // are intentionally NOT included (PD-1 convergence).
1186
+ try {
1187
+ var cmRes = await query(
1188
+ "SELECT cm.product_id AS pid, cm.collection_slug AS slug " +
1189
+ "FROM collection_members cm " +
1190
+ "JOIN collections col ON col.slug = cm.collection_slug AND col.archived_at IS NULL " +
1191
+ "WHERE cm.product_id IN (" + placeholders + ")",
1192
+ ids,
1193
+ );
1194
+ for (var cm = 0; cm < cmRes.rows.length; cm += 1) {
1195
+ var pr1 = byId[cmRes.rows[cm].pid];
1196
+ if (pr1 && cmRes.rows[cm].slug) pr1.collection.push(cmRes.rows[cm].slug);
1197
+ }
1198
+ } catch (e) {
1199
+ if (!(e && /no such table/i.test(e.message || ""))) throw e;
1200
+ }
1201
+
1202
+ // In-stock — true when any variant SKU has available stock
1203
+ // (stock_on_hand > stock_held). Boolean facet field `in_stock`.
1204
+ try {
1205
+ var invRes = await query(
1206
+ "SELECT v.product_id AS pid, " +
1207
+ " MAX(CASE WHEN (inv.stock_on_hand - inv.stock_held) > 0 THEN 1 ELSE 0 END) AS any_stock " +
1208
+ "FROM variants v " +
1209
+ "JOIN inventory inv ON inv.sku = v.sku " +
1210
+ "WHERE v.product_id IN (" + placeholders + ") " +
1211
+ "GROUP BY v.product_id",
1212
+ ids,
1213
+ );
1214
+ for (var iv = 0; iv < invRes.rows.length; iv += 1) {
1215
+ var pr2 = byId[invRes.rows[iv].pid];
1216
+ if (pr2) pr2.in_stock = Number(invRes.rows[iv].any_stock) === 1;
1217
+ }
1218
+ } catch (e2) {
1219
+ if (!(e2 && /no such table/i.test(e2.message || ""))) throw e2;
1220
+ }
1221
+
1222
+ return { rows: rows };
1223
+ },
1224
+
1225
+ // "You may also like" siblings for a product, decorated in one
1226
+ // query (backs PERF-1). Ported from the edge `listRelatedProducts`
1227
+ // sibling SELECT: the other active members of `collectionSlug`
1228
+ // (self excluded), ordered membership position ASC then product id
1229
+ // ASC, capped at `limit`, each carrying its first variant's current
1230
+ // price + first media row. Returns the exact
1231
+ // `{ slug, title, hero_r2_key, hero_alt_text, price_minor,
1232
+ // price_currency }` shape the rail renderer consumes. NOTE: this is
1233
+ // ONLY the sibling decoration — the primary-collection lookup stays
1234
+ // in the caller (it already has it).
1235
+ relatedSiblings: async function (collectionSlug, excludeProductId, currency, limit) {
1236
+ _slug(collectionSlug);
1237
+ _id(excludeProductId, "product_id");
1238
+ var cur = _ccy(currency);
1239
+ var n = (Number.isInteger(limit) && limit > 0) ? limit : 4;
1240
+ var r = await query(
1241
+ "SELECT p.id AS id, p.slug AS slug, p.title AS title, " +
1242
+ " pr.amount_minor AS price_amount_minor, " +
1243
+ " pr.currency AS price_currency, " +
1244
+ " m.r2_key AS hero_r2_key, " +
1245
+ " m.alt_text AS hero_alt_text " +
1246
+ "FROM collection_members cm " +
1247
+ "JOIN products p ON p.id = cm.product_id " +
1248
+ "LEFT JOIN ( " +
1249
+ " SELECT iv.*, " +
1250
+ " ROW_NUMBER() OVER (PARTITION BY iv.product_id ORDER BY iv.position ASC, iv.created_at ASC) AS rn " +
1251
+ " FROM variants iv " +
1252
+ ") v ON v.product_id = p.id AND v.rn = 1 " +
1253
+ "LEFT JOIN prices pr ON pr.variant_id = v.id AND pr.currency = ?1 AND pr.effective_until IS NULL " +
1254
+ "LEFT JOIN ( " +
1255
+ " SELECT im.*, " +
1256
+ " ROW_NUMBER() OVER (PARTITION BY im.product_id ORDER BY im.position ASC, im.created_at ASC) AS rn " +
1257
+ " FROM media im " +
1258
+ " WHERE im.product_id IS NOT NULL " +
1259
+ ") m ON m.product_id = p.id AND m.rn = 1 " +
1260
+ "WHERE cm.collection_slug = ?2 AND cm.product_id != ?3 AND p.status = 'active' " +
1261
+ "ORDER BY cm.position ASC, cm.product_id ASC LIMIT ?4",
1262
+ [cur, collectionSlug, excludeProductId, n],
1263
+ );
1264
+ return r.rows.map(function (row) {
1265
+ return {
1266
+ slug: row.slug,
1267
+ title: row.title,
1268
+ hero_r2_key: row.hero_r2_key != null ? row.hero_r2_key : null,
1269
+ hero_alt_text: row.hero_r2_key != null ? (row.hero_alt_text || row.title) : null,
1270
+ price_minor: row.price_amount_minor != null ? row.price_amount_minor : null,
1271
+ price_currency: row.price_currency || "USD",
1272
+ };
1273
+ });
1274
+ },
1275
+
1276
+ // Variants for a set of SKUs in one query, keyed by SKU (backs
1277
+ // PERF-9 bundle member-title batching). Returns a map sku → variant
1278
+ // row shaped like `variants.bySku` output (options_json parsed to
1279
+ // `options`). Used to collect every bundle member's owning product
1280
+ // id in one hop instead of a per-member `variants.bySku`.
1281
+ variantsBySkus: async function (skus) {
1282
+ if (!Array.isArray(skus) || skus.length === 0) return {};
1283
+ var unique = [];
1284
+ var seen = Object.create(null);
1285
+ for (var s = 0; s < skus.length; s += 1) {
1286
+ var sk = skus[s];
1287
+ if (typeof sk !== "string" || seen[sk]) continue;
1288
+ seen[sk] = true;
1289
+ unique.push(sk);
1290
+ }
1291
+ if (!unique.length) return {};
1292
+ var placeholders = unique.map(function (_v, n) { return "?" + (n + 1); }).join(", ");
1293
+ var r = await query(
1294
+ "SELECT * FROM variants WHERE sku IN (" + placeholders + ")",
1295
+ unique,
1296
+ );
1297
+ var out = {};
1298
+ for (var i = 0; i < r.rows.length; i += 1) {
1299
+ var row = r.rows[i];
1300
+ row.options = JSON.parse(row.options_json);
1301
+ out[row.sku] = row;
1302
+ }
1303
+ return out;
1304
+ },
1305
+
1306
+ // Variants for a set of variant ids in one query, keyed by id
1307
+ // (backs PERF-6 cart reprice). Returns a map id → variant row
1308
+ // shaped like `variants.get` output (options_json parsed to
1309
+ // `options`) — the same value the prior per-line `variants.get`
1310
+ // returned, keyed by the stable variant_id the cart line carries.
1311
+ variantsByIds: async function (variantIds) {
1312
+ if (!Array.isArray(variantIds) || variantIds.length === 0) return {};
1313
+ var unique = [];
1314
+ var seen = Object.create(null);
1315
+ for (var n = 0; n < variantIds.length; n += 1) {
1316
+ var vid = variantIds[n];
1317
+ if (typeof vid !== "string" || !vid.length || seen[vid]) continue;
1318
+ seen[vid] = true;
1319
+ unique.push(_id(vid, "variant id"));
1320
+ }
1321
+ if (!unique.length) return {};
1322
+ var placeholders = unique.map(function (_v, i) { return "?" + (i + 1); }).join(", ");
1323
+ var r = await query(
1324
+ "SELECT * FROM variants WHERE id IN (" + placeholders + ")",
1325
+ unique,
1326
+ );
1327
+ var out = {};
1328
+ for (var i = 0; i < r.rows.length; i += 1) {
1329
+ var row = r.rows[i];
1330
+ row.options = JSON.parse(row.options_json);
1331
+ out[row.id] = row;
1332
+ }
1333
+ return out;
1334
+ },
1335
+
1336
+ // Products for a set of ids in one query, keyed by id (backs PERF-9
1337
+ // bundle member-title batching). Returns a map id → product row.
1338
+ productsByIds: async function (productIds) {
1339
+ if (!Array.isArray(productIds) || productIds.length === 0) return {};
1340
+ var ids = productIds.map(function (id) { return _id(id, "product id"); });
1341
+ var placeholders = ids.map(function (_v, n) { return "?" + (n + 1); }).join(", ");
1342
+ var r = await query(
1343
+ "SELECT * FROM products WHERE id IN (" + placeholders + ")",
1344
+ ids,
1345
+ );
1346
+ var out = {};
1347
+ for (var i = 0; i < r.rows.length; i += 1) out[r.rows[i].id] = r.rows[i];
1348
+ return out;
1349
+ },
1350
+
1351
+ // Every price row (current + history) for a set of variant ids in
1352
+ // one query, grouped into the per-variant price model the admin
1353
+ // product-detail screen builds (backs PERF-4). Replaces the admin's
1354
+ // triple-nested per-variant → per-currency → (prices.current +
1355
+ // prices.history) loop with a single read. Returns a map
1356
+ // { variantId: { currencies: [{ currency, current, history }] } }
1357
+ // byte-identical to the per-item build:
1358
+ // - `currencies` is one entry per distinct currency, ordered ASC
1359
+ // (the order `prices.currencies` returns).
1360
+ // - `current` is the open price row (effective_until IS NULL) for
1361
+ // that currency, or null — matching `prices.current`.
1362
+ // - `history` is every row for that currency ordered effective_from
1363
+ // DESC — matching `prices.history`.
1364
+ // EVERY input variant id is present in the map; a variant with no
1365
+ // prices maps to `{ currencies: [] }` (the per-item path's empty
1366
+ // build). Price rows are the raw `SELECT *` shape (no arithmetic —
1367
+ // amount_minor is the integer minor-unit value passed through).
1368
+ pricesForVariants: async function (variantIds) {
1369
+ if (!Array.isArray(variantIds) || variantIds.length === 0) return {};
1370
+ var unique = [];
1371
+ var seen = Object.create(null);
1372
+ for (var n = 0; n < variantIds.length; n += 1) {
1373
+ var vid = variantIds[n];
1374
+ if (typeof vid !== "string" || !vid.length) continue;
1375
+ var canon = _id(vid, "variant id");
1376
+ if (seen[canon]) continue;
1377
+ seen[canon] = true;
1378
+ unique.push(canon);
1379
+ }
1380
+ var out = {};
1381
+ // Seed every requested variant so an unpriced variant still maps to
1382
+ // an empty currencies list (the per-item loop's `{ currencies: [] }`).
1383
+ for (var s = 0; s < unique.length; s += 1) out[unique[s]] = { currencies: [] };
1384
+ if (!unique.length) return out;
1385
+ var placeholders = unique.map(function (_v, i) { return "?" + (i + 1); }).join(", ");
1386
+ // ORDER BY variant_id, currency ASC (matches prices.currencies'
1387
+ // ASC ordering), then effective_from DESC (matches prices.history).
1388
+ var r = await query(
1389
+ "SELECT * FROM prices WHERE variant_id IN (" + placeholders + ") " +
1390
+ "ORDER BY variant_id, currency, effective_from DESC",
1391
+ unique,
1392
+ );
1393
+ // Group variant → currency. The currency-ASC ordering means the
1394
+ // first time a (variant, currency) pair is seen drives its position
1395
+ // in the `currencies` array, reproducing the per-currency loop order.
1396
+ var byVariantCurrency = {};
1397
+ for (var i = 0; i < r.rows.length; i += 1) {
1398
+ var row = r.rows[i];
1399
+ var bucket = out[row.variant_id];
1400
+ if (!bucket) continue; // a price for a variant outside the input set
1401
+ var key = row.variant_id + "\u0000" + row.currency;
1402
+ var entry = byVariantCurrency[key];
1403
+ if (!entry) {
1404
+ entry = { currency: row.currency, current: null, history: [] };
1405
+ byVariantCurrency[key] = entry;
1406
+ bucket.currencies.push(entry);
1407
+ }
1408
+ entry.history.push(row);
1409
+ if (row.effective_until == null && entry.current == null) entry.current = row;
1410
+ }
1411
+ return out;
1412
+ },
1413
+ };
1414
+ }
1415
+
854
1416
  module.exports = {
855
1417
  create: create,
856
1418
  };
@@ -891,6 +891,39 @@ function create(opts) {
891
891
  return out;
892
892
  }
893
893
 
894
+ // ---- countIn -----------------------------------------------------------
895
+
896
+ // The current size of a collection, cheaply. For a MANUAL collection
897
+ // this is an exact `COUNT(*)` over `collection_members` — one indexed
898
+ // aggregate, no row materialisation (the admin list previously pulled
899
+ // up to 200 member rows just to `.length` them). For a SMART collection
900
+ // there is no cheap SQL count (membership is rule-evaluated against the
901
+ // catalog application-side), so the bounded preview is kept: it walks
902
+ // the same matched set `productsIn` does, capped at MAX_LIMIT, and the
903
+ // result is labelled a preview.
904
+ //
905
+ // Returns `{ exact: n }` for manual (a true total) and `{ approx: n }`
906
+ // for smart (the bounded preview, ≤ MAX_LIMIT). The caller reads
907
+ // whichever key is present; the number shown is unchanged from the
908
+ // previous `productsIn(...).rows.length` for both flavours.
909
+ async function countIn(slug) {
910
+ _slug(slug);
911
+ var row = await _row(slug);
912
+ if (!row) throw new TypeError("collections.countIn: collection " + JSON.stringify(slug) + " not found");
913
+ if (row.type === "manual") {
914
+ var r = await query(
915
+ "SELECT COUNT(*) AS n FROM collection_members WHERE collection_slug = ?1",
916
+ [slug],
917
+ );
918
+ var n = r.rows[0] && r.rows[0].n;
919
+ return { exact: Number(n == null ? 0 : n) };
920
+ }
921
+ // Smart: the bounded preview — same matched set productsIn returns,
922
+ // capped at MAX_LIMIT (the cap the admin list already used).
923
+ var preview = await productsIn({ slug: slug, limit: MAX_LIMIT });
924
+ return { approx: (preview.rows || []).length };
925
+ }
926
+
894
927
  return {
895
928
  defineManual: defineManual,
896
929
  defineSmart: defineSmart,
@@ -902,6 +935,7 @@ function create(opts) {
902
935
  removeProduct: removeProduct,
903
936
  reorderProducts: reorderProducts,
904
937
  productsIn: productsIn,
938
+ countIn: countIn,
905
939
  collectionsForProduct: collectionsForProduct,
906
940
  evaluateRules: _evaluateRules,
907
941
  };