@blamejs/blamejs-shop 0.4.20 → 0.4.21

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.21 (2026-06-06) — **Subscription changes reach the payment processor, smart collections match real products, refunds claw back earned points, and the remaining balance races are closed.** A correctness release across the commerce surfaces. Changing a subscription's quantity now updates the processor before the local record — what the customer sees is what they're billed — and a cadence change on a processor-backed plan is honestly refused rather than silently ignored. Smart collections, which matched zero products in production because their rules read fields the catalog rows never carried, now evaluate against the real product data. Loyalty points earned on a purchase are reversed when the order is refunded or cancelled. And the loyalty, store-credit, gift-registry, and gift-card-ledger write paths that could lose updates or oversell under concurrency now use atomic guards. **Fixed:** *Subscription quantity changes reach the payment processor* — Changing a subscription's quantity updated the local record and showed a success message while the processor kept billing the original amount. The change is now pushed to the processor first — a processor failure leaves the local record untouched and surfaces the error, so the customer-visible state and the billed state can no longer diverge. Changing delivery frequency on a processor-backed subscription is refused with honest guidance (the billing cadence is bound to the plan's price and cannot be re-cadenced in place); self-managed local subscriptions keep their frequency controls. Self-manage controls also now respect the processor's status — a subscription the processor reports as cancelled or expired shows its state instead of live controls. · *Smart collections match real catalog products* — Smart-collection rules read fields like tags, price, and stock from each product row — fields the real catalog listing never carried, so every smart collection matched zero products in production even though admin previews built on richer mock rows looked right. Rule evaluation now joins the real tag, category, vendor, price, and inventory data onto each product page, and the admin preview routes through the same path the storefront uses. Smart-collection pages also stop re-walking the entire catalog on every paginated request — the matched set is briefly cached and a rule edit invalidates it. · *Refunds and cancellations reverse the loyalty points the order earned* — Points awarded when an order was paid survived a refund or cancellation — buying, earning, and refunding farmed points indefinitely. The earn record is now claimed atomically on the order's refund or cancel transition and the awarded points are clawed back from the balance, floored at zero when some were already spent. The reversal is idempotent (a re-delivered payment webhook reverses exactly once) and lifetime points — which drive tier — are deliberately untouched. · *Balance and inventory-adjacent races closed across loyalty, store credit, gift registry, and gift-card ledger* — Loyalty earn and adjust used read-then-write absolute updates that could lose concurrent updates; the gift-registry purchase check could oversell a registry item under two simultaneous purchases; and the gift-card ledger's overdraft check could let two concurrent debits both pass. All of these now use single-statement conditional writes that refuse cleanly when the guard fails. Store-credit expiry sweeps also stop under-expiring when operator-initiated deductions exist — the sweep now keys on its own prior output rather than netting all expiry rows together. · *Search-ranking click-through metrics are bounded and attributed* — The ranking metrics screen could show click-through rates above 100%, and clicks were attributed from the query string alone — trivially spoofable. A click now only counts when the same session recorded a real impression for that query, and displayed rates are bounded at 100%.
12
+
11
13
  - v0.4.20 (2026-06-06) — **Payment integrity: refunds, gift-card balances, and checkout submission are race-proof, and abandoned checkouts return gift-card funds.** A money-path hardening release. Concurrent refund clicks on the same return can no longer reach the payment provider twice. A gift card that part-paid a checkout gets its balance back when the order is cancelled, fails payment, or is reaped as stale. Double-submitting checkout can no longer create two charges and two orders. Return requests are refused server-side on orders that aren't in a returnable state, the refund confirmation screen shows the currency the provider will actually refund, and a buy-online-pickup-in-store order now reaches its delivered state when picked up. **Fixed:** *A return can only be refunded once, even under concurrent requests* — Two simultaneous refund requests for the same approved return could both pass the status check and both reach the payment provider, each with a fresh idempotency key — a double refund. The refund now claims the return atomically before the provider is called (the second request answers 409), the provider call uses a key derived from the return itself so even a retry collapses into the same refund, and a provider failure releases the claim so the refund can be retried. · *Gift-card funds come back when a part-paid checkout dies* — When a gift card partially covered a checkout and the order was then cancelled, failed payment, or sat abandoned until reaped, the card's debit was never returned — the balance was permanently gone. Redemptions are now reversed on those order transitions: the card balance is restored (a fully drained card is reactivated), the reversal is idempotent, and it rides the same order lifecycle that releases inventory holds. · *Double-submitting checkout creates one charge and one order* — Two concurrent checkout submissions could both pass the cart-status read and each create a payment intent and an order. The cart is now claimed atomically as the single gate — the second submission is redirected back to the cart — and the payment-provider idempotency key is derived from the cart, so even a duplicate that slipped through would collapse into one charge on the provider's side. A failure before the order exists releases the claim so the customer can retry. · *Return requests are validated server-side* — The return-request form and its submission are now refused on orders that are not in a returnable state — a refunded, cancelled, or unpaid order answers with a clear message instead of opening a return that could feed a second refund downstream. Previously only the button's visibility enforced this. · *A failed gift-card settlement no longer strands a completed payment* — If recording a gift card's redemption failed after the payment had already succeeded and the order existed, the checkout aborted mid-way — leaving a paid order with a cart that never converted. The settlement step now completes the order regardless and surfaces the failure for follow-up instead of stranding the purchase. · *The refund confirmation shows the currency the provider refunds* — The confirmation screen displayed the return's approved currency, but the provider refunds in the original charge's currency. The screen now reads the same source the refund uses. · *Pickup orders reach their delivered state* — Marking a pickup order as picked up drove an order transition that was only legal for shipped orders, and the failure was silently swallowed — the pickup schedule said picked up while the order stayed in its paid state. The order lifecycle now has a pickup-completion transition, so a picked-up order genuinely lands in delivered and downstream reporting sees it.
12
14
 
13
15
  - v0.4.19 (2026-06-06) — **Wishlist alerts fire once per event instead of repeating daily, stale digests catch up with a single email, and webhook delivery stops throttling itself.** A set of notification and delivery fixes. Back-in-stock and price-drop wishlist alerts now fire only when something actually changes — a steadily in-stock item no longer re-alerts every day. A wishlist digest whose schedule fell behind sends one catch-up email instead of one per minute until current. Outbound webhook delivery no longer counts its own rate-limit refusals against the limit (which kept the throttle tripped once hit), retrying an exhausted delivery no longer duplicates its dead-letter record, and the blog RSS feed renders correctly when a post title or body contains dollar-sign sequences. **Fixed:** *Back-in-stock and price-drop alerts fire on the change, not the state* — The wishlist alert sweep evaluated the current state of each item, so anything in stock re-alerted every wishlister on every sweep — the daily dedupe window just made the flood daily. Alerts now track the last-observed state per customer and item: back-in-stock fires only on an out-of-stock-to-in-stock transition, price-drop only when the price falls below the level already alerted, and a recovered price re-arms the alert. A steadily in-stock item never re-fires. The weekly cap and dedupe are also enforced atomically, so two overlapping sweeps can't double-send past the cap. · *A stale digest enrollment catches up with one email* — A wishlist digest whose next-send time had fallen far behind advanced one period per scheduler tick and sent an email each time — a subscriber could receive dozens of digests in an hour while the schedule caught up. Catching up now snaps the schedule to the present and sends at most one digest. · *Webhook delivery rate limiting no longer feeds on its own refusals* — The outbound webhook rate-limit gate counted its own refusal records toward the limit, so once an endpoint tripped the throttle it could stay tripped indefinitely, and every suppressed attempt persisted another row. Refusals are no longer counted by the gate and collapse to a single record per endpoint per window. · *Retrying an exhausted webhook delivery is idempotent* — Manually retrying a delivery that had already exhausted its attempts inserted a fresh dead-letter record on every click. Retry of an exhausted or already-delivered delivery now answers as a clean no-op, and the dead-letter table enforces one record per delivery. · *The RSS feed renders posts containing dollar-sign sequences* — The feed assembled items with a plain string replacement, which gives `$` sequences special meaning — a post title or body containing a dollar sign followed by a backtick or ampersand could corrupt the entire feed XML. The feed now uses the same literal splice the page renderers use. The feed's author field also no longer exposes an internal identifier; it carries the shop name, matching the blog pages.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.20",
2
+ "version": "0.4.21",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -248,21 +248,45 @@ function _now() { return Date.now(); }
248
248
  // in isolation against a synthetic product.
249
249
  function _matchRule(rule, product) {
250
250
  var fieldVal = product == null ? undefined : product[rule.field];
251
+ // A catalog field can be MULTI-VALUED — a product carries an array
252
+ // of `tags` and an array of `category` memberships. For the scalar
253
+ // ops (`eq` / `neq` / `in` / `not_in`) an array field matches with
254
+ // membership semantics ("any of the product's categories equals X").
255
+ // A scalar field value keeps strict-equality semantics, so the
256
+ // existing single-valued fields (`vendor`, numeric fields) are
257
+ // unaffected. This is what lets a smart rule like
258
+ // `{ field: "category", op: "eq", value: "apparel" }` match a
259
+ // product that lives in apparel + clearance + outdoor.
260
+ var isArrayVal = Array.isArray(fieldVal);
251
261
  switch (rule.op) {
252
- case "eq": return fieldVal === rule.value;
253
- case "neq": return fieldVal !== rule.value;
262
+ case "eq": return isArrayVal ? fieldVal.indexOf(rule.value) >= 0 : fieldVal === rule.value;
263
+ case "neq": return isArrayVal ? fieldVal.indexOf(rule.value) < 0 : fieldVal !== rule.value;
254
264
  case "contains": {
255
265
  // Validator guaranteed an array field; if the catalog row
256
266
  // hasn't populated it, treat as empty (not a match).
257
- if (!Array.isArray(fieldVal)) return false;
267
+ if (!isArrayVal) return false;
258
268
  return fieldVal.indexOf(rule.value) >= 0;
259
269
  }
260
270
  case "gt": return typeof fieldVal === "number" && fieldVal > rule.value;
261
271
  case "gte": return typeof fieldVal === "number" && fieldVal >= rule.value;
262
272
  case "lt": return typeof fieldVal === "number" && fieldVal < rule.value;
263
273
  case "lte": return typeof fieldVal === "number" && fieldVal <= rule.value;
264
- case "in": return rule.value.indexOf(fieldVal) >= 0;
265
- case "not_in": return rule.value.indexOf(fieldVal) < 0;
274
+ case "in":
275
+ if (isArrayVal) {
276
+ for (var ai = 0; ai < fieldVal.length; ai += 1) {
277
+ if (rule.value.indexOf(fieldVal[ai]) >= 0) return true;
278
+ }
279
+ return false;
280
+ }
281
+ return rule.value.indexOf(fieldVal) >= 0;
282
+ case "not_in":
283
+ if (isArrayVal) {
284
+ for (var ni = 0; ni < fieldVal.length; ni += 1) {
285
+ if (rule.value.indexOf(fieldVal[ni]) >= 0) return false;
286
+ }
287
+ return true;
288
+ }
289
+ return rule.value.indexOf(fieldVal) < 0;
266
290
  case "between": {
267
291
  if (typeof fieldVal !== "number") return false;
268
292
  return fieldVal >= rule.value[0] && fieldVal <= rule.value[1];
@@ -299,6 +323,20 @@ function _evaluateRules(input) {
299
323
  return _matchRuleset(rules, product);
300
324
  }
301
325
 
326
+ // Collect the distinct catalog fields a (validated) rule set reads, so
327
+ // the smart-eval path can enrich ONLY the fields a collection actually
328
+ // references rather than every supported field on every product. `rules`
329
+ // is the `{ all, any }` shape `_validateRules` returns.
330
+ function _fieldsReferenced(rules) {
331
+ var seen = Object.create(null);
332
+ function _walk(list) {
333
+ for (var i = 0; i < list.length; i += 1) seen[list[i].field] = true;
334
+ }
335
+ _walk(rules.all);
336
+ _walk(rules.any);
337
+ return Object.keys(seen);
338
+ }
339
+
302
340
  // ---- sort comparators for smart collections ------------------------------
303
341
 
304
342
  // Best-selling needs a sales-rank on the product object; the catalog
@@ -369,6 +407,63 @@ function create(opts) {
369
407
  }
370
408
  var cursorSecret = opts.cursorSecret;
371
409
 
410
+ // Smart-collection matched-roster cache. A smart collection has no
411
+ // materialised membership — `productsIn` walks the WHOLE catalog,
412
+ // evaluates rules, sorts, then slices the requested page. Paginating
413
+ // a smart collection therefore re-walked the entire catalog on every
414
+ // page request (page 2 redid the work page 1 already did). This brief
415
+ // in-process cache holds the sorted matched roster keyed by the
416
+ // collection's identity + rule fingerprint, so successive page
417
+ // requests within the TTL slice the cached roster instead of
418
+ // re-walking. The TTL is short (a smart collection that just lost a
419
+ // product to an archive shows the stale member for at most one TTL)
420
+ // and configurable; pass `smartCacheTtlMs: 0` to disable. The cache
421
+ // is per-factory-instance and bounded by an LRU-ish cap so it can't
422
+ // grow unbounded across many distinct collections.
423
+ var SMART_CACHE_TTL_MS = opts.smartCacheTtlMs != null
424
+ ? opts.smartCacheTtlMs
425
+ : b.constants.TIME.seconds(30);
426
+ if (typeof SMART_CACHE_TTL_MS !== "number" || !isFinite(SMART_CACHE_TTL_MS) || SMART_CACHE_TTL_MS < 0) {
427
+ throw new TypeError("collections.create: smartCacheTtlMs must be a non-negative number of ms");
428
+ }
429
+ var SMART_CACHE_MAX_ENTRIES = 64;
430
+ var _smartCache = new Map(); // key -> { roster, expires_at }
431
+
432
+ function _smartCacheKey(slug, row) {
433
+ // The rule fingerprint + sort_strategy + updated_at are part of the
434
+ // key, so re-defining a smart collection's rules (which bumps
435
+ // updated_at and rewrites rules_json) misses the prior entry and
436
+ // re-walks — no stale roster after an operator edit. NUL joins the
437
+ // parts (it can't appear in a slug, timestamp, sort strategy, or
438
+ // JSON, so the parts can't collide), written as an escape so the
439
+ // source stays plain text.
440
+ return slug + "\u0000" + (row.updated_at == null ? "" : row.updated_at) +
441
+ "\u0000" + (row.sort_strategy || "") + "\u0000" + (row.rules_json || "");
442
+ }
443
+
444
+ function _smartCacheGet(key) {
445
+ if (SMART_CACHE_TTL_MS === 0) return null;
446
+ var hit = _smartCache.get(key);
447
+ if (!hit) return null;
448
+ if (hit.expires_at <= _now()) { _smartCache.delete(key); return null; }
449
+ // Refresh LRU recency.
450
+ _smartCache.delete(key);
451
+ _smartCache.set(key, hit);
452
+ return hit.roster;
453
+ }
454
+
455
+ function _smartCacheSet(key, roster) {
456
+ if (SMART_CACHE_TTL_MS === 0) return;
457
+ if (_smartCache.size >= SMART_CACHE_MAX_ENTRIES) {
458
+ // Evict the least-recently-used entry (first key in insertion
459
+ // order — `get` re-inserts on hit so live entries drift to the
460
+ // tail).
461
+ var oldest = _smartCache.keys().next().value;
462
+ if (oldest !== undefined) _smartCache.delete(oldest);
463
+ }
464
+ _smartCache.set(key, { roster: roster, expires_at: _now() + SMART_CACHE_TTL_MS });
465
+ }
466
+
372
467
  // ---- internal helpers --------------------------------------------------
373
468
 
374
469
  async function _row(slug) {
@@ -406,8 +501,12 @@ function create(opts) {
406
501
  // caller-supplied visitor. The catalog is expected to expose
407
502
  // `products.list({ limit, cursor?, status? })`; the smart-eval
408
503
  // path filters to `status = 'active'` so archived rows never leak
409
- // into a smart collection.
410
- async function _walkCatalogActive(visit) {
504
+ // into a smart collection. `enrichFields` (optional) is the list of
505
+ // smart-rule fields to join onto each page's rows before the visitor
506
+ // sees them, so the rule evaluator reads real catalog data rather
507
+ // than `undefined` — enrichment is batched per page (one query per
508
+ // referenced supporting table per page, not per product).
509
+ async function _walkCatalogActive(visit, enrichFields) {
411
510
  var cursor = null;
412
511
  var pages = 0;
413
512
  var pageLimit = 200;
@@ -419,6 +518,7 @@ function create(opts) {
419
518
  while (pages < MAX_PAGES) {
420
519
  var page = await catalog.products.list({ status: "active", limit: pageLimit, cursor: cursor });
421
520
  var rows = (page && page.rows) || [];
521
+ if (enrichFields && enrichFields.length) await _enrichRows(rows, enrichFields);
422
522
  for (var i = 0; i < rows.length; i += 1) await visit(rows[i]);
423
523
  cursor = (page && page.next_cursor) || null;
424
524
  pages += 1;
@@ -427,6 +527,149 @@ function create(opts) {
427
527
  throw new Error("collections: catalog walk exceeded " + MAX_PAGES + " pages — pre-materialise smart membership");
428
528
  }
429
529
 
530
+ // ---- smart-rule field enrichment --------------------------------------
531
+
532
+ // The catalog's `products.list` returns the bare product row — id,
533
+ // slug, title, description, status, created_at, updated_at. A smart
534
+ // collection's rules read `tags`, `category`, `vendor`, `price_minor`,
535
+ // and `inventory_count`, none of which live on that row; they're
536
+ // joined from supporting tables (`product_tags`, `product_categories`,
537
+ // `vendor_skus` via `variants.sku`, `prices`, `inventory`). Without
538
+ // this enrichment every rule on those fields evaluated against
539
+ // `undefined` and a smart collection matched ZERO products in
540
+ // production while the unit-test mock (which pre-stuffs the fields)
541
+ // stayed green — a dark feature.
542
+ //
543
+ // Enrichment is batched per catalog page and scoped to ONLY the fields
544
+ // the active rule set references (computed once by the caller), so a
545
+ // collection that filters on price alone never pays for the tag /
546
+ // category / vendor joins. A row that ALREADY carries a field (e.g. a
547
+ // caller that pre-decorated, or the test mock) is left untouched — the
548
+ // enrichment only fills gaps. Each supporting-table read is wrapped so
549
+ // an unmigrated table degrades the field to its empty form (empty
550
+ // array / null / 0) rather than throwing out of the catalog walk.
551
+ async function _enrichRows(rows, fields) {
552
+ if (!rows.length || !fields.length) return;
553
+ var need = {};
554
+ for (var f = 0; f < fields.length; f += 1) need[fields[f]] = true;
555
+
556
+ // Only enrich product rows that are MISSING the requested field —
557
+ // gather their ids per field so a pre-decorated row is never
558
+ // re-queried.
559
+ var ids = [];
560
+ var idSeen = Object.create(null);
561
+ for (var i = 0; i < rows.length; i += 1) {
562
+ var id = rows[i] && rows[i].id;
563
+ if (id == null) continue;
564
+ if (!idSeen[id]) { idSeen[id] = true; ids.push(id); }
565
+ }
566
+ if (!ids.length) return;
567
+ var ph = ids.map(function (_v, n) { return "?" + (n + 1); }).join(",");
568
+
569
+ async function _safeQuery(sql, params) {
570
+ try { return (await query(sql, params)).rows; }
571
+ catch (_e) { return []; } // unmigrated supporting table → empty
572
+ }
573
+
574
+ var tagsByProduct = null;
575
+ if (need.tags) {
576
+ tagsByProduct = Object.create(null);
577
+ var tagRows = await _safeQuery(
578
+ "SELECT product_id, tag FROM product_tags WHERE product_id IN (" + ph + ")",
579
+ ids,
580
+ );
581
+ for (var t = 0; t < tagRows.length; t += 1) {
582
+ var tp = tagRows[t].product_id;
583
+ (tagsByProduct[tp] || (tagsByProduct[tp] = [])).push(tagRows[t].tag);
584
+ }
585
+ }
586
+
587
+ var catByProduct = null;
588
+ if (need.category) {
589
+ catByProduct = Object.create(null);
590
+ var catRows = await _safeQuery(
591
+ "SELECT product_id, category FROM product_categories WHERE product_id IN (" + ph + ")",
592
+ ids,
593
+ );
594
+ for (var c = 0; c < catRows.length; c += 1) {
595
+ var cp = catRows[c].product_id;
596
+ (catByProduct[cp] || (catByProduct[cp] = [])).push(catRows[c].category);
597
+ }
598
+ }
599
+
600
+ var vendorByProduct = null;
601
+ if (need.vendor) {
602
+ vendorByProduct = Object.create(null);
603
+ // Vendor binds to a SKU (vendor_skus.sku), and a SKU belongs to a
604
+ // variant of a product. A product with several variants could in
605
+ // principle map to more than one vendor; the smart-rule field is
606
+ // scalar, so we take the lexicographically-smallest assigned
607
+ // vendor slug for determinism. Products with no assigned vendor
608
+ // get null (no row), and the rule on `vendor` simply won't match.
609
+ var venRows = await _safeQuery(
610
+ "SELECT v.product_id AS product_id, MIN(vs.vendor_slug) AS vendor " +
611
+ "FROM variants v JOIN vendor_skus vs ON vs.sku = v.sku " +
612
+ "WHERE v.product_id IN (" + ph + ") GROUP BY v.product_id",
613
+ ids,
614
+ );
615
+ for (var vi = 0; vi < venRows.length; vi += 1) {
616
+ vendorByProduct[venRows[vi].product_id] = venRows[vi].vendor;
617
+ }
618
+ }
619
+
620
+ var priceByProduct = null;
621
+ if (need.price_minor) {
622
+ priceByProduct = Object.create(null);
623
+ // Lowest CURRENT price across the product's variants — the
624
+ // `effective_until IS NULL` row is the live price the catalog's
625
+ // own decorator reads. "Starting at" is the natural price a
626
+ // price-bounded smart rule (`price_minor < 5000`) should test.
627
+ var priceRows = await _safeQuery(
628
+ "SELECT v.product_id AS product_id, MIN(pr.amount_minor) AS price_minor " +
629
+ "FROM variants v JOIN prices pr ON pr.variant_id = v.id " +
630
+ "WHERE v.product_id IN (" + ph + ") AND pr.effective_until IS NULL " +
631
+ "GROUP BY v.product_id",
632
+ ids,
633
+ );
634
+ for (var pi = 0; pi < priceRows.length; pi += 1) {
635
+ priceByProduct[priceRows[pi].product_id] = priceRows[pi].price_minor;
636
+ }
637
+ }
638
+
639
+ var invByProduct = null;
640
+ if (need.inventory_count) {
641
+ invByProduct = Object.create(null);
642
+ // Total on-hand units across the product's variant SKUs. A rule
643
+ // like `inventory_count > 0` ("only in-stock") reads this.
644
+ var invRows = await _safeQuery(
645
+ "SELECT v.product_id AS product_id, COALESCE(SUM(inv.stock_on_hand), 0) AS inventory_count " +
646
+ "FROM variants v JOIN inventory inv ON inv.sku = v.sku " +
647
+ "WHERE v.product_id IN (" + ph + ") GROUP BY v.product_id",
648
+ ids,
649
+ );
650
+ for (var ii = 0; ii < invRows.length; ii += 1) {
651
+ invByProduct[invRows[ii].product_id] = invRows[ii].inventory_count;
652
+ }
653
+ }
654
+
655
+ for (var r = 0; r < rows.length; r += 1) {
656
+ var row = rows[r];
657
+ var rid = row && row.id;
658
+ if (rid == null) continue;
659
+ if (need.tags && !("tags" in row)) row.tags = tagsByProduct[rid] || [];
660
+ if (need.category && !("category" in row)) row.category = catByProduct[rid] || [];
661
+ if (need.vendor && !("vendor" in row)) {
662
+ row.vendor = Object.prototype.hasOwnProperty.call(vendorByProduct, rid) ? vendorByProduct[rid] : null;
663
+ }
664
+ if (need.price_minor && !("price_minor" in row)) {
665
+ row.price_minor = Object.prototype.hasOwnProperty.call(priceByProduct, rid) ? priceByProduct[rid] : null;
666
+ }
667
+ if (need.inventory_count && !("inventory_count" in row)) {
668
+ row.inventory_count = Object.prototype.hasOwnProperty.call(invByProduct, rid) ? invByProduct[rid] : 0;
669
+ }
670
+ }
671
+ }
672
+
430
673
  // ---- defineManual ------------------------------------------------------
431
674
 
432
675
  async function defineManual(input) {
@@ -824,12 +1067,22 @@ function create(opts) {
824
1067
  }
825
1068
  }
826
1069
 
827
- var matched = [];
828
- await _walkCatalogActive(function (product) {
829
- if (_matchRuleset(rules, product)) matched.push(product);
830
- });
831
- var sortStrategy = row.sort_strategy === "manual" ? "newest" : row.sort_strategy;
832
- matched.sort(_smartCompare(sortStrategy));
1070
+ // Reuse the cached sorted roster when a recent page request already
1071
+ // walked + sorted this exact (slug, rules, sort, updated_at)
1072
+ // combination; otherwise walk the catalog once, sort, and cache.
1073
+ // Paginating no longer re-walks the whole catalog per page.
1074
+ var cacheKey = _smartCacheKey(input.slug, row);
1075
+ var matched = _smartCacheGet(cacheKey);
1076
+ if (matched == null) {
1077
+ var enrichFields = _fieldsReferenced(rules);
1078
+ matched = [];
1079
+ await _walkCatalogActive(function (product) {
1080
+ if (_matchRuleset(rules, product)) matched.push(product);
1081
+ }, enrichFields);
1082
+ var sortStrategy = row.sort_strategy === "manual" ? "newest" : row.sort_strategy;
1083
+ matched.sort(_smartCompare(sortStrategy));
1084
+ _smartCacheSet(cacheKey, matched);
1085
+ }
833
1086
  var slice = matched.slice(startIdx, startIdx + limit);
834
1087
  var nextS = null;
835
1088
  if (startIdx + slice.length < matched.length) {
@@ -879,12 +1132,27 @@ function create(opts) {
879
1132
  "SELECT * FROM collections WHERE type = 'smart' AND archived_at IS NULL",
880
1133
  [],
881
1134
  );
1135
+ // Parse every active smart rule set once, collecting the UNION
1136
+ // of catalog fields they read so the single product can be
1137
+ // enriched with real tag / category / vendor / price /
1138
+ // inventory data in one batch before any rule evaluates —
1139
+ // mirroring the productsIn smart path. Without this the reverse
1140
+ // lookup matched zero smart collections for the same dark-feature
1141
+ // reason the forward path did.
1142
+ var parsed = [];
1143
+ var fieldSeen = Object.create(null);
882
1144
  for (var s = 0; s < smartRows.rows.length; s += 1) {
883
1145
  if (seen[smartRows.rows[s].slug]) continue;
884
1146
  var rules;
885
1147
  try { rules = JSON.parse(smartRows.rows[s].rules_json); }
886
1148
  catch (_e) { continue; }
887
- if (_matchRuleset(rules, product)) out.push(_decode(smartRows.rows[s]));
1149
+ parsed.push({ row: smartRows.rows[s], rules: rules });
1150
+ var refs = _fieldsReferenced(rules);
1151
+ for (var rf = 0; rf < refs.length; rf += 1) fieldSeen[refs[rf]] = true;
1152
+ }
1153
+ await _enrichRows([product], Object.keys(fieldSeen));
1154
+ for (var pj = 0; pj < parsed.length; pj += 1) {
1155
+ if (_matchRuleset(parsed[pj].rules, product)) out.push(_decode(parsed[pj].row));
888
1156
  }
889
1157
  }
890
1158
  }
@@ -240,24 +240,53 @@ function create(opts) {
240
240
  var requested = _epochMs(input.occurred_at, "occurred_at");
241
241
  if (requested == null) requested = _now();
242
242
 
243
- var latest = await _readLatest(giftCardId);
244
- if (amount > latest.balance) {
243
+ // Atomic guarded INSERT — the overdraft check and the row write
244
+ // happen in ONE statement so two concurrent debits can't both
245
+ // read the same balance, both pass the check, and both write
246
+ // (the read-then-write race that corrupted `balance_after_minor`).
247
+ // The latest row's snapshot (balance + occurred_at) is read by
248
+ // correlated scalar subqueries INSIDE the INSERT; the WHERE gates
249
+ // the write on `current_balance >= amount` against that live
250
+ // value, and the inserted `balance_after_minor` / monotonic
251
+ // `occurred_at` are derived from the same subqueries — so on D1
252
+ // (where a single statement is atomic) exactly one of two racing
253
+ // debits lands. rowCount === 0 means the guard refused: either a
254
+ // genuine overdraft or the loser of a race.
255
+ var id = b.uuid.v7();
256
+ var balSub =
257
+ "COALESCE((SELECT balance_after_minor FROM gift_card_ledger " +
258
+ "WHERE gift_card_id = ?2 ORDER BY occurred_at DESC LIMIT 1), 0)";
259
+ var tsSub =
260
+ "(SELECT occurred_at FROM gift_card_ledger " +
261
+ "WHERE gift_card_id = ?2 ORDER BY occurred_at DESC LIMIT 1)";
262
+ var ins = await query(
263
+ "INSERT INTO gift_card_ledger " +
264
+ "(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at) " +
265
+ "SELECT ?1, ?2, 'debit', ?3, NULL, NULL, ?4, " +
266
+ balSub + " - ?3, " +
267
+ "CASE WHEN ?5 > COALESCE(" + tsSub + ", 0) THEN ?5 ELSE COALESCE(" + tsSub + ", 0) + 1 END " +
268
+ "WHERE " + balSub + " >= ?3",
269
+ [id, giftCardId, amount, orderId, requested],
270
+ );
271
+ if (Number(ins.rowCount || 0) === 0) {
245
272
  var insufficient = new Error("giftCardLedger.debit: amount exceeds available balance");
246
273
  insufficient.code = "GIFT_CARD_LEDGER_INSUFFICIENT_BALANCE";
247
274
  throw insufficient;
248
275
  }
249
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
250
- var after = latest.balance - amount;
251
- var id = await _writeRow(giftCardId, "debit", amount, null, null, orderId, after, ts);
252
-
276
+ // Re-read the row we just wrote to surface the resolved
277
+ // balance_after / occurred_at without recomputing them client-side.
278
+ var wrote = (await query(
279
+ "SELECT balance_after_minor, occurred_at FROM gift_card_ledger WHERE id = ?1",
280
+ [id],
281
+ )).rows[0];
253
282
  return {
254
283
  id: id,
255
284
  gift_card_id: giftCardId,
256
285
  kind: "debit",
257
286
  amount_minor: amount,
258
287
  order_id: orderId,
259
- balance_after_minor: after,
260
- occurred_at: ts,
288
+ balance_after_minor: wrote ? wrote.balance_after_minor : null,
289
+ occurred_at: wrote ? wrote.occurred_at : requested,
261
290
  };
262
291
  },
263
292
 
@@ -622,10 +622,12 @@ function create(opts) {
622
622
  if (item.archived_at != null) {
623
623
  throw new TypeError("giftRegistry.purchaseItem: item " + JSON.stringify(itemId) + " has been removed");
624
624
  }
625
- // Refuse over-purchase: aggregate prior purchases + this one
626
- // must not exceed `quantity_desired`. The owner's wish-list is
625
+ // Refuse over-purchase: aggregate prior purchases + this one must
626
+ // not exceed `quantity_desired`. The owner's wish-list is
627
627
  // authoritative — a fourth blender is not a gift, it's a return
628
- // pending.
628
+ // pending. The pre-read below only shapes a friendly "remaining N"
629
+ // error for the common (uncontended) case; the AUTHORITATIVE check
630
+ // is the guarded INSERT that follows.
629
631
  var priorRes = await query(
630
632
  "SELECT COALESCE(SUM(quantity), 0) AS sum FROM gift_registry_purchases " +
631
633
  "WHERE registry_slug = ?1 AND item_id = ?2",
@@ -644,12 +646,48 @@ function create(opts) {
644
646
 
645
647
  var id = b.uuid.v7();
646
648
  var ts = _now();
647
- await query(
649
+ // Atomic guarded INSERT — the SUM-of-prior-purchases check and the
650
+ // purchase write land in ONE statement so two concurrent buyers
651
+ // can't both read the same prior sum, both pass the pre-check, and
652
+ // both insert (the read-then-write race that let purchases exceed
653
+ // `quantity_desired`). The constraint is re-evaluated against the
654
+ // LIVE purchase sum + the item's live `quantity_desired` inside the
655
+ // INSERT; the item must also still exist and be unarchived. On D1 a
656
+ // single statement is atomic, so exactly one of two racing
657
+ // purchases for the last unit lands. rowCount === 0 means the guard
658
+ // refused — a concurrent purchase claimed the remaining quantity
659
+ // first (or the item was archived between the pre-check and here).
660
+ var ins = await query(
648
661
  "INSERT INTO gift_registry_purchases " +
649
662
  "(id, registry_slug, item_id, quantity, buyer_customer_id, buyer_message, reveal_buyer, occurred_at) " +
650
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
663
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 " +
664
+ "WHERE EXISTS (" +
665
+ " SELECT 1 FROM gift_registry_items gi " +
666
+ " WHERE gi.id = ?3 AND gi.registry_slug = ?2 AND gi.archived_at IS NULL " +
667
+ " AND (" +
668
+ " SELECT COALESCE(SUM(gp.quantity), 0) FROM gift_registry_purchases gp " +
669
+ " WHERE gp.registry_slug = ?2 AND gp.item_id = ?3" +
670
+ " ) + ?4 <= gi.quantity_desired" +
671
+ ")",
651
672
  [id, slug, itemId, qty, buyerId, buyerMsg, reveal ? 1 : 0, ts],
652
673
  );
674
+ if (Number(ins.rowCount || 0) === 0) {
675
+ // Lost the race (or the item was archived between the pre-check
676
+ // and the guarded INSERT). Recompute the live remaining for an
677
+ // honest message; the write applied nothing.
678
+ var liveSum = Number((await query(
679
+ "SELECT COALESCE(SUM(quantity), 0) AS sum FROM gift_registry_purchases " +
680
+ "WHERE registry_slug = ?1 AND item_id = ?2",
681
+ [slug, itemId],
682
+ )).rows[0].sum || 0);
683
+ var raced = new Error(
684
+ "giftRegistry.purchaseItem: quantity " + qty +
685
+ " exceeds remaining " + Math.max(0, item.quantity_desired - liveSum) +
686
+ " on item " + itemId,
687
+ );
688
+ raced.code = "GIFT_REGISTRY_OVER_PURCHASE";
689
+ throw raced;
690
+ }
653
691
  await query(
654
692
  "UPDATE gift_registries SET updated_at = ?1 WHERE slug = ?2",
655
693
  [ts, slug],
@@ -648,6 +648,111 @@ function create(opts) {
648
648
  };
649
649
  }
650
650
 
651
+ // ---- reverseForEvent ------------------------------------------------
652
+
653
+ // Reverse every award booked for one (customer, event) — the
654
+ // counterpart to awardForEvent on an order's cancel / refund edge. A
655
+ // paid order awards points; if that order later dies the points must
656
+ // come back off the balance or a buy-then-refund mints free rewards.
657
+ //
658
+ // The earn-log `reversed_at` claim IS the idempotency guard: the
659
+ // `UPDATE ... WHERE reversed_at IS NULL` serializes a concurrent
660
+ // double-fire (a re-delivered webhook, or the stale-order reaper
661
+ // racing a refund) so the points are clawed back exactly once. A
662
+ // never-awarded event (a guest order, or one that never reached paid)
663
+ // claims zero rows and is a natural no-op — no paid-state precondition
664
+ // needed. Returns { reversed_points, clawed_points }: reversed_points
665
+ // is what the awards totalled; clawed_points is what actually came off
666
+ // the balance (floored at zero — a customer may have already spent the
667
+ // points, and the balance can't go negative).
668
+ async function reverseForEvent(input) {
669
+ if (!input || typeof input !== "object") {
670
+ throw new TypeError("loyaltyEarnRules.reverseForEvent: input object required");
671
+ }
672
+ var customerId = _uuid(input.customer_id, "customer_id");
673
+ var triggerEventRef = _triggerEventRef(input.trigger_event_ref);
674
+
675
+ // Atomic claim across every rule that awarded for this event. The
676
+ // unreversed predicate is the serialization point — a row claimed
677
+ // here can't be claimed by a racing reversal, and an already-reversed
678
+ // (or never-awarded) event claims nothing.
679
+ var ts = _now();
680
+ var claim = await query(
681
+ "UPDATE loyalty_earn_log SET reversed_at = ?1 " +
682
+ "WHERE customer_id = ?2 AND trigger_event_ref = ?3 AND reversed_at IS NULL",
683
+ [ts, customerId, triggerEventRef],
684
+ );
685
+ if (Number(claim.rowCount || 0) === 0) {
686
+ return { reversed_points: 0, clawed_points: 0 };
687
+ }
688
+
689
+ // Sum exactly the rows this call claimed (reversed_at === ts pins them
690
+ // to this reversal, not an earlier one against the same event).
691
+ var sumRow = (await query(
692
+ "SELECT COALESCE(SUM(points_awarded), 0) AS earned FROM loyalty_earn_log " +
693
+ "WHERE customer_id = ?1 AND trigger_event_ref = ?2 AND reversed_at = ?3",
694
+ [customerId, triggerEventRef, ts],
695
+ )).rows[0] || { earned: 0 };
696
+ var earned = Number(sumRow.earned || 0);
697
+
698
+ // Claw the earned points back off the running balance, floored at
699
+ // zero. loyalty.adjust refuses an underflow by THROWING an Error with
700
+ // code LOYALTY_INSUFFICIENT_BALANCE; a concurrent spend can shrink the
701
+ // balance between our read and the adjust, so on that refusal we
702
+ // re-read and retry against the smaller balance (≤3 attempts). The
703
+ // non-negative guard inside adjust makes the race safe — the worst
704
+ // case is we claw less, never below zero, never negative. Lifetime is
705
+ // not decremented (adjust's stance — tier never downgrades
706
+ // retroactively). Skip the adjust entirely when there's nothing to
707
+ // claw (adjust requires a non-zero delta).
708
+ var clawed = 0;
709
+ if (loyaltyHandle && typeof loyaltyHandle.adjust === "function"
710
+ && typeof loyaltyHandle.balance === "function" && earned > 0) {
711
+ try {
712
+ for (var attempt = 0; attempt < 3; attempt += 1) {
713
+ var bal = await loyaltyHandle.balance(customerId);
714
+ var claw = Math.min(earned, Number((bal && bal.balance) || 0));
715
+ if (claw <= 0) break;
716
+ try {
717
+ await loyaltyHandle.adjust({
718
+ customer_id: customerId,
719
+ points: -claw,
720
+ source: "earn-reversal",
721
+ notes: "reversed ref=" + triggerEventRef,
722
+ });
723
+ clawed = claw;
724
+ break;
725
+ } catch (err) {
726
+ // A concurrent spend drained the balance below `claw` between
727
+ // the read and the adjust. Re-read and retry against the new,
728
+ // smaller balance. Any other failure escapes to the claim
729
+ // release below.
730
+ if (!(err && err.code === "LOYALTY_INSUFFICIENT_BALANCE")) throw err;
731
+ }
732
+ }
733
+ } catch (clawErr) {
734
+ // The clawback failed for a reason that is NOT the floor-at-zero
735
+ // refusal (a transient DB fault, an unmigrated ledger). Holding
736
+ // the claim would be a silent loss: a retry would see the rows
737
+ // already reversed and no-op while the balance keeps the points.
738
+ // Release the claim so a later reversal can run, then surface the
739
+ // original failure. The release is best-effort — if it ALSO
740
+ // fails, the original error still propagates and the rows stay
741
+ // claimed for manual reconciliation.
742
+ try {
743
+ await query(
744
+ "UPDATE loyalty_earn_log SET reversed_at = NULL " +
745
+ "WHERE customer_id = ?1 AND trigger_event_ref = ?2 AND reversed_at = ?3",
746
+ [customerId, triggerEventRef, ts],
747
+ );
748
+ } catch (_releaseErr) { /* drop-silent — the original failure is the signal */ }
749
+ throw clawErr;
750
+ }
751
+ }
752
+
753
+ return { reversed_points: earned, clawed_points: clawed };
754
+ }
755
+
651
756
  // ---- metricsForRule -------------------------------------------------
652
757
 
653
758
  async function metricsForRule(input) {
@@ -767,6 +872,7 @@ function create(opts) {
767
872
  archiveRule: archiveRule,
768
873
  evaluateForEvent: evaluateForEvent,
769
874
  awardForEvent: awardForEvent,
875
+ reverseForEvent: reverseForEvent,
770
876
  metricsForRule: metricsForRule,
771
877
  applyBatch: applyBatch,
772
878
  };