@blamejs/blamejs-shop 0.4.96 → 0.4.98
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 +14 -3
- package/lib/affiliates.js +11 -1
- package/lib/api-keys.js +11 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/auto-discount.js +14 -6
- package/lib/referral-leaderboard.js +23 -20
- package/lib/referrals.js +10 -2
- package/lib/subscription-gifts.js +10 -1
- package/lib/tenants.js +19 -2
- package/lib/vendors.js +40 -14
- package/lib/webhook-receiver.js +15 -6
- 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.98 (2026-06-25) — **A concurrent vendor registration with the same slug now returns a typed duplicate error instead of leaking a raw database error.** Registering a vendor checks whether the slug is already taken before inserting, then inserts. Two registrations of the same slug racing each other can both pass that pre-check, so the second insert collides on the slug primary key. That insert had no failure handling, so the raw database error surfaced — and in production, where the data layer redacts it to a generic 'HTTP 500', the caller got an opaque error rather than the typed 'slug already registered' it gets from the pre-check. The insert is now wrapped: on any failure it re-reads by slug, and if a row exists it raises the same typed duplicate error the pre-check raises, otherwise it re-throws. No configuration changes. **Fixed:** *Racing vendor registrations surface a typed duplicate error, not a raw 500* — vendors.registerVendor pre-checks the slug and then inserts, but a concurrent registration of the same slug can slip past the pre-check and collide on the slug primary key. The insert is now wrapped to detect that state-agnostically — re-reading by slug after a failure and raising the same typed VENDOR_SLUG_TAKEN error the pre-check raises (rather than leaking the raw constraint error, which production redacts to a bare 500). Mirrors the assignSku duplicate handling.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- 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.
|
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
|
-
|
|
728
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/lib/asset-manifest.json
CHANGED
package/lib/auto-discount.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -476,26 +476,29 @@ function create(opts) {
|
|
|
476
476
|
[id, customerId, tier, periodLabel, pointsBudget, ts],
|
|
477
477
|
);
|
|
478
478
|
} catch (e) {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
//
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/vendors.js
CHANGED
|
@@ -398,19 +398,36 @@ function create(opts) {
|
|
|
398
398
|
}
|
|
399
399
|
|
|
400
400
|
var ts = _now();
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
401
|
+
try {
|
|
402
|
+
await query(
|
|
403
|
+
"INSERT INTO vendors " +
|
|
404
|
+
"(slug, name, contact_email_hash, contact_email_normalised, " +
|
|
405
|
+
" contact_phone, address_json, payout_method, payout_address, " +
|
|
406
|
+
" commission_split_bps, status, paused_at, archived_at, " +
|
|
407
|
+
" created_at, updated_at) " +
|
|
408
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, ?12, ?12)",
|
|
409
|
+
[
|
|
410
|
+
slug, name, emailHash, emailNorm, phone, addressJson,
|
|
411
|
+
payoutMethod, payoutAddr, splitBps, status,
|
|
412
|
+
status === "paused" ? ts : null, ts,
|
|
413
|
+
],
|
|
414
|
+
);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
// The pre-check above can miss a CONCURRENT registration, and the
|
|
417
|
+
// production D1 service binding redacts the SQLite slug-PRIMARY-KEY
|
|
418
|
+
// constraint message to a generic "HTTP 500", so a message regex would
|
|
419
|
+
// be blind there. State-agnostic detection: re-read by the UNIQUE key
|
|
420
|
+
// (vendors.slug) — if the row now exists a racing register won, so
|
|
421
|
+
// surface the SAME typed duplicate the pre-check throws; otherwise the
|
|
422
|
+
// insert failed for another reason, so re-throw.
|
|
423
|
+
var raced = await _getVendorRaw(slug);
|
|
424
|
+
if (raced) {
|
|
425
|
+
var racedDupe = new Error("vendors.registerVendor: slug " + JSON.stringify(slug) + " already registered");
|
|
426
|
+
racedDupe.code = "VENDOR_SLUG_TAKEN";
|
|
427
|
+
throw racedDupe;
|
|
428
|
+
}
|
|
429
|
+
throw e;
|
|
430
|
+
}
|
|
414
431
|
return _hydrateVendor(await _getVendorRaw(slug));
|
|
415
432
|
},
|
|
416
433
|
|
|
@@ -615,7 +632,16 @@ function create(opts) {
|
|
|
615
632
|
[vendorSlug, sku, ts],
|
|
616
633
|
);
|
|
617
634
|
} catch (e) {
|
|
618
|
-
|
|
635
|
+
// State-agnostic collision detection: prod D1 redacts the SQLite
|
|
636
|
+
// "UNIQUE constraint failed" message to a generic "HTTP 500", so a
|
|
637
|
+
// message regex is blind there. Re-read by the UNIQUE key
|
|
638
|
+
// (vendor_skus.sku) — if the row now exists, a concurrent assign
|
|
639
|
+
// claimed the SKU; otherwise the insert failed for another reason,
|
|
640
|
+
// so re-throw.
|
|
641
|
+
var clash = await query(
|
|
642
|
+
"SELECT vendor_slug FROM vendor_skus WHERE sku = ?1", [sku],
|
|
643
|
+
);
|
|
644
|
+
if (clash.rows.length) {
|
|
619
645
|
var raced = new Error("vendors.assignSku: sku " + JSON.stringify(sku) + " is already assigned");
|
|
620
646
|
raced.code = "VENDOR_SKU_TAKEN";
|
|
621
647
|
throw raced;
|
package/lib/webhook-receiver.js
CHANGED
|
@@ -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
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
|
|
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