@blamejs/blamejs-shop 0.4.103 → 0.4.105
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/README.md +1 -1
- package/SECURITY.md +11 -8
- package/lib/affiliates.js +2 -2
- package/lib/api-keys.js +1 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/auto-discount.js +4 -4
- package/lib/auto-replenish.js +1 -1
- package/lib/backorder.js +2 -2
- package/lib/carrier-accounts.js +1 -1
- package/lib/cart-bulk-ops.js +1 -1
- package/lib/cart.js +2 -2
- package/lib/catalog.js +3 -3
- package/lib/click-and-collect.js +2 -2
- package/lib/customer-merge.js +2 -2
- package/lib/customer-surveys.js +1 -1
- package/lib/cycle-counting.js +1 -1
- package/lib/dropship-forwarding.js +1 -1
- package/lib/dunning.js +1 -1
- package/lib/email-campaigns.js +4 -4
- package/lib/giftcards.js +6 -6
- package/lib/inventory-allocations.js +1 -1
- package/lib/inventory-audits.js +1 -1
- package/lib/inventory-receive.js +2 -2
- package/lib/inventory-writeoffs.js +1 -1
- package/lib/invoice-renderer.js +1 -1
- package/lib/knowledge-base.js +1 -1
- package/lib/loyalty-redemption.js +3 -3
- package/lib/loyalty.js +410 -195
- package/lib/operator-approvals.js +1 -1
- package/lib/operator-help-center.js +1 -1
- package/lib/order-exchanges.js +1 -1
- package/lib/order-export.js +4 -4
- package/lib/payment-retries.js +2 -2
- package/lib/pick-lists.js +1 -1
- package/lib/plan-changes.js +1 -1
- package/lib/preorder.js +4 -4
- package/lib/print-on-demand.js +1 -1
- package/lib/print-queue.js +2 -2
- package/lib/purchase-orders.js +1 -1
- package/lib/push-notifications.js +2 -2
- package/lib/quotes.js +7 -7
- package/lib/referrals.js +3 -3
- package/lib/reorder-reminders.js +1 -1
- package/lib/returns.js +4 -4
- package/lib/seller-signup.js +1 -1
- package/lib/shipping-insurance.js +2 -2
- package/lib/sms-dispatcher.js +2 -2
- package/lib/split-shipments.js +1 -1
- package/lib/stock-alerts.js +1 -1
- package/lib/stock-transfers.js +4 -4
- package/lib/subscription-gifts.js +2 -2
- package/lib/suggestion-box.js +2 -2
- package/lib/webhooks.js +10 -3
- package/lib/wishlist-alerts.js +1 -1
- package/lib/wishlist-digest.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.105 (2026-06-25) — **A loyalty points balance is now an exactly-once, tamper-evident running ledger instead of a stored counter updated alongside its history.** A customer's loyalty balance, lifetime points, and tier were held in a stored account row and updated in a statement separate from the transaction-history insert. On a backend without interactive transactions, a failure between those two writes could leave the balance and the history disagreeing, and there was no way to make a points credit exactly-once with a stored counter. Every loyalty mutation — earn, redeem, expire, adjust, credit, and the refund-time reverse/restore credits — is now a single guarded insert into the transaction ledger that also carries the new running balance, lifetime, and tier, so the balance change and its audit row are one write with no window to half-apply. The running balance is read from the ledger tip rather than a separate counter, redemptions refuse an overdraft inside that one statement, and concurrent writes to the same customer are serialized by a per-customer chain so two redemptions can never both spend the same points. Each ledger row is linked into a per-customer SHA3-512 hash chain, so the history is tamper-evident and can be verified end to end (the loyalty addition to the store-credit and gift-card ledger tamper-evidence). Balances, tiers, the points ratios, and the entire loyalty API are unchanged; the running-balance columns are added by a migration that anchors each existing customer to their current trusted balance without replaying history. The account row is kept as a denormalized mirror for the tier leaderboard and tier-expiry, refreshed after each write and never on a balance decision path. **Changed:** *Loyalty balance, lifetime, and tier are computed from a single guarded ledger write* — The loyalty balance, lifetime points, and tier are no longer a stored counter mutated next to a separate history insert. Each earn / redeem / expire / adjust / credit and each refund-time reverse or restore is now one guarded insert into the loyalty transaction ledger that records the event and carries the resulting running balance, lifetime, and tier in the same row — so a balance change and its audit entry are a single write that cannot half-apply, and a points credit is exactly-once and crash-safe. The current balance is read from the latest ledger row; a redemption's overdraft refusal lives inside that insert; and concurrent writes to one customer are serialized so two redemptions can't both spend the same balance. The customer's balances, tier, the points-per-dollar and redemption ratios, and the public loyalty API are all unchanged. · *Loyalty history is now a tamper-evident hash chain with a verifyChain audit* — Each loyalty transaction row is linked to the previous one for the same customer by a SHA3-512 hash chain, the same tamper-evidence the store-credit and gift-card ledgers already carry. A new verifyChain audit recomputes the chain to prove no past row was edited or re-ordered. A schema migration adds the running-balance and chain columns and anchors every existing customer to their current trusted balance — it copies the live balance verbatim rather than replaying history, so a balance is never recomputed (and never corrupted) during the upgrade. The migration applies automatically on deploy; no operator action is required.
|
|
12
|
+
|
|
13
|
+
- v0.4.104 (2026-06-25) — **Compare-and-swap atomic claims across the framework now decide won/lost through the shared row-count primitive instead of hand-rolled checks.** Throughout the framework, an atomic claim on a single-statement-per-request backend is a guarded write — UPDATE ... WHERE the row is still in its expected state, or a conditional INSERT — whose affected-row count decides whether this caller won the race (exactly one row) or lost and must no-op. That won/lost decision was hand-rolled at each site as a Number(result.rowCount || 0) === 1 check, which silently treats a result carrying no recognizable row-count as a lost race. Every such site (104 across commissions, redemption caps, inventory and stock-transfer claims, fulfillment and replenishment leases, gift-card and loyalty ledger writes, webhook delivery leases, and more) now routes that decision through the framework's shared row-count primitive, which owns the exactly-one-row check and the cross-adapter row-count field naming, and surfaces an indeterminate result instead of silently counting it as lost. On the production database path the affected-row count is always reported, so there is no behavior change; the change centralizes the interpretation and makes an otherwise-silent indeterminate result observable. No configuration changes and no schema changes. **Changed:** *Atomic compare-and-swap claims use the shared row-count primitive for their won/lost decision* — Compare-and-swap atomic claims — the guarded UPDATE / conditional INSERT pattern used wherever two requests can race the same row on a backend without interactive transactions — previously decided won-versus-lost with a hand-written row-count comparison at each call site, and a result that carried no recognizable row-count was silently taken as a lost race. These claims now make that decision through the framework's shared row-count primitive, which centralizes the exactly-one-row check and the adapter-specific row-count field naming and raises an error on an indeterminate result rather than treating it as a loss. Because the production database always reports the affected-row count, the won/lost outcome is unchanged on every real path; the benefit is one consistent, audited interpretation and a previously-silent indeterminate result now surfacing. A hand-rolled cross-adapter row-count extraction in the webhook delivery-retry lease is removed in favor of the same primitive.
|
|
14
|
+
|
|
11
15
|
- v0.4.103 (2026-06-25) — **A return refund that exceeds the order's remaining refundable balance is now refused cleanly, instead of failing at the payment provider.** The direct refund console refuses an over-refund up front: it compares the requested amount against the order's remaining refundable balance and returns a clear 'more than is still refundable' error before any money moves. The provider-backed return (RMA) refund path skipped that check — it issued the RMA's approved amount straight to the payment provider, relying on the provider to reject anything past the captured charge. The result was a late, opaque provider-failure error (and a returns workflow left mid-flight) instead of the clean, actionable refusal the console gives. The RMA refund path now applies the same remaining-balance guard before calling the provider: an approved amount greater than what remains refundable — including a return stacked on top of an earlier partial refund — is refused with the same clear message, the return is left in its received state so the operator can re-approve a corrected amount, and the payment provider is never dialed. A refund of an already-refunded return continues to return the existing conflict response. No configuration changes. **Fixed:** *Return (RMA) refunds honor the order's remaining-balance cap before reaching the payment provider* — A provider-backed return refund issued the return's approved amount directly to Stripe or PayPal with no remaining-balance check, so an amount larger than what was still refundable on the order — for example a return approved for more than the order total, or stacked on top of an earlier partial refund — failed only at the payment provider, surfacing as an opaque provider-failure error rather than the clean refusal the direct refund console returns. The return refund path now compares the approved amount against the order's remaining refundable balance first: an over-cap amount is refused with a clear message, the return stays in its received state so a corrected amount can be re-approved, and the provider is never called. A refund attempt on an already-refunded return still returns the existing conflict response.
|
|
12
16
|
|
|
13
17
|
- v0.4.102 (2026-06-25) — **Vendored framework refreshed to 0.15.26.** Updates the vendored blamejs framework to 0.15.26. The substantive upstream change is a worker-thread handle leak fix in the sandbox primitive: b.sandbox.run now awaits the worker's termination before resolving, so repeatedly running sandboxed code in a long-lived process no longer accumulates leaked MessagePort handles that keep the event loop alive and slow graceful shutdown. The follow-up 0.15.26 build is upstream test-harness correctness only and ships a library byte-for-byte identical to 0.15.25. No runtime API changes and no behavior change for this storefront, which does not run untrusted code through the sandbox primitive; no operator action required. **Changed:** *Vendored framework refreshed to 0.15.26* — Refreshes the vendored blamejs framework to 0.15.26. Upstream 0.15.25 fixes a worker-thread MessagePort leak in the sandbox primitive (b.sandbox.run now waits for the worker to terminate before resolving), and 0.15.26 is a test-harness-only build whose shipped library is identical to 0.15.25. There are no runtime API changes; this storefront does not expose the sandbox primitive on any request path, so there is no operator-visible behavior change and no action required.
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
81
81
|
| **`lib/quotes.js`** | Request-for-quote negotiation. A signed-in customer requests a quote from the cart (line quantities + an optional message); the operator responds from the console with per-line pricing and a validity window; the customer reviews and accepts or declines from `/account/quotes` or through a single-use capability link (`/quote/:token` — the token is stored only as a namespaced hash and compared timing-safe). Accepting converts through the normal order path — inventory holds are placed first and rolled back if order creation fails — and every status change replays a `b.fsm` lifecycle (requested → responded → accepted / declined / expired / withdrawn / converted), with expiry enforced at accept time so a stale price is never honored. A scheduled sweep also transitions quotes past their validity window to `expired` (so the console's expired filter reflects the real lifecycle), operators can reprice an open quote (the customer's existing link shows the new pricing) or convert a verbally-approved one to an order with a recorded reason, and the customer view renders the operator's per-line notes and the validity date. |
|
|
82
82
|
| **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
|
|
83
83
|
| **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
|
|
84
|
-
| **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
|
|
84
|
+
| **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger — the balance, lifetime, and tier ride that ledger as a per-customer running snapshot written in one guarded row per event (exactly-once, with no separate counter to fall out of step), linked into a per-customer SHA3-512 hash chain like the gift-card and store-credit ledgers, with `loyalty.verifyChain(customerId)` to recompute it end to end. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
|
|
85
85
|
| **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
|
|
86
86
|
| **`lib/product-compare.js`** | Side-by-side product comparison, no sign-in required. Each product page gets an "Add to compare" / "Remove from compare" control; `POST /compare/toggle` adds or removes the product and `GET /compare` renders the comparison table — image, price, availability, and the per-product attributes resolved through the catalog. The basket is keyed on the visitor's existing sealed session cookie (a signed-in shopper's customer id rides alongside), capped at four products (a fifth add is refused with a notice rather than silently dropping one), and is idempotent. Products that go archived or out of stock resolve out of the table with a remove control instead of breaking the render; an empty basket shows a friendly empty state. `POST /compare/clear` empties it. Linked from the footer on every page. |
|
|
87
87
|
| **`lib/bundles.js`** | Sell products together at a set price. A product page shows the bundles it belongs to as a "Bundle & save" offer — the member products, the bundle price, and the saving against the parts. `POST /cart/bundle` adds the whole bundle in one action: the price is recomputed from the live catalog and allocated across the member lines (proportional to list price, remainder on the last line) so the cart subtotal matches the quoted price. The add is atomic — an archived or out-of-stock member shows the bundle unavailable and adds nothing rather than a partial set; an unpriceable bundle (missing member price / currency mismatch) is shown unavailable, never a 500. The client sends only the bundle code; the server prices it. The offer renders identically from the edge worker and the container. |
|
package/SECURITY.md
CHANGED
|
@@ -319,15 +319,18 @@ node -e "
|
|
|
319
319
|
(success / failure / denied) and paginated. Opening the log is itself
|
|
320
320
|
recorded (an `audit.read` row), so reviewing the audit trail leaves
|
|
321
321
|
its own forensic mark.
|
|
322
|
-
- **The gift-card
|
|
323
|
-
and verifiable.** Every ledger entry — credits, debits, and
|
|
322
|
+
- **The gift-card, store-credit, and loyalty ledgers are hash-chained,
|
|
323
|
+
fork-proof, and verifiable.** Every ledger entry — credits, debits, and
|
|
324
324
|
expirations alike — links to its predecessor through a SHA3-512 chain,
|
|
325
|
-
keyed per card (gift cards) or per customer (store credit), so
|
|
326
|
-
row or deleting one from the middle breaks the linkage. A
|
|
327
|
-
fence (one child per chain tip) makes concurrent writes
|
|
328
|
-
than fork the chain
|
|
329
|
-
|
|
330
|
-
|
|
325
|
+
keyed per card (gift cards) or per customer (store credit, loyalty), so
|
|
326
|
+
editing a row or deleting one from the middle breaks the linkage. A
|
|
327
|
+
uniqueness fence (one child per chain tip) makes concurrent writes
|
|
328
|
+
serialize rather than fork the chain, and each loyalty / gift-card /
|
|
329
|
+
store-credit balance change is written in that same single guarded row,
|
|
330
|
+
so a points or balance mutation is exactly-once even on a backend without
|
|
331
|
+
interactive transactions. `giftCardLedger.verifyChain(cardId)` /
|
|
332
|
+
`storeCredit.verifyChain(customerId)` / `loyalty.verifyChain(customerId)`
|
|
333
|
+
recompute a ledger's chain end to end and report the first divergence — run them whenever a balance is
|
|
331
334
|
disputed; pass a trusted anchor (a row count + head hash from an earlier
|
|
332
335
|
snapshot) to also catch a truncation that deletes the most-recent rows,
|
|
333
336
|
which an append-only chain can't detect on its own. The overdraft
|
package/lib/affiliates.js
CHANGED
|
@@ -925,7 +925,7 @@ function create(opts) {
|
|
|
925
925
|
"WHERE id = ?3 AND status = 'pending'",
|
|
926
926
|
[paidAt, reference, id],
|
|
927
927
|
);
|
|
928
|
-
if (
|
|
928
|
+
if (!b.sql.casWon(paidUpd).won) {
|
|
929
929
|
var racedPaid = await _getCommissionRaw(id);
|
|
930
930
|
var refusedPaid = new Error(
|
|
931
931
|
"affiliates.markCommissionPaid: refused — commission is " +
|
|
@@ -979,7 +979,7 @@ function create(opts) {
|
|
|
979
979
|
"WHERE id = ?3 AND status = 'pending'",
|
|
980
980
|
[ts, reason, id],
|
|
981
981
|
);
|
|
982
|
-
if (
|
|
982
|
+
if (!b.sql.casWon(voidUpd).won) {
|
|
983
983
|
var racedVoid = await _getCommissionRaw(id);
|
|
984
984
|
var refusedVoid = new Error(
|
|
985
985
|
"affiliates.markCommissionVoided: refused — commission is " +
|
package/lib/api-keys.js
CHANGED
|
@@ -580,7 +580,7 @@ function create(opts) {
|
|
|
580
580
|
"token_hash_previous = ?2, token_hash = ?3 WHERE id = ?4 AND status = 'active'",
|
|
581
581
|
[ts, current.token_hash, placeholderHash, id],
|
|
582
582
|
);
|
|
583
|
-
if (
|
|
583
|
+
if (!b.sql.casWon(rotateClaim).won) {
|
|
584
584
|
var lost = await _getRaw(id);
|
|
585
585
|
var refusedRotate = new Error(
|
|
586
586
|
"apiKeys.rotate: refused — key is " + (lost ? lost.status : "gone")
|
package/lib/asset-manifest.json
CHANGED
package/lib/auto-discount.js
CHANGED
|
@@ -1170,7 +1170,7 @@ function create(opts) {
|
|
|
1170
1170
|
"ON CONFLICT(rule_slug, order_id) DO NOTHING",
|
|
1171
1171
|
[id, slug, orderId, customerId, savings, appliedAt],
|
|
1172
1172
|
);
|
|
1173
|
-
if (
|
|
1173
|
+
if (b.sql.casWon(ins).won) {
|
|
1174
1174
|
await query(
|
|
1175
1175
|
"UPDATE auto_discount_rules SET redemptions_used = redemptions_used + 1, updated_at = ?1 WHERE slug = ?2",
|
|
1176
1176
|
[_now(), slug],
|
|
@@ -1252,7 +1252,7 @@ function create(opts) {
|
|
|
1252
1252
|
"WHERE slug = ?2 AND (max_redemptions_total IS NULL OR redemptions_used < max_redemptions_total)",
|
|
1253
1253
|
[ts, slug],
|
|
1254
1254
|
);
|
|
1255
|
-
if (
|
|
1255
|
+
if (!b.sql.casWon(counter).won) {
|
|
1256
1256
|
return { claimed: false, reason: "total-cap" };
|
|
1257
1257
|
}
|
|
1258
1258
|
|
|
@@ -1297,7 +1297,7 @@ function create(opts) {
|
|
|
1297
1297
|
}
|
|
1298
1298
|
throw e;
|
|
1299
1299
|
}
|
|
1300
|
-
if (
|
|
1300
|
+
if (!b.sql.casWon(ins).won) {
|
|
1301
1301
|
// Per-customer cap refused — return the total-cap reservation.
|
|
1302
1302
|
await query(
|
|
1303
1303
|
"UPDATE auto_discount_rules SET redemptions_used = " +
|
|
@@ -1326,7 +1326,7 @@ function create(opts) {
|
|
|
1326
1326
|
"UPDATE auto_discount_applications SET order_id = ?1 WHERE rule_slug = ?2 AND order_id = ?3",
|
|
1327
1327
|
[orderId, slug, claimRef],
|
|
1328
1328
|
);
|
|
1329
|
-
return
|
|
1329
|
+
return b.sql.casWon(res).won;
|
|
1330
1330
|
}
|
|
1331
1331
|
|
|
1332
1332
|
async function releaseClaim(input) {
|
package/lib/auto-replenish.js
CHANGED
|
@@ -770,7 +770,7 @@ function create(opts) {
|
|
|
770
770
|
"AND (last_run_at IS NULL OR last_run_at <= ?3)",
|
|
771
771
|
[now, policy.slug, now - interval],
|
|
772
772
|
);
|
|
773
|
-
if (
|
|
773
|
+
if (!b.sql.casWon(claim).won) continue; // lost the claim / not yet due
|
|
774
774
|
var runRow = await _runPolicyOnce(policy, now);
|
|
775
775
|
summaries.push(_shapeRun(runRow));
|
|
776
776
|
}
|
package/lib/backorder.js
CHANGED
|
@@ -332,7 +332,7 @@ function create(opts) {
|
|
|
332
332
|
"WHERE id = ?2 AND status = 'pending'",
|
|
333
333
|
[ts, line.id],
|
|
334
334
|
);
|
|
335
|
-
if (
|
|
335
|
+
if (!b.sql.casWon(fulfillClaim).won) {
|
|
336
336
|
throw new TypeError("backorder.fulfillBackorder: line is no longer pending " +
|
|
337
337
|
"(concurrently fulfilled or cancelled)");
|
|
338
338
|
}
|
|
@@ -377,7 +377,7 @@ function create(opts) {
|
|
|
377
377
|
"WHERE id = ?3 AND status = 'pending'",
|
|
378
378
|
[reason, ts, line.id],
|
|
379
379
|
);
|
|
380
|
-
if (
|
|
380
|
+
if (!b.sql.casWon(cancelClaim).won) {
|
|
381
381
|
throw new TypeError("backorder.cancelBackorder: line is no longer pending " +
|
|
382
382
|
"(concurrently fulfilled or cancelled)");
|
|
383
383
|
}
|
package/lib/carrier-accounts.js
CHANGED
|
@@ -591,7 +591,7 @@ function create(opts) {
|
|
|
591
591
|
"WHERE id = ?6 AND status IN ('active','rotating') AND api_key_hash = ?7",
|
|
592
592
|
[hashKey, prevHash, hashSec, now, now, id, prevHash],
|
|
593
593
|
);
|
|
594
|
-
if (
|
|
594
|
+
if (!b.sql.casWon(claim).won) {
|
|
595
595
|
var lost = await _getRaw(id);
|
|
596
596
|
var conflict = new Error(
|
|
597
597
|
(lost && lost.status === "disabled")
|
package/lib/cart-bulk-ops.js
CHANGED
|
@@ -536,7 +536,7 @@ function create(opts) {
|
|
|
536
536
|
"UPDATE carts SET status = 'abandoned', updated_at = ?1 WHERE id = ?2 AND status = 'active'",
|
|
537
537
|
[ts, input.cart_id],
|
|
538
538
|
);
|
|
539
|
-
if (
|
|
539
|
+
if (!b.sql.casWon(claim).won) {
|
|
540
540
|
throw new TypeError("cartBulkOps.splitCart: cart " + input.cart_id + " already split/modified");
|
|
541
541
|
}
|
|
542
542
|
var children = [];
|
package/lib/cart.js
CHANGED
|
@@ -450,7 +450,7 @@ function create(opts) {
|
|
|
450
450
|
[ts, cartId],
|
|
451
451
|
);
|
|
452
452
|
var cart = (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0] || null;
|
|
453
|
-
return { claimed:
|
|
453
|
+
return { claimed: b.sql.casWon(r).won, cart: cart };
|
|
454
454
|
},
|
|
455
455
|
|
|
456
456
|
// Release a checkout claim back to 'active' so the buyer can retry. The
|
|
@@ -470,7 +470,7 @@ function create(opts) {
|
|
|
470
470
|
"UPDATE carts SET status = 'active', updated_at = ?1 WHERE id = ?2 AND status = 'converted'",
|
|
471
471
|
[ts, cartId],
|
|
472
472
|
);
|
|
473
|
-
return
|
|
473
|
+
return b.sql.casWon(r).won;
|
|
474
474
|
},
|
|
475
475
|
|
|
476
476
|
// ---- abandoned-cart visibility (operator dashboard) ---------------
|
package/lib/catalog.js
CHANGED
|
@@ -743,7 +743,7 @@ function _inventoryModule(query, opts) {
|
|
|
743
743
|
"WHERE sku = ?3 AND (stock_on_hand - stock_held) >= ?1",
|
|
744
744
|
[need, ts, sku],
|
|
745
745
|
);
|
|
746
|
-
if (rDown.
|
|
746
|
+
if (b.sql.casWon(rDown).won) {
|
|
747
747
|
await _afterMutation(sku);
|
|
748
748
|
return { adjusted: true, sku: sku, delta: delta };
|
|
749
749
|
}
|
|
@@ -796,7 +796,7 @@ function _inventoryModule(query, opts) {
|
|
|
796
796
|
"WHERE sku = ?3 AND (stock_on_hand - stock_held) >= ?1",
|
|
797
797
|
[qty, ts, sku],
|
|
798
798
|
);
|
|
799
|
-
if (r.
|
|
799
|
+
if (b.sql.casWon(r).won) {
|
|
800
800
|
// Hold lowers available; a low-stock threshold may now trip.
|
|
801
801
|
await _afterMutation(sku);
|
|
802
802
|
return { held: true, sku: sku, qty: qty };
|
|
@@ -829,7 +829,7 @@ function _inventoryModule(query, opts) {
|
|
|
829
829
|
"WHERE sku = ?3 AND stock_held >= ?1 AND stock_on_hand >= ?1",
|
|
830
830
|
[qty, ts, sku],
|
|
831
831
|
);
|
|
832
|
-
if (r.
|
|
832
|
+
if (b.sql.casWon(r).won) {
|
|
833
833
|
await _afterMutation(sku);
|
|
834
834
|
return { decremented: true, sku: sku, qty: qty };
|
|
835
835
|
}
|
package/lib/click-and-collect.js
CHANGED
|
@@ -493,7 +493,7 @@ function create(opts) {
|
|
|
493
493
|
input.scheduled_window_start, input.scheduled_window_end, now, now,
|
|
494
494
|
bucketStart, bucketEnd, capacity],
|
|
495
495
|
);
|
|
496
|
-
if (
|
|
496
|
+
if (!b.sql.casWon(ins).won) {
|
|
497
497
|
throw new TypeError("click-and-collect.scheduleAtLocation: location_code " +
|
|
498
498
|
JSON.stringify(input.location_code) + " is at capacity for the requested window");
|
|
499
499
|
}
|
|
@@ -592,7 +592,7 @@ function create(opts) {
|
|
|
592
592
|
"WHERE order_id = ?5 AND status = 'ready'",
|
|
593
593
|
[input.picked_up_at, sigHash, proofKind, now, orderId],
|
|
594
594
|
);
|
|
595
|
-
if (
|
|
595
|
+
if (!b.sql.casWon(pickupClaim).won) {
|
|
596
596
|
throw new TypeError("click-and-collect.markPickedUp: schedule is no longer ready " +
|
|
597
597
|
"(concurrently picked up)");
|
|
598
598
|
}
|
package/lib/customer-merge.js
CHANGED
|
@@ -707,7 +707,7 @@ function create(opts) {
|
|
|
707
707
|
"executed_at = ?1, executed_by = ?2 WHERE id = ?3 AND status = 'proposed'",
|
|
708
708
|
[ts, executedBy, mergeId],
|
|
709
709
|
);
|
|
710
|
-
if (
|
|
710
|
+
if (!b.sql.casWon(claim).won) {
|
|
711
711
|
// Lost the race (or the row left 'proposed' between our read
|
|
712
712
|
// and the claim). Re-read: an already-executed row is the
|
|
713
713
|
// winner's result; anything else is a genuine state error.
|
|
@@ -818,7 +818,7 @@ function create(opts) {
|
|
|
818
818
|
"AND EXISTS (SELECT 1 FROM customer_merge_redirects WHERE source_customer_id = ?4)",
|
|
819
819
|
[now, reason, mergeId, row.source_customer_id],
|
|
820
820
|
);
|
|
821
|
-
if (
|
|
821
|
+
if (!b.sql.casWon(claim).won) {
|
|
822
822
|
var raceErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
|
|
823
823
|
" is no longer executed (or its merge is still finalizing); only fully-executed merges can be rolled back");
|
|
824
824
|
raceErr.code = "CUSTOMER_MERGE_NOT_EXECUTED";
|
package/lib/customer-surveys.js
CHANGED
|
@@ -758,7 +758,7 @@ function create(opts) {
|
|
|
758
758
|
"WHERE id = ?2 AND status = 'issued'",
|
|
759
759
|
[nowTs, invitation.id],
|
|
760
760
|
);
|
|
761
|
-
if (
|
|
761
|
+
if (!b.sql.casWon(claim).won) {
|
|
762
762
|
var raced = new Error("customerSurveys.submitResponse: invitation already responded");
|
|
763
763
|
raced.code = "SURVEY_INVITATION_ALREADY_RESPONDED";
|
|
764
764
|
throw raced;
|
package/lib/cycle-counting.js
CHANGED
|
@@ -627,7 +627,7 @@ function create(opts) {
|
|
|
627
627
|
"WHERE slug = ?4 AND status IN ('scheduled', 'in_progress')",
|
|
628
628
|
[varianceCount, varianceValueMinor, ts, input.slug],
|
|
629
629
|
);
|
|
630
|
-
if (
|
|
630
|
+
if (!b.sql.casWon(claim).won) {
|
|
631
631
|
// Lost the claim — a concurrent (or prior) finalize already advanced
|
|
632
632
|
// this count and owns the adjustment writes. Touch no shelves; return
|
|
633
633
|
// the totals computed from the now-frozen lines.
|
|
@@ -358,7 +358,7 @@ function create(opts) {
|
|
|
358
358
|
" WHERE id = ?" + p + " AND status = ?" + (p + 1),
|
|
359
359
|
params,
|
|
360
360
|
);
|
|
361
|
-
if (
|
|
361
|
+
if (!b.sql.casWon(upd).won) {
|
|
362
362
|
var raced = new Error("dropship-forwarding: forwarding " + forwardingId +
|
|
363
363
|
" changed state concurrently (no longer " + current.status + ") — transition refused");
|
|
364
364
|
raced.code = "DROPSHIP_FORWARDING_TRANSITION_REFUSED";
|
package/lib/dunning.js
CHANGED
|
@@ -411,7 +411,7 @@ function create(opts) {
|
|
|
411
411
|
"WHERE id = ?1 AND status = 'active' AND next_action_at = ?2",
|
|
412
412
|
[enrollment.id, observedNextAt],
|
|
413
413
|
);
|
|
414
|
-
if (
|
|
414
|
+
if (!b.sql.casWon(claim).won) {
|
|
415
415
|
// Lost the race — another tick already claimed this row (or it
|
|
416
416
|
// was unset to a terminal status between the snapshot and now).
|
|
417
417
|
// Skip the side-effect; return the current row state.
|
package/lib/email-campaigns.js
CHANGED
|
@@ -853,7 +853,7 @@ function create(opts) {
|
|
|
853
853
|
"VALUES (?1, ?2, ?3, 'claimed', NULL, ?4)",
|
|
854
854
|
[b.uuid.v7(), slug, emailHash, Date.now()],
|
|
855
855
|
);
|
|
856
|
-
return r.
|
|
856
|
+
return b.sql.casWon(r).won;
|
|
857
857
|
}
|
|
858
858
|
|
|
859
859
|
async function _finalizeSend(slug, emailHash, outcome, failReason) {
|
|
@@ -1200,7 +1200,7 @@ function create(opts) {
|
|
|
1200
1200
|
"WHERE slug = ?2 AND status = 'scheduled'",
|
|
1201
1201
|
[Date.now(), slug],
|
|
1202
1202
|
);
|
|
1203
|
-
if (
|
|
1203
|
+
if (!b.sql.casWon(claim).won) {
|
|
1204
1204
|
var raceErr = new TypeError(
|
|
1205
1205
|
"emailCampaigns.sendNow: campaign '" + slug + "' is already being sent"
|
|
1206
1206
|
);
|
|
@@ -1260,7 +1260,7 @@ function create(opts) {
|
|
|
1260
1260
|
"WHERE slug = ?2 AND status = 'scheduled'",
|
|
1261
1261
|
[Date.now(), row.slug],
|
|
1262
1262
|
);
|
|
1263
|
-
if (
|
|
1263
|
+
if (!b.sql.casWon(claim).won) continue;
|
|
1264
1264
|
var stats = await _drainSend(row);
|
|
1265
1265
|
var midRow = await _getRow(row.slug);
|
|
1266
1266
|
await _fire(midRow, "complete");
|
|
@@ -1582,7 +1582,7 @@ function create(opts) {
|
|
|
1582
1582
|
"WHERE slug = ?2 AND status = 'scheduled'",
|
|
1583
1583
|
[Date.now(), slug],
|
|
1584
1584
|
);
|
|
1585
|
-
if (
|
|
1585
|
+
if (!b.sql.casWon(claim).won) {
|
|
1586
1586
|
var raceErr = new TypeError(
|
|
1587
1587
|
"emailCampaigns.broadcast: campaign '" + slug + "' is already being sent"
|
|
1588
1588
|
);
|
package/lib/giftcards.js
CHANGED
|
@@ -322,7 +322,7 @@ function create(opts) {
|
|
|
322
322
|
"updated_at = ?2 WHERE id = ?3 AND balance_minor >= ?1 AND status = 'active'",
|
|
323
323
|
[input.amount_minor, ts, row.id],
|
|
324
324
|
);
|
|
325
|
-
if (dec.
|
|
325
|
+
if (!b.sql.casWon(dec).won) {
|
|
326
326
|
// Race: another redemption beat us to the balance. Refuse
|
|
327
327
|
// with the same shape as the up-front insufficient check so
|
|
328
328
|
// the caller doesn't have to distinguish "checked then
|
|
@@ -407,7 +407,7 @@ function create(opts) {
|
|
|
407
407
|
"UPDATE giftcard_redemptions SET reversed_at = ?1 WHERE id = ?2 AND reversed_at IS NULL",
|
|
408
408
|
[ts, red.id],
|
|
409
409
|
);
|
|
410
|
-
if (
|
|
410
|
+
if (!b.sql.casWon(claim).won) continue; // lost the claim
|
|
411
411
|
// Restore the spendable balance on the card row. Capped at the card's
|
|
412
412
|
// issued_minor by the schema CHECK; the amount restored is exactly
|
|
413
413
|
// what this redemption debited, so it can't exceed the face value.
|
|
@@ -446,7 +446,7 @@ function create(opts) {
|
|
|
446
446
|
"UPDATE giftcard_redemptions SET order_id = ?1 WHERE id = ?2 AND order_id IS NULL",
|
|
447
447
|
[orderId, redemptionId],
|
|
448
448
|
);
|
|
449
|
-
return
|
|
449
|
+
return b.sql.casWon(res).won;
|
|
450
450
|
},
|
|
451
451
|
|
|
452
452
|
// Reverse ONE redemption by its own id — the compensation edge for a
|
|
@@ -472,7 +472,7 @@ function create(opts) {
|
|
|
472
472
|
"UPDATE giftcard_redemptions SET reversed_at = ?1 WHERE id = ?2 AND reversed_at IS NULL",
|
|
473
473
|
[ts, red.id],
|
|
474
474
|
);
|
|
475
|
-
if (
|
|
475
|
+
if (!b.sql.casWon(claim).won) return null; // lost the claim
|
|
476
476
|
await query(
|
|
477
477
|
"UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
|
|
478
478
|
"status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
|
|
@@ -554,7 +554,7 @@ function create(opts) {
|
|
|
554
554
|
"WHERE id = ?4 AND reversed_minor = ?5",
|
|
555
555
|
[target, nowFull ? 1 : 0, ts, red.id, already],
|
|
556
556
|
);
|
|
557
|
-
if (
|
|
557
|
+
if (!b.sql.casWon(claim).won) continue; // lost the claim to a concurrent reversal
|
|
558
558
|
// Restore the spendable balance on the card row. Capped at the card's
|
|
559
559
|
// issued_minor by the schema CHECK; the cumulative reversed_minor is
|
|
560
560
|
// bounded by amount_minor, so it can't exceed the face value.
|
|
@@ -592,7 +592,7 @@ function create(opts) {
|
|
|
592
592
|
// the row (no schema column) but accepted so a future audit-log
|
|
593
593
|
// primitive can ride alongside without a surface change.
|
|
594
594
|
void opts2.reason;
|
|
595
|
-
if (
|
|
595
|
+
if (b.sql.casWon(upd).won) {
|
|
596
596
|
var after = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
|
|
597
597
|
return after.rows[0] || null;
|
|
598
598
|
}
|
|
@@ -381,7 +381,7 @@ function create(opts) {
|
|
|
381
381
|
"committed_order_id = ?2 WHERE id = ?3 AND status = 'held'",
|
|
382
382
|
[ts, input.order_id, input.hold_id],
|
|
383
383
|
);
|
|
384
|
-
if (
|
|
384
|
+
if (!b.sql.casWon(claim).won) {
|
|
385
385
|
throw new TypeError("inventory-allocations.commitHold: hold " + existing.id +
|
|
386
386
|
" is no longer held (committed by a concurrent call)");
|
|
387
387
|
}
|
package/lib/inventory-audits.js
CHANGED
|
@@ -642,7 +642,7 @@ function create(opts) {
|
|
|
642
642
|
"WHERE id = ?4 AND status IN ('open', 'in_progress')",
|
|
643
643
|
[varianceCount, varianceValueMinor, ts, auditId],
|
|
644
644
|
);
|
|
645
|
-
if (
|
|
645
|
+
if (!b.sql.casWon(claim).won) {
|
|
646
646
|
// Lost the claim — a concurrent (or prior) finalize already
|
|
647
647
|
// advanced this audit and owns the adjustment writes. Touch no
|
|
648
648
|
// shelves; return the totals computed from the now-frozen lines.
|
package/lib/inventory-receive.js
CHANGED
|
@@ -351,7 +351,7 @@ function create(opts) {
|
|
|
351
351
|
"UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2 AND status = 'pending'",
|
|
352
352
|
[ts, receiptId],
|
|
353
353
|
);
|
|
354
|
-
if (
|
|
354
|
+
if (!b.sql.casWon(claim).won) {
|
|
355
355
|
// A concurrent call won the claim and is restocking (or already did).
|
|
356
356
|
// Replay-safe no-op so the loser doesn't double-apply.
|
|
357
357
|
return { id: receiptId, applied_count: 0, stock_changes: [] };
|
|
@@ -422,7 +422,7 @@ function create(opts) {
|
|
|
422
422
|
"UPDATE inventory_receipts SET status = 'reversed', updated_at = ?1 WHERE id = ?2 AND status = 'applied'",
|
|
423
423
|
[claimTs, receiptId],
|
|
424
424
|
);
|
|
425
|
-
if (
|
|
425
|
+
if (!b.sql.casWon(claim).won) {
|
|
426
426
|
throw new TypeError("inventory-receive.reverse: receipt " + receiptId +
|
|
427
427
|
" is no longer applied (reversed by a concurrent call)");
|
|
428
428
|
}
|
|
@@ -555,7 +555,7 @@ function create(opts) {
|
|
|
555
555
|
"reverse_reason = ?2 WHERE id = ?3 AND status = 'recorded'",
|
|
556
556
|
[ts, reason, id],
|
|
557
557
|
);
|
|
558
|
-
if (
|
|
558
|
+
if (!b.sql.casWon(claim).won) {
|
|
559
559
|
throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
|
|
560
560
|
" is no longer recorded (reversed by a concurrent call)");
|
|
561
561
|
}
|
package/lib/invoice-renderer.js
CHANGED
|
@@ -319,7 +319,7 @@ function create(opts) {
|
|
|
319
319
|
"WHERE series = ?3 AND next_value = ?4",
|
|
320
320
|
[current + 1, Date.now(), series, current],
|
|
321
321
|
);
|
|
322
|
-
if (
|
|
322
|
+
if (b.sql.casWon(info).won) return current;
|
|
323
323
|
}
|
|
324
324
|
throw new Error("invoiceRenderer: sequence advance for series " + JSON.stringify(series) + " contended 16 times");
|
|
325
325
|
}
|
package/lib/knowledge-base.js
CHANGED
|
@@ -928,7 +928,7 @@ function create(opts) {
|
|
|
928
928
|
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
929
929
|
[b.uuid.v7(), slug, sessionHash, vote, ts],
|
|
930
930
|
);
|
|
931
|
-
var changed = inserted && (inserted.
|
|
931
|
+
var changed = inserted && b.sql.casWon(inserted).won;
|
|
932
932
|
if (changed) {
|
|
933
933
|
var col = vote === "helpful" ? "helpful_count" : "not_helpful_count";
|
|
934
934
|
await query(
|
|
@@ -533,7 +533,7 @@ function create(opts) {
|
|
|
533
533
|
"WHERE customer_id = ?2 AND reward_slug = ?3 AND status IN ('active','consumed')) < ?7",
|
|
534
534
|
[redemptionId, customerId, rewardSlug, reward.point_cost, ts, expiresAt, reward.max_per_customer],
|
|
535
535
|
);
|
|
536
|
-
if (
|
|
536
|
+
if (!b.sql.casWon(claim).won) {
|
|
537
537
|
// Lost the cap claim — another redeem filled the last slot between
|
|
538
538
|
// the pre-check and here. Return the points and refuse, so a raced
|
|
539
539
|
// caller is never charged for a redemption it didn't get.
|
|
@@ -648,7 +648,7 @@ function create(opts) {
|
|
|
648
648
|
"WHERE id = ?3 AND status = 'active'",
|
|
649
649
|
[ts, orderId, redemptionId],
|
|
650
650
|
);
|
|
651
|
-
if (
|
|
651
|
+
if (!b.sql.casWon(r).won) {
|
|
652
652
|
// Lost the race — re-read so the caller gets the row's actual
|
|
653
653
|
// post-race state. Refuse with the same FSM error so the call
|
|
654
654
|
// surface is identical whether the loser noticed pre- or
|
|
@@ -691,7 +691,7 @@ function create(opts) {
|
|
|
691
691
|
"WHERE id = ?2 AND status = 'active'",
|
|
692
692
|
[reason, redemptionId],
|
|
693
693
|
);
|
|
694
|
-
if (
|
|
694
|
+
if (!b.sql.casWon(r).won) {
|
|
695
695
|
var raceAfter = await getRedemption(redemptionId);
|
|
696
696
|
var raceErr = new Error("loyaltyRedemption.cancelRedemption: redemption status is '" + (raceAfter && raceAfter.status) + "', only 'active' may be cancelled with a point refund");
|
|
697
697
|
raceErr.code = "REDEMPTION_NOT_ACTIVE";
|