@blamejs/blamejs-shop 0.4.95 → 0.4.97

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.97 (2026-06-25) — **Constraint-collision handling no longer depends on the database error text, so duplicate detection works in production instead of only in tests.** Several write paths detected a UNIQUE (or related) constraint collision by matching the database error message — looking for the literal text 'UNIQUE constraint failed'. That text only survives in local/test runs: in production, the data layer redacts the underlying SQLite error to a generic 'HTTP 500' with no constraint text, so the match silently failed and the intended collision handling was skipped — the raw error surfaced instead of the idempotent replay, the typed duplicate error, the regenerate-and-retry, or the claim reuse. Every such site now detects the collision by re-reading state through the unique key after the insert fails: if the row is present the failure was the collision and the site runs its existing handling; if not, the original error is re-thrown. This makes idempotent webhook replays, duplicate-key errors, code/token retries, and discount-claim reuse behave correctly in production, not just under tests. No configuration changes. **Fixed:** *Constraint-collision detection is now state-based, not error-message-based* — Duplicate-delivery webhook replays (webhook receiver), duplicate-key errors (tenant domain + slug, vendor SKU), regenerate-and-retry on a code/token collision (referral codes, gift-redemption tokens, API keys, affiliate codes), and the auto-discount per-order claim reuse all decided 'was this a constraint collision?' by scanning the SQLite error message. Production redacts that message to a bare 'HTTP 500', so the check was blind there and the collision branch never ran. Each site now re-reads by its unique key after an insert error and runs its handling only when the row is actually present, re-throwing any other error — the same approach the gift-card and affiliate-commission ledgers already use. The admin error-to-response mapper, which has no single key to re-read, now prefers a typed constraint code over the message text where the driver provides one.
12
+
13
+ - v0.4.96 (2026-06-25) — **Compose two newly-public framework primitives directly, removing an internal-path dependency and a drift-prone duplicate transition table.** Two internal refactors that adopt framework surface the application previously had to work around, with no change in behaviour. The text-guard module composed the framework's codepoint-threat catalog by reaching into an internal vendored module path, because the catalog was not on the framework's public surface; it now composes the public b.codepointClass (the same module, reference-identical), so a future framework refresh that moves the internal file can't break the import. The quotes module kept a hand-rolled loop that re-derived a quote's next status from a second copy of its transition table; it now resolves the destination through the state machine's own side-effect-free resolver, off the single transition definition, so the two can no longer drift apart. No configuration changes. **Changed:** *Text-guard composes the public codepoint-threat catalog* — The free-text guard built its bidi / control / null-byte / zero-width / mixed-script screening on the framework's codepoint catalog by requiring an internal vendored module path, since the catalog was not yet exported publicly. It now composes the public b.codepointClass — the identical module — so the screening no longer depends on an internal file location that an upstream refresh could relocate. · *Quote transitions resolve through the state machine instead of a duplicate table* — Validating a quote's lifecycle edge (respond / accept / reject / cancel / expire / convert) used the state machine to check the edge was legal, then a separate hand-rolled loop over a second copy of the transition table to read the destination status. The destination is now resolved by the state machine's own side-effect-free resolver, off the single transition definition the machine is built from, removing the duplicate table walk that could drift from it.
14
+
11
15
  - v0.4.95 (2026-06-25) — **An order state change no longer logs a phantom transition when it loses a concurrent race, and a transient failure writing the history row no longer strands a stock hold.** Advancing an order through its lifecycle reads the order's current status, resolves the next state, then writes it with an UPDATE guarded on the row still being in the state it was read in — so when two writers race the same order (the stale-order reaper cancelling while a payment webhook marks it paid), exactly one wins and the loser collapses to a no-op. Two issues in that path are fixed. First, the state machine's transition audit was emitted before the guarded write resolved, so a writer that LOST the race still recorded a 'success' transition into the audit trail for a state change that never landed; the audit is now written only for the writer that actually moved the row. Second, the denormalized history-row insert ran between the committed status write and the inventory settlement with no failure handling: a transient error writing that row aborted the whole call after the status had already advanced, so the stock hold was never converted to a shelf debit (or released) and a webhook retry — finding the order already advanced — skipped settlement entirely, stranding the hold. The history insert is now best-effort (the state-machine audit is the authoritative record), so a failure writing it never prevents the hold from settling. No configuration changes. **Changed:** *Vendored framework refreshed to 0.15.24* — Updates the vendored blamejs framework to 0.15.24, an upstream build supply-chain hardening release with no runtime API changes. No operator action required. **Fixed:** *A lost order-transition race no longer records a phantom 'success' in the audit trail* — The order state machine emitted its transition audit during the in-memory replay, before the guarded status write that decides the race. A writer that lost the race (its conditional update matched zero rows and it no-opped) had nonetheless already written a 'success' transition into the audit trail — a record an auditor could not tell apart from a real one. The destination state is now resolved side-effect-free, and the transition audit is emitted only after the guarded write wins, so the audit trail records exactly the transitions that actually landed. · *A transient failure writing an order's history row no longer strands its stock hold* — After an order's status write committed, the order_transitions history row was inserted with no failure handling, ahead of the inventory settlement. A transient error on that insert aborted the call with the order already advanced, so the held stock was never debited or released; a webhook retry then found the order past its starting state, no-opped at the concurrency guard, and skipped settlement — leaving the hold stranded with no automatic recovery. A history-row write failure no longer aborts settlement, so the hold always settles once the state change commits. Because that row is also where a PayPal capture id is recovered from, a write failure is not silently dropped: the row's full payload — including any capture metadata — is recorded to the durable error feed (and the audit chain) for reconciliation.
12
16
 
13
17
  - v0.4.94 (2026-06-25) — **A print-on-demand fulfillment status change is now an atomic claim — concurrent updates can no longer clobber each other or record a phantom transition.** Advancing a print-on-demand fulfillment through its lifecycle (submitted → shipped / failed / cancelled) read the row's current status, validated the move in memory, and then wrote the new status with an UPDATE keyed on the row id alone. Two updates racing the same fulfillment — a worker marking it shipped while an operator marks it failed, or a re-delivered supplier webhook — both read the same starting status, both passed the in-memory check, and both wrote unconditionally, so the later write silently overwrote the earlier one's status and columns (a lost update), and each emitted a 'success' transition into the audit trail even though only one write reflected reality. The status write is now a single-statement atomic claim gated on the row still being in the state it was read in (the same compare-and-swap the dropship-forwarding and returns lifecycles already use): exactly one update wins, the loser changes zero rows and is refused, and the audit record is written only for the update that actually changed the row. No configuration changes. **Fixed:** *Concurrent print-on-demand fulfillment transitions no longer clobber each other or log a phantom transition* — The fulfillment status update was keyed on the row id with no guard on the starting state, so two concurrent transitions both wrote unconditionally — last-writer-wins overwrote the other's status and stamped columns, and both emitted a 'success' transition audit. The update is now an atomic compare-and-swap gated on the row still being in the from-state, with the destination resolved side-effect-free and the audit emitted only on the winning write. The losing transition changes zero rows and is refused with a clear concurrency error instead of corrupting the record.
package/lib/admin.js CHANGED
@@ -724,12 +724,23 @@ function _safeNotice(e, auditAction) {
724
724
  return { status: 400, code: "bad-request", message: "Invalid input." };
725
725
  }
726
726
  var msg = (e && e.message) || "";
727
- if (/UNIQUE constraint failed/i.test(msg) || /FOREIGN KEY constraint failed/i.test(msg)) {
728
- return /FOREIGN KEY/i.test(msg)
727
+ // Prefer a typed constraint code over message text: the production D1
728
+ // service-binding redacts the SQLite constraint string to a generic
729
+ // "HTTP 500", so a message regex is blind in prod (it only matches under
730
+ // node:sqlite). A generic, table-agnostic mapper has no UNIQUE key to
731
+ // re-read, so it can't use the state-agnostic re-read pattern the typed
732
+ // call sites use — it relies on `e.code` set by the caller / DB driver,
733
+ // falling back to the (test-only-reliable) message scan.
734
+ var sqlState = (e && (e.code || e.sqliteCode)) || "";
735
+ var isUnique = sqlState === "SQLITE_CONSTRAINT_UNIQUE" || sqlState === "UNIQUE" || /UNIQUE constraint failed/i.test(msg);
736
+ var isForeign = sqlState === "SQLITE_CONSTRAINT_FOREIGNKEY" || sqlState === "FOREIGN_KEY" || /FOREIGN KEY constraint failed/i.test(msg);
737
+ var isCheckNN = sqlState === "SQLITE_CONSTRAINT_CHECK" || sqlState === "SQLITE_CONSTRAINT_NOTNULL" || /(?:CHECK|NOT NULL) constraint failed/i.test(msg);
738
+ if (isUnique || isForeign) {
739
+ return isForeign
729
740
  ? { status: 409, code: "conflict", message: "A referenced record does not exist." }
730
741
  : { status: 409, code: "conflict", message: "That value is already in use." };
731
742
  }
732
- if (/(?:CHECK|NOT NULL) constraint failed/i.test(msg)) {
743
+ if (isCheckNN) {
733
744
  return { status: 400, code: "bad-request", message: "A required value is missing or invalid." };
734
745
  }
735
746
  // Genuine unknown error — record it server-side via the framework audit
package/lib/affiliates.js CHANGED
@@ -474,7 +474,17 @@ function create(opts) {
474
474
  break;
475
475
  } catch (e) {
476
476
  lastErr = e;
477
- if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) throw e;
477
+ // State-agnostic collision detection never error-message matching.
478
+ // The production D1 service-binding redacts the SQLite "UNIQUE
479
+ // constraint failed" text to a generic "HTTP 500", so a message regex
480
+ // is blind in prod (it only matches under node:sqlite). Re-read by the
481
+ // colliding UNIQUE key (`code`): if a row now holds the code we tried,
482
+ // the failure WAS the collision, so loop and regenerate; if no such
483
+ // row exists the insert failed for another reason, so re-throw.
484
+ var clash = await query(
485
+ "SELECT id FROM affiliates WHERE code = ?1", [row.code]
486
+ );
487
+ if (!clash.rows.length) throw e;
478
488
  }
479
489
  }
480
490
  if (lastErr) throw lastErr;
package/lib/api-keys.js CHANGED
@@ -352,7 +352,17 @@ function create(opts) {
352
352
  break;
353
353
  } catch (e) {
354
354
  lastErr = e;
355
- if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) throw e;
355
+ // State-agnostic collision detection never error-message matching.
356
+ // The production D1 service-binding redacts the SQLite "UNIQUE
357
+ // constraint failed" text to a generic "HTTP 500", so a message regex
358
+ // is blind in prod (it only matches under node:sqlite). Re-read by the
359
+ // colliding UNIQUE key (`token_hash`): if a row now holds this hash the
360
+ // failure WAS the collision, so regenerate and retry; otherwise the
361
+ // insert failed for another reason, so re-throw.
362
+ var clash = await query(
363
+ "SELECT id FROM api_keys WHERE token_hash = ?1", [row.token_hash]
364
+ );
365
+ if (!clash.rows.length) throw e;
356
366
  // Regenerate on collision.
357
367
  plaintext = _generateToken();
358
368
  row.token_hash = _hashToken(plaintext);
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.95",
2
+ "version": "0.4.97",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -1275,17 +1275,25 @@ function create(opts) {
1275
1275
  // a claim from a prior attempt whose rollback didn't complete —
1276
1276
  // reuse it (its counter reservation was already paid), after
1277
1277
  // returning the increment this call just took.
1278
- if (/UNIQUE constraint failed/i.test((e && e.message) || "")) {
1278
+ //
1279
+ // State-agnostic collision detection — never error-message matching.
1280
+ // The production D1 service-binding redacts the SQLite "UNIQUE
1281
+ // constraint failed" text to a generic "HTTP 500", so a message regex
1282
+ // is blind in prod (it only matches under node:sqlite). Re-read by the
1283
+ // colliding UNIQUE key (rule_slug, order_id): if a row now exists the
1284
+ // failure WAS the collision, so reuse it; otherwise the insert failed
1285
+ // for another reason, so re-throw (without returning the increment).
1286
+ var held = (await query(
1287
+ "SELECT id FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
1288
+ [slug, claimRef],
1289
+ )).rows[0];
1290
+ if (held) {
1279
1291
  await query(
1280
1292
  "UPDATE auto_discount_rules SET redemptions_used = " +
1281
1293
  "CASE WHEN redemptions_used > 0 THEN redemptions_used - 1 ELSE 0 END, updated_at = ?1 WHERE slug = ?2",
1282
1294
  [_now(), slug],
1283
1295
  );
1284
- var held = (await query(
1285
- "SELECT id FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
1286
- [slug, claimRef],
1287
- )).rows[0];
1288
- return { claimed: true, id: held ? held.id : null, reused: true };
1296
+ return { claimed: true, id: held.id, reused: true };
1289
1297
  }
1290
1298
  throw e;
1291
1299
  }
package/lib/quotes.js CHANGED
@@ -212,35 +212,27 @@ function _getQuoteFsm() {
212
212
  return _quoteFsm;
213
213
  }
214
214
 
215
- // Resolve the to-status for a legal (from, event) pair off the transition
216
- // table — the FSM's `can()` only answers yes/no, so the destination comes from
217
- // the declaration the machine was built from (one source of truth).
218
- function _toStatus(fromStatus, event) {
219
- for (var i = 0; i < QUOTE_TRANSITIONS.length; i += 1) {
220
- var t = QUOTE_TRANSITIONS[i];
221
- if (t.from === fromStatus && t.on === event) return t.to;
222
- }
223
- return null;
224
- }
225
-
226
215
  // Validate one status-changing edge through the FSM. `event` is the verb name
227
216
  // above; `fromStatus` is the row's CURRENT status. Returns the resolved
228
217
  // to-status on success. Throws a coded QUOTE_TRANSITION_REFUSED Error when the
229
218
  // machine refuses the edge (terminal state, wrong source state, or unknown
230
219
  // event) so the caller maps it to a uniform 409 regardless of which guard
231
- // tripped — the same shape the hand-rolled status checks used to throw. The
232
- // machine's synchronous `can()` is the gate (no guards declared, so it never
233
- // awaits); the destination is read back off the transition table.
220
+ // tripped — the same shape the hand-rolled status checks used to throw.
221
+ // `target` resolves the destination state side-effect-free (no audit emit, no
222
+ // state mutation) and returns null on an illegal edge — so it is BOTH the gate
223
+ // and the to-status resolver in one call, read off the single FSM source of
224
+ // truth rather than a parallel transition-table walk that could drift from it.
234
225
  function _assertTransition(fromStatus, event, verbLabel) {
235
226
  var fsm = _getQuoteFsm();
236
227
  var instance = fsm.restore({ state: fromStatus, history: [], context: {} });
237
- if (!instance.can(event)) {
228
+ var toStatus = instance.target(event);
229
+ if (toStatus == null) {
238
230
  var refused = new Error("quotes." + verbLabel + ": refused — quote is " +
239
231
  fromStatus + ", cannot " + event);
240
232
  refused.code = "QUOTE_TRANSITION_REFUSED";
241
233
  throw refused;
242
234
  }
243
- return _toStatus(fromStatus, event);
235
+ return toStatus;
244
236
  }
245
237
 
246
238
  // ---- validators ---------------------------------------------------------
@@ -476,26 +476,29 @@ function create(opts) {
476
476
  [id, customerId, tier, periodLabel, pointsBudget, ts],
477
477
  );
478
478
  } catch (e) {
479
- if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
480
- // Lost the race to a concurrent caller return their row.
481
- var raced = await query(
482
- "SELECT id, customer_id, tier, period_label, points_awarded, occurred_at " +
483
- "FROM referral_leaderboard_bonuses " +
484
- "WHERE customer_id = ?1 AND period_label = ?2 AND tier = ?3 LIMIT 1",
485
- [customerId, periodLabel, tier],
486
- );
487
- if (raced.rows.length) {
488
- var rr = raced.rows[0];
489
- return {
490
- id: rr.id,
491
- customer_id: rr.customer_id,
492
- tier: rr.tier,
493
- period_label: rr.period_label,
494
- points_awarded: Number(rr.points_awarded || 0),
495
- occurred_at: Number(rr.occurred_at || 0),
496
- status: "already-awarded",
497
- };
498
- }
479
+ // State-agnostic collision detection: prod D1 redacts the SQLite
480
+ // "UNIQUE constraint failed" message to a generic "HTTP 500", so a
481
+ // message regex is blind there. Re-read by the UNIQUE key
482
+ // (customer_id, period_label, tier) if the row now exists we lost
483
+ // the race to a concurrent caller, so return their row; otherwise
484
+ // the insert failed for another reason, so re-throw.
485
+ var raced = await query(
486
+ "SELECT id, customer_id, tier, period_label, points_awarded, occurred_at " +
487
+ "FROM referral_leaderboard_bonuses " +
488
+ "WHERE customer_id = ?1 AND period_label = ?2 AND tier = ?3 LIMIT 1",
489
+ [customerId, periodLabel, tier],
490
+ );
491
+ if (raced.rows.length) {
492
+ var rr = raced.rows[0];
493
+ return {
494
+ id: rr.id,
495
+ customer_id: rr.customer_id,
496
+ tier: rr.tier,
497
+ period_label: rr.period_label,
498
+ points_awarded: Number(rr.points_awarded || 0),
499
+ occurred_at: Number(rr.occurred_at || 0),
500
+ status: "already-awarded",
501
+ };
499
502
  }
500
503
  throw e;
501
504
  }
package/lib/referrals.js CHANGED
@@ -260,8 +260,16 @@ function create(opts) {
260
260
  break;
261
261
  } catch (e) {
262
262
  lastErr = e;
263
- // UNIQUE-violation: regenerate. Any other error propagates.
264
- if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) {
263
+ // State-agnostic collision detection never error-message
264
+ // matching. The production D1 service-binding redacts the
265
+ // SQLite "UNIQUE constraint failed" text to a generic "HTTP
266
+ // 500", so a message regex is blind in prod. Re-read the
267
+ // colliding `code`: if a row with that code now exists the
268
+ // failure WAS the UNIQUE race, so we regenerate and retry; if
269
+ // no such row exists the insert failed for another reason and
270
+ // we propagate. The cap (5 attempts) is unchanged.
271
+ var clash = await _codeRowByCode(code);
272
+ if (!clash) {
265
273
  throw e;
266
274
  }
267
275
  }
@@ -385,7 +385,16 @@ function create(opts) {
385
385
  break;
386
386
  } catch (e) {
387
387
  lastErr = e;
388
- if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) throw e;
388
+ // State-agnostic collision detection never error-message
389
+ // matching. The production D1 service-binding redacts the SQLite
390
+ // "UNIQUE constraint failed" text to a generic "HTTP 500", so a
391
+ // message regex is blind in prod. Re-read by the colliding
392
+ // token_hash: if a row with that hash now exists the failure WAS
393
+ // the UNIQUE race, so we regenerate the token and retry; if no
394
+ // such row exists the insert failed for another reason and we
395
+ // propagate. The cap (5 attempts) is unchanged.
396
+ var clash = await _getRawByTokenHash(tokenHash);
397
+ if (!clash) throw e;
389
398
  }
390
399
  }
391
400
  if (lastErr) throw lastErr;
package/lib/tenants.js CHANGED
@@ -265,7 +265,17 @@ function create(opts) {
265
265
  [id, tenantId, domain, isPrimary ? 1 : 0, ts]
266
266
  );
267
267
  } catch (e) {
268
- if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
268
+ // State-agnostic collision detection: the production D1 service
269
+ // binding redacts the SQLite "UNIQUE constraint failed" text to a
270
+ // generic "HTTP 500", so a message regex is blind in prod. Re-read
271
+ // by the UNIQUE key (tenant_domains.domain is globally UNIQUE) — if
272
+ // the row now exists, the insert lost a duplicate-domain race; if
273
+ // not, the insert failed for another reason, so re-throw.
274
+ var clash = await query(
275
+ "SELECT 1 FROM tenant_domains WHERE domain = ?1 LIMIT 1",
276
+ [domain]
277
+ );
278
+ if (clash.rows.length) {
269
279
  var dupe = new Error("tenants: domain '" + domain + "' is already registered");
270
280
  dupe.code = "TENANT_DOMAIN_DUPLICATE";
271
281
  throw dupe;
@@ -337,7 +347,14 @@ function create(opts) {
337
347
  [id, slug, name, defaultCurrency, defaultLocale, themeSlug, status, ts]
338
348
  );
339
349
  } catch (e) {
340
- if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
350
+ // State-agnostic collision detection: prod D1 redacts the SQLite
351
+ // "UNIQUE constraint failed" message to a generic "HTTP 500", so a
352
+ // message regex is blind there. Re-read by the UNIQUE key
353
+ // (tenants.slug) — if the row now exists, a concurrent define won
354
+ // the slug; otherwise the insert failed for another reason, so
355
+ // re-throw.
356
+ var clash = await _tenantBySlug(slug);
357
+ if (clash) {
341
358
  var slugDupe = new Error("tenants.defineTenant: slug '" + slug + "' is already registered");
342
359
  slugDupe.code = "TENANT_SLUG_DUPLICATE";
343
360
  throw slugDupe;
package/lib/text-guard.js CHANGED
@@ -15,13 +15,9 @@
15
15
  * The dangerous-codepoint surface (bidi overrides, C0 control bytes,
16
16
  * null bytes, zero-width / invisible formatting, mixed-script
17
17
  * confusables) is NOT reinvented here — it composes the framework's
18
- * own codepoint catalog, the single source of truth the guard-*
19
- * family already builds on. The framework does not yet re-export the
20
- * catalog on its public index; until it does, this module reaches
21
- * the catalog leaf directly. The leaf has no dependency on any shop
22
- * module, so requiring it is refresh-safe; an upstream one-line
23
- * `module.exports` add would let this collapse to `b.codepointClass`
24
- * with no behavior change.
18
+ * own codepoint catalog (`b.codepointClass`), the single source of
19
+ * truth the guard-* family already builds on, so the tables and
20
+ * regexes never drift into a second hand-maintained copy.
25
21
  *
26
22
  * Validators THROW a `TypeError` on bad input. The admin / account
27
23
  * request wrappers map a thrown `TypeError` to a clean 400, so a
@@ -50,13 +46,11 @@
50
46
  var b = require("./vendor/blamejs");
51
47
 
52
48
  // The framework's codepoint catalog (bidi / control / null / zero-width
53
- // tables + regexes, the script ranges, and the detect/assert/strip
54
- // helpers the guard-* family composes). It is not on the framework's
55
- // public index yet; the catalog leaf has no shop-module dependency, so
56
- // composing it directly stays refresh-safe and keeps a single source of
57
- // truth instead of a second copy of the tables.
58
- // allow:vendor-hand-edit — composing the framework's codepoint catalog leaf (no shop dependency, refresh-safe); collapses to b.codepointClass once upstream re-exports it
59
- var codepointClass = require("./vendor/blamejs/lib/codepoint-class");
49
+ // tables + regexes, the script ranges, and the detect/assert/strip helpers
50
+ // the guard-* family composes), exposed on the public surface as
51
+ // b.codepointClass so this composes a single source of truth instead of a
52
+ // second copy of the tables.
53
+ var codepointClass = b.codepointClass;
60
54
 
61
55
  // ---- shop validators ----------------------------------------------------
62
56
 
package/lib/vendors.js CHANGED
@@ -615,7 +615,16 @@ function create(opts) {
615
615
  [vendorSlug, sku, ts],
616
616
  );
617
617
  } catch (e) {
618
- if (e && e.message && e.message.indexOf("UNIQUE") !== -1) {
618
+ // State-agnostic collision detection: prod D1 redacts the SQLite
619
+ // "UNIQUE constraint failed" message to a generic "HTTP 500", so a
620
+ // message regex is blind there. Re-read by the UNIQUE key
621
+ // (vendor_skus.sku) — if the row now exists, a concurrent assign
622
+ // claimed the SKU; otherwise the insert failed for another reason,
623
+ // so re-throw.
624
+ var clash = await query(
625
+ "SELECT vendor_slug FROM vendor_skus WHERE sku = ?1", [sku],
626
+ );
627
+ if (clash.rows.length) {
619
628
  var raced = new Error("vendors.assignSku: sku " + JSON.stringify(sku) + " is already assigned");
620
629
  raced.code = "VENDOR_SKU_TAKEN";
621
630
  throw raced;
@@ -704,12 +704,21 @@ function create(opts) {
704
704
  [eventId, slug, idempotencyKey, bodySha, bodyBytes.length, nowMs],
705
705
  );
706
706
  } catch (e) {
707
- // The UNIQUE(source_slug, idempotency_key) constraint races
708
- // when two deliveries with the same key arrive simultaneously
709
- // — the second insert collapses to a replay rather than a
710
- // hard error. The caller saw a successful verify; we treat
711
- // the late-arriving duplicate as a benign replay.
712
- if (e && e.message && e.message.indexOf("UNIQUE") !== -1 && idempotencyKey != null) {
707
+ // The UNIQUE(source_slug, idempotency_key) constraint races when two
708
+ // deliveries with the same key arrive simultaneously — the second
709
+ // insert collapses to a replay rather than a hard error.
710
+ //
711
+ // State-agnostic collision detection never error-message matching.
712
+ // The production D1 service-binding redacts the SQLite "UNIQUE
713
+ // constraint failed" text to a generic "HTTP 500", so a message regex
714
+ // is blind in prod (it only matches under node:sqlite, where the full
715
+ // text survives). On ANY insert error we re-read the (source_slug,
716
+ // idempotency_key) row: if it now exists the failure WAS the UNIQUE
717
+ // race, so we collapse to a benign replay; if it does not exist the
718
+ // insert failed for another reason and we re-throw the original error.
719
+ // The idempotencyKey != null guard stays — a row with a NULL key has
720
+ // no unique slot to replay onto.
721
+ if (idempotencyKey != null) {
713
722
  var late = await query(
714
723
  "SELECT id FROM webhook_received_events " +
715
724
  "WHERE source_slug = ?1 AND idempotency_key = ?2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.95",
3
+ "version": "0.4.97",
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": {