@blamejs/blamejs-shop 0.4.105 → 0.4.106

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.106 (2026-06-25) — **A capped discount rule's redemption counter no longer drifts toward its limit after a transient database error mid-claim.** Claiming a redemption against a discount rule with a total-redemption cap reserves a slot by incrementing the rule's used-count, then writes the per-order claim row. If writing that claim row failed for a reason other than the expected duplicate-claim collision — a transient backend error — the reserved slot was not returned, so the rule's used-count drifted one higher than the claims actually made and a capped rule could reach its limit early and start refusing legitimate redemptions. The claim path already returns the reservation when it reuses an existing claim and when the per-customer cap refuses; it now also returns it on this transient-failure path. The return is safe and cannot over-grant: the code re-reads the claim by its unique key and only returns the slot when that read confirms no claim row was written, so there is no case where a slot is returned for a claim that actually landed. **Fixed:** *A failed redemption claim returns its reserved cap slot instead of leaking it* — A redemption claim against a total-capped discount rule increments the rule's used-count to reserve a slot before inserting the per-order claim row. A failure inserting that row that was not the expected duplicate-claim collision (a transient backend error) left the reserved slot un-returned, drifting the used-count above the real claim count so the rule could hit its cap and refuse valid redemptions prematurely. The claim path now returns the reserved slot on that path too, matching the existing reuse and per-customer-cap refusals. It is safe against over-granting because it returns the slot only after a re-read on the claim's unique key confirms no row was written — a claim that actually landed is detected and reused, never double-counted.
12
+
11
13
  - 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
14
 
13
15
  - 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.105",
2
+ "version": "0.4.106",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -1282,7 +1282,7 @@ function create(opts) {
1282
1282
  // is blind in prod (it only matches under node:sqlite). Re-read by the
1283
1283
  // colliding UNIQUE key (rule_slug, order_id): if a row now exists the
1284
1284
  // failure WAS the collision, so reuse it; otherwise the insert failed
1285
- // for another reason, so re-throw (without returning the increment).
1285
+ // for another reason — return the leaked reservation, then re-throw.
1286
1286
  var held = (await query(
1287
1287
  "SELECT id FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
1288
1288
  [slug, claimRef],
@@ -1295,6 +1295,20 @@ function create(opts) {
1295
1295
  );
1296
1296
  return { claimed: true, id: held.id, reused: true };
1297
1297
  }
1298
+ // Non-collision failure AND the re-read found NO claim row — which is
1299
+ // DEFINITIVE here: the INSERT did not land. The total-cap reservation
1300
+ // this call took above therefore leaked, so return it before re-throwing,
1301
+ // exactly like the per-customer-cap refusal path below. Safe (no
1302
+ // over-grant): the re-read on the UNIQUE (rule_slug, order_id) key
1303
+ // confirmed no row exists, so unlike a stored-balance revert there is no
1304
+ // indeterminate-success case to under-count. Without this, a transient
1305
+ // D1 error after the increment drifts redemptions_used upward and a capped
1306
+ // rule hits its max prematurely.
1307
+ await query(
1308
+ "UPDATE auto_discount_rules SET redemptions_used = " +
1309
+ "CASE WHEN redemptions_used > 0 THEN redemptions_used - 1 ELSE 0 END, updated_at = ?1 WHERE slug = ?2",
1310
+ [_now(), slug],
1311
+ );
1298
1312
  throw e;
1299
1313
  }
1300
1314
  if (!b.sql.casWon(ins).won) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.105",
3
+ "version": "0.4.106",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {