@blamejs/blamejs-shop 0.4.19 → 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 +4 -0
- package/lib/admin.js +60 -18
- package/lib/asset-manifest.json +1 -1
- package/lib/cart.js +44 -0
- package/lib/checkout.js +188 -51
- package/lib/click-and-collect.js +30 -15
- package/lib/collections.js +282 -14
- package/lib/gift-card-ledger.js +37 -8
- package/lib/gift-registry.js +43 -5
- package/lib/giftcards.js +60 -0
- package/lib/loyalty-earn-rules.js +106 -0
- package/lib/loyalty.js +63 -30
- package/lib/order.js +135 -1
- package/lib/returns.js +45 -5
- package/lib/search-ranking.js +58 -2
- package/lib/store-credit.js +31 -19
- package/lib/storefront.js +147 -19
- package/lib/subscription-controls.js +113 -0
- package/package.json +1 -1
package/lib/collections.js
CHANGED
|
@@ -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 (!
|
|
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":
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
var
|
|
832
|
-
matched
|
|
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
|
-
|
|
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
|
}
|
package/lib/gift-card-ledger.js
CHANGED
|
@@ -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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
var
|
|
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:
|
|
260
|
-
occurred_at:
|
|
288
|
+
balance_after_minor: wrote ? wrote.balance_after_minor : null,
|
|
289
|
+
occurred_at: wrote ? wrote.occurred_at : requested,
|
|
261
290
|
};
|
|
262
291
|
},
|
|
263
292
|
|
package/lib/gift-registry.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
"
|
|
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],
|
package/lib/giftcards.js
CHANGED
|
@@ -346,6 +346,66 @@ function create(opts) {
|
|
|
346
346
|
};
|
|
347
347
|
},
|
|
348
348
|
|
|
349
|
+
// Credit a card's spend back when the order that redeemed it never
|
|
350
|
+
// completed (abandoned / cancelled / refunded). `redeem` debited the
|
|
351
|
+
// card row's balance at checkout; if the order dies, that money must
|
|
352
|
+
// return to the card or the customer's balance is silently gone.
|
|
353
|
+
//
|
|
354
|
+
// Keyed on the ORDER id — the order FSM drives this on its cancel /
|
|
355
|
+
// refund edges (the same transitions that release inventory holds), so
|
|
356
|
+
// reversal is transition-driven, not a separate operator action. Every
|
|
357
|
+
// unreversed redemption against the order is restored.
|
|
358
|
+
//
|
|
359
|
+
// Idempotent + concurrency-safe: each redemption is claimed with
|
|
360
|
+
// `WHERE id = ? AND reversed_at IS NULL` and the rowCount checked, so a
|
|
361
|
+
// double-fire (the stale-order reaper racing a payment-failed webhook,
|
|
362
|
+
// or a re-delivered cancel) credits the balance back exactly once. The
|
|
363
|
+
// balance restore is itself bounded by the card's issued_minor CHECK, so
|
|
364
|
+
// it can never push the card above its original face value. A card that
|
|
365
|
+
// had drained to `redeemed` is reactivated since it now carries balance
|
|
366
|
+
// again. Returns the list of reversed redemptions (empty when there was
|
|
367
|
+
// nothing to reverse — an order that used no gift card, or one already
|
|
368
|
+
// fully reversed).
|
|
369
|
+
reverseRedemption: async function (orderId) {
|
|
370
|
+
_uuid(orderId, "order_id");
|
|
371
|
+
var rows = (await query(
|
|
372
|
+
"SELECT id, giftcard_id, amount_minor FROM giftcard_redemptions " +
|
|
373
|
+
"WHERE order_id = ?1 AND reversed_at IS NULL",
|
|
374
|
+
[orderId],
|
|
375
|
+
)).rows;
|
|
376
|
+
var reversed = [];
|
|
377
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
378
|
+
var red = rows[i];
|
|
379
|
+
var ts = _now();
|
|
380
|
+
// Claim the redemption first — the unreversed predicate is the
|
|
381
|
+
// serialization point so two concurrent reversals can't both credit.
|
|
382
|
+
var claim = await query(
|
|
383
|
+
"UPDATE giftcard_redemptions SET reversed_at = ?1 WHERE id = ?2 AND reversed_at IS NULL",
|
|
384
|
+
[ts, red.id],
|
|
385
|
+
);
|
|
386
|
+
if (Number(claim.rowCount || 0) === 0) continue; // lost the claim
|
|
387
|
+
// Restore the spendable balance on the card row. Capped at the card's
|
|
388
|
+
// issued_minor by the schema CHECK; the amount restored is exactly
|
|
389
|
+
// what this redemption debited, so it can't exceed the face value.
|
|
390
|
+
// Reactivate a card that had drained to `redeemed` — it carries
|
|
391
|
+
// balance again. A `voided`/`expired` card stays in its terminal
|
|
392
|
+
// status (the balance is restored on the row for reconciliation, but
|
|
393
|
+
// a voided/expired card isn't spendable regardless).
|
|
394
|
+
await query(
|
|
395
|
+
"UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
|
|
396
|
+
"status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
|
|
397
|
+
"updated_at = ?2 WHERE id = ?3",
|
|
398
|
+
[red.amount_minor, ts, red.giftcard_id],
|
|
399
|
+
);
|
|
400
|
+
reversed.push({
|
|
401
|
+
redemption_id: red.id,
|
|
402
|
+
gift_card_id: red.giftcard_id,
|
|
403
|
+
amount_minor: red.amount_minor,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return reversed;
|
|
407
|
+
},
|
|
408
|
+
|
|
349
409
|
"void": async function (id, opts2) {
|
|
350
410
|
opts2 = opts2 || {};
|
|
351
411
|
_uuid(id, "giftcard id");
|