@blamejs/blamejs-shop 0.3.46 → 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/CHANGELOG.md +2 -0
- package/lib/admin.js +25 -25
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +63 -0
- package/lib/collections.js +34 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.47 (2026-06-01) — **Admin product detail, collections list, and order shipment panels load with fewer database round trips.** Three admin-console screens re-derived data with one database query per item: the product-detail screen ran a query per variant per currency for current and historical prices, the collections list pulled up to 200 member rows for each collection just to count them, and the order-detail shipment panel hydrated each shipment and its labels sequentially. These now read in batched or parallel queries. The displayed data is unchanged, with one correctness improvement: a manual collection with more than 200 members now shows its true product count instead of a number capped at 200. **Changed:** *Batched admin product-detail price hydration* — The admin product-detail screen now loads every variant's current and historical prices across all currencies in a single batched query instead of one query per variant per currency. The screen renders identically. · *Collections list counts without pulling member rows* — The admin collections list now counts a manual collection's members with a COUNT query instead of fetching up to 200 member rows per collection. A manual collection with more than 200 members now shows its true count (previously the displayed count was capped at 200); rule-based collections keep their existing bounded preview count. · *Parallel shipment hydration on order detail* — The order-detail shipment panel now hydrates each shipment and its labels in parallel rather than one after another, so an order with several shipments opens faster.
|
|
12
|
+
|
|
11
13
|
- v0.3.46 (2026-06-01) — **Storefront pages render in far fewer database round trips, and the search collection filter is consistent across cache tiers.** The dynamically rendered storefront (the path that serves returning, signed-in, or non-default-currency shoppers) re-derived product data with one database query per item — a home or search page could issue dozens to hundreds of round trips, where the cached edge already did the same work in a single batched query. The home grid, search results, product page, related-products rail, and cart now read product, variant, price, inventory, and media data in batched queries that mirror the edge exactly, so those pages render with far fewer round trips and identical output. As part of this, the search 'collection' filter now reflects manual collection membership consistently whether a page is served from the cache or rendered dynamically. **Changed:** *Batched product reads on storefront pages* — The home grid, search results, product detail, related-products rail, and cart now load product/variant/price/inventory/media data in batched database queries instead of one query per item. On a cache miss, a signed-in session, or a non-default currency the dynamic render path previously issued a query per product (and per variant); these pages now match the single-query shape the cached edge already used. Rendered pages are unchanged. · *Consistent search collection filter across cache tiers* — The 'collection' filter on the search page now reflects manual collection membership on both the cached and the dynamically rendered paths. Previously the dynamic path could additionally surface smart-collection rule matches in that filter, so a shopper could see different collection filter options depending on which path served the page. If you relied on smart (rule-based) collections appearing as search filters, they now appear only where a product is an explicit member; this aligns the two render paths.
|
|
12
14
|
|
|
13
15
|
- v0.3.45 (2026-06-01) — **Tax, VAT, bulk price changes, and customer emails now compute money in exact integer arithmetic.** Several money calculations were performed with floating-point intermediates. On a large order subtotal the sales-tax math could lose a minor unit, and the customer-facing wishlist emails formatted prices by dividing by 100 — which is wrong for zero-decimal currencies like JPY (¥1,050 rendered as 10.5) and three-decimal currencies like BHD, and dropped trailing zeros ($10.00 shown as 10). All of these now run through the framework's exact BigInt money primitive: sales-tax and VAT/GST extraction, the per-rate owed figure on a tax filing, the admin bulk price-adjust, the wishlist price-drop and digest emails, and subscription monthly-revenue normalization. Prices in customer emails are now rendered with the correct currency exponent and symbol for every currency. **Changed:** *Bulk price-adjust rounds to even on exact half-unit ties* — The admin bulk price-adjust now rounds a price that lands exactly halfway between two minor units to the nearest even unit, matching the rounding used everywhere else in the money surface, instead of always rounding the half up. This only changes the result on an exact tie and by at most one minor unit (for example a price computed as 200.5 now becomes 200 rather than 201). If a pricing regime requires rounding halves up, that is a one-line option change. **Fixed:** *Exact sales-tax and VAT computation* — Sales tax, VAT-inclusive extraction, and VAT-exclusive addition are computed in exact integer arithmetic rather than with a floating-point intermediate, so the tax on a large order total is no longer off by a minor unit. The per-rate owed figure on a tax filing is likewise computed exactly, which matters when a filing window aggregates a very large taxable total. · *Currency-correct prices in wishlist emails* — Price-drop alert emails and the wishlist digest now format prices with each currency's own decimal places and symbol. Zero-decimal currencies (such as JPY) and three-decimal currencies (such as BHD) render correctly, and whole amounts keep their trailing zeros — for example $10.00 instead of 10. · *Exact subscription monthly-revenue normalization* — Normalizing a weekly or annual plan to a monthly figure for revenue reporting now uses exact rational arithmetic instead of a floating-point cadence factor, so the dashboard total is stable and does not drift by a cent between refreshes.
|
package/lib/admin.js
CHANGED
|
@@ -660,21 +660,15 @@ function mount(router, deps) {
|
|
|
660
660
|
var p = await catalog.products.get(id);
|
|
661
661
|
if (!p) return null;
|
|
662
662
|
var variants = await catalog.variants.listForProduct(id);
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
current: await catalog.prices.current(v.id, cur),
|
|
673
|
-
history: await catalog.prices.history(v.id, cur),
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
pricesByVariant[v.id] = { currencies: perCurrency };
|
|
677
|
-
}
|
|
663
|
+
// Every variant's price-per-currency (current + history) in one read.
|
|
664
|
+
// batch.pricesForVariants returns `{ variantId: { currencies:
|
|
665
|
+
// [{ currency, current, history }] } }` byte-identical to the prior
|
|
666
|
+
// per-variant → per-currency (prices.current + prices.history) loop:
|
|
667
|
+
// currencies ASC, current = open row (effective_until IS NULL), history
|
|
668
|
+
// = rows DESC. Unpriced variants map to `{ currencies: [] }`.
|
|
669
|
+
var pricesByVariant = await catalog.batch.pricesForVariants(
|
|
670
|
+
variants.map(function (v) { return v.id; }),
|
|
671
|
+
);
|
|
678
672
|
var media = await catalog.media.listForProduct(id);
|
|
679
673
|
return { product: p, variants: variants, prices_by_variant: pricesByVariant, media: media };
|
|
680
674
|
}
|
|
@@ -1680,9 +1674,14 @@ function mount(router, deps) {
|
|
|
1680
1674
|
if (orderTracking) {
|
|
1681
1675
|
try {
|
|
1682
1676
|
var shipRows = await orderTracking.listForOrder(o.id);
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1677
|
+
// Hydrate every shipment + its labels in parallel — the set is
|
|
1678
|
+
// bounded by shipments-per-order (a handful), so Promise.all
|
|
1679
|
+
// collapses the sequential per-shipment getShipment +
|
|
1680
|
+
// labelsForShipment round trips into one wait. Order is
|
|
1681
|
+
// preserved (Promise.all keeps array position).
|
|
1682
|
+
shipments = await Promise.all(shipRows.map(async function (sRow) {
|
|
1683
|
+
var full = await orderTracking.getShipment(sRow.id);
|
|
1684
|
+
var ship = full || sRow;
|
|
1686
1685
|
// Carrier-label records for the shipment. Best-effort: the
|
|
1687
1686
|
// shipping_labels table may be unmigrated, and the labels
|
|
1688
1687
|
// primitive may not be wired — a read failure leaves the
|
|
@@ -1691,8 +1690,8 @@ function mount(router, deps) {
|
|
|
1691
1690
|
try { ship.labels = await shippingLabels.labelsForShipment(ship.id); }
|
|
1692
1691
|
catch (_le) { ship.labels = []; }
|
|
1693
1692
|
}
|
|
1694
|
-
|
|
1695
|
-
}
|
|
1693
|
+
return ship;
|
|
1694
|
+
}));
|
|
1696
1695
|
} catch (_e) { shipments = []; }
|
|
1697
1696
|
}
|
|
1698
1697
|
// Split-shipment plans for the order. Best-effort, same rationale
|
|
@@ -4658,15 +4657,16 @@ function mount(router, deps) {
|
|
|
4658
4657
|
|
|
4659
4658
|
async function _listForBrowser(filter) {
|
|
4660
4659
|
var rows = await collections.list(filter || {});
|
|
4661
|
-
// Annotate each row with its current size
|
|
4662
|
-
//
|
|
4663
|
-
//
|
|
4660
|
+
// Annotate each row with its current size via collections.countIn:
|
|
4661
|
+
// manual → an exact COUNT(*) (one indexed aggregate, no row
|
|
4662
|
+
// materialisation); smart → the bounded matched-preview count. The
|
|
4663
|
+
// loop is bounded by the collection count (operators have tens, not
|
|
4664
4664
|
// thousands), so this is not an N+1 over an unbounded set.
|
|
4665
4665
|
for (var i = 0; i < rows.length; i += 1) {
|
|
4666
4666
|
var count = null;
|
|
4667
4667
|
try {
|
|
4668
|
-
var
|
|
4669
|
-
count =
|
|
4668
|
+
var c = await collections.countIn(rows[i].slug);
|
|
4669
|
+
count = c.exact != null ? c.exact : c.approx;
|
|
4670
4670
|
} catch (_e) { count = null; }
|
|
4671
4671
|
rows[i]._count = count;
|
|
4672
4672
|
}
|
package/lib/asset-manifest.json
CHANGED
package/lib/catalog.js
CHANGED
|
@@ -1347,6 +1347,69 @@ function _batchModule(query) {
|
|
|
1347
1347
|
for (var i = 0; i < r.rows.length; i += 1) out[r.rows[i].id] = r.rows[i];
|
|
1348
1348
|
return out;
|
|
1349
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
|
+
},
|
|
1350
1413
|
};
|
|
1351
1414
|
}
|
|
1352
1415
|
|
package/lib/collections.js
CHANGED
|
@@ -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
|
};
|
package/package.json
CHANGED