@blamejs/blamejs-shop 0.4.62 → 0.4.64

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/lib/preorder.js CHANGED
@@ -245,29 +245,67 @@ function create(opts) {
245
245
  // order surface they used). The per-campaign counter is NOT
246
246
  // decremented on conversion — the reserved capacity is consumed by
247
247
  // the order, not freed.
248
+ //
249
+ // Returns `{ won: <bool>, orderId }`. `won` is false when the
250
+ // conditional claim lost the race (the row was already flipped out
251
+ // of 'active' by a concurrent converter) — the caller skips the
252
+ // exactly-once side-effect for that row.
253
+ //
254
+ // Concurrency: the status flip is the atomic claim and runs FIRST,
255
+ // gated by `AND status = 'active'`, so two concurrent converters
256
+ // can't both mint an order for the same reservation. SQLite
257
+ // serialises writers, so exactly one UPDATE matches; the loser sees
258
+ // rowCount 0 and bails without calling `createFromCart`. When the
259
+ // order side-effect throws after the claim, the claim is reverted
260
+ // (self-targeting `WHERE id=? AND status='converted'`) so the row
261
+ // isn't stranded in 'converted' with no backing order.
248
262
  async function _convertOne(reservation, campaign, ts) {
263
+ var claim = await query(
264
+ "UPDATE preorder_reservations SET status = 'converted', converted_at = ?1 " +
265
+ "WHERE id = ?2 AND status = 'active'",
266
+ [ts, reservation.id],
267
+ );
268
+ if (Number(claim.rowCount || 0) !== 1) {
269
+ // Lost the race — a concurrent converter already claimed this
270
+ // reservation. Skip the side-effect; the winner mints the order.
271
+ return { won: false, orderId: null };
272
+ }
249
273
  var convertedOrderId = null;
250
274
  if (orderHandle) {
251
- var orderResult = await orderHandle.createFromCart({
252
- customer_id: reservation.customer_id,
253
- lines: [{
254
- sku: campaign.sku,
255
- variant_id: campaign.variant_id,
256
- quantity: reservation.quantity,
257
- unit_price_minor: campaign.full_price_minor,
258
- currency: campaign.currency,
259
- }],
260
- preorder_reservation_id: reservation.id,
261
- preorder_campaign_slug: campaign.slug,
262
- });
275
+ var orderResult;
276
+ try {
277
+ orderResult = await orderHandle.createFromCart({
278
+ customer_id: reservation.customer_id,
279
+ lines: [{
280
+ sku: campaign.sku,
281
+ variant_id: campaign.variant_id,
282
+ quantity: reservation.quantity,
283
+ unit_price_minor: campaign.full_price_minor,
284
+ currency: campaign.currency,
285
+ }],
286
+ preorder_reservation_id: reservation.id,
287
+ preorder_campaign_slug: campaign.slug,
288
+ });
289
+ } catch (e) {
290
+ // The order create failed after we claimed the row — revert
291
+ // the claim so the reservation returns to 'active' and a retry
292
+ // (or the next launch sweep) can convert it cleanly.
293
+ await query(
294
+ "UPDATE preorder_reservations SET status = 'active', converted_at = NULL " +
295
+ "WHERE id = ?1 AND status = 'converted'",
296
+ [reservation.id],
297
+ );
298
+ throw e;
299
+ }
263
300
  convertedOrderId = orderResult && orderResult.id ? orderResult.id : null;
264
301
  }
302
+ // Backfill the order id onto the already-claimed row.
265
303
  await query(
266
- "UPDATE preorder_reservations SET status = 'converted', converted_at = ?1, " +
267
- "converted_order_id = ?2 WHERE id = ?3 AND status = 'active'",
268
- [ts, convertedOrderId, reservation.id],
304
+ "UPDATE preorder_reservations SET converted_order_id = ?1 " +
305
+ "WHERE id = ?2 AND status = 'converted'",
306
+ [convertedOrderId, reservation.id],
269
307
  );
270
- return convertedOrderId;
308
+ return { won: true, orderId: convertedOrderId };
271
309
  }
272
310
 
273
311
  return {
@@ -369,6 +407,15 @@ function create(opts) {
369
407
  throw new TypeError("preorder.reserve: campaign is " + campaign.status +
370
408
  ", only active campaigns accept reservations");
371
409
  }
410
+ // The JS cap pre-check below stays for a precise, friendly error
411
+ // message on the common (uncontended) path — but it is NOT the
412
+ // guard. Two concurrent reserves could both pass this read-time
413
+ // check against the same `units_reserved` and both insert,
414
+ // overshooting `max_units_available` (oversell). The ATOMIC guard
415
+ // is the conditional increment further down: the cap is folded
416
+ // into the UPDATE's WHERE so the increment only applies when it
417
+ // stays within the cap, and SQLite serialises the two writers so
418
+ // exactly one wins.
372
419
  if (campaign.max_units_available !== null && campaign.max_units_available !== undefined) {
373
420
  if (campaign.units_reserved + input.quantity > campaign.max_units_available) {
374
421
  throw new TypeError("preorder.reserve: would exceed max_units_available " +
@@ -378,23 +425,46 @@ function create(opts) {
378
425
  }
379
426
 
380
427
  var ts = _now();
428
+ // Claim capacity FIRST via a conditional increment that gates on
429
+ // the cap inside its own WHERE. rowCount 0 means the cap would be
430
+ // exceeded (or the campaign vanished) — refuse before writing any
431
+ // reservation row, so the counter never overshoots the cap under
432
+ // concurrency.
433
+ var claim = await query(
434
+ "UPDATE preorder_campaigns SET units_reserved = units_reserved + ?1, updated_at = ?2 " +
435
+ "WHERE slug = ?3 AND status = 'active' " +
436
+ "AND (max_units_available IS NULL OR units_reserved + ?1 <= max_units_available)",
437
+ [input.quantity, ts, input.campaign_slug],
438
+ );
439
+ if (Number(claim.rowCount || 0) !== 1) {
440
+ throw new TypeError("preorder.reserve: would exceed max_units_available " +
441
+ "(cap=" + campaign.max_units_available + ", reserved=" + campaign.units_reserved +
442
+ ", requested=" + input.quantity + ")");
443
+ }
381
444
  // Pass the monotonic ts into uuid.v7 so the 48-bit timestamp
382
445
  // prefix is strictly increasing across consecutive reservations
383
446
  // — the (id) keyset cursor used by reservationsForCustomer is
384
447
  // monotonic without depending on Date.now() ticking between
385
448
  // calls.
386
449
  var id = b.uuid.v7({ now: ts });
387
- await query(
388
- "INSERT INTO preorder_reservations (id, campaign_slug, customer_id, quantity, " +
389
- "payment_intent_id, status, reservation_at) " +
390
- "VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6)",
391
- [id, input.campaign_slug, input.customer_id, input.quantity, paymentIntentId, ts],
392
- );
393
- await query(
394
- "UPDATE preorder_campaigns SET units_reserved = units_reserved + ?1, updated_at = ?2 " +
395
- "WHERE slug = ?3",
396
- [input.quantity, ts, input.campaign_slug],
397
- );
450
+ try {
451
+ await query(
452
+ "INSERT INTO preorder_reservations (id, campaign_slug, customer_id, quantity, " +
453
+ "payment_intent_id, status, reservation_at) " +
454
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6)",
455
+ [id, input.campaign_slug, input.customer_id, input.quantity, paymentIntentId, ts],
456
+ );
457
+ } catch (e) {
458
+ // The reservation insert failed after we claimed capacity —
459
+ // compensate by releasing the units we reserved so the counter
460
+ // doesn't strand capacity the customer never got.
461
+ await query(
462
+ "UPDATE preorder_campaigns SET units_reserved = MAX(0, units_reserved - ?1), " +
463
+ "updated_at = ?2 WHERE slug = ?3",
464
+ [input.quantity, _now(), input.campaign_slug],
465
+ );
466
+ throw e;
467
+ }
398
468
  return await _getReservation(id);
399
469
  },
400
470
 
@@ -447,11 +517,24 @@ function create(opts) {
447
517
  ", only active reservations can be cancelled");
448
518
  }
449
519
  var ts = _now();
450
- await query(
520
+ // Claim the cancel atomically, gated on `status = 'active'`. The
521
+ // JS check above is advisory; this conditional UPDATE is the
522
+ // guard. Two concurrent cancels of the same reservation would
523
+ // otherwise both pass the JS check and both decrement the
524
+ // counter — double-freeing capacity. SQLite serialises writers,
525
+ // so exactly one UPDATE matches; the loser sees rowCount 0 and
526
+ // refuses WITHOUT decrementing, so the counter stays correct.
527
+ var claim = await query(
451
528
  "UPDATE preorder_reservations SET status = 'cancelled', cancelled_at = ?1, " +
452
- "cancel_reason = ?2 WHERE id = ?3",
529
+ "cancel_reason = ?2 WHERE id = ?3 AND status = 'active'",
453
530
  [ts, reason, reservation.id],
454
531
  );
532
+ if (Number(claim.rowCount || 0) !== 1) {
533
+ throw new TypeError("preorder.cancelReservation: reservation " +
534
+ JSON.stringify(reservation.id) + " was already cancelled or converted concurrently");
535
+ }
536
+ // Only the winner of the claim frees capacity. MAX(0, …) clamps
537
+ // the counter against out-of-band corruption.
455
538
  await query(
456
539
  "UPDATE preorder_campaigns SET units_reserved = MAX(0, units_reserved - ?1), " +
457
540
  "updated_at = ?2 WHERE slug = ?3",
@@ -484,10 +567,18 @@ function create(opts) {
484
567
  JSON.stringify(reservation.campaign_slug) + " not found");
485
568
  }
486
569
  var ts = _now();
487
- var orderId = await _convertOne(reservation, campaign, ts);
570
+ // `_convertOne` does the atomic claim internally. If a concurrent
571
+ // converter beat us to this reservation we lose the race and
572
+ // refuse — the JS `status === "active"` check above is advisory;
573
+ // the conditional UPDATE is the real guard.
574
+ var outcome = await _convertOne(reservation, campaign, ts);
575
+ if (!outcome.won) {
576
+ throw new TypeError("preorder.convertReservationToOrder: reservation " +
577
+ JSON.stringify(reservation.id) + " was already converted concurrently");
578
+ }
488
579
  return {
489
580
  reservation_id: reservation.id,
490
- converted_order_id: orderId,
581
+ converted_order_id: outcome.orderId,
491
582
  status: "converted",
492
583
  };
493
584
  },
@@ -516,22 +607,54 @@ function create(opts) {
516
607
  throw new TypeError("preorder.launchCampaign: now (" + input.now +
517
608
  ") is before launch_at (" + campaign.launch_at + ")");
518
609
  }
610
+ var ts = _now();
611
+ // Claim the campaign FIRST, gated on `status = 'active'`. The JS
612
+ // status check above is advisory; this conditional UPDATE is the
613
+ // atomic guard. SQLite serialises writers, so exactly one of two
614
+ // concurrent launches matches — the loser sees rowCount 0 and
615
+ // refuses, so the reservation-conversion sweep below runs at most
616
+ // once for the campaign. (Each individual reservation is ALSO
617
+ // claim-guarded inside `_convertOne`, so even a torn interleave
618
+ // can't double-mint an order.)
619
+ var claim = await query(
620
+ "UPDATE preorder_campaigns SET status = 'launched', launched_at = ?1, updated_at = ?1 " +
621
+ "WHERE slug = ?2 AND status = 'active'",
622
+ [ts, input.slug],
623
+ );
624
+ if (Number(claim.rowCount || 0) !== 1) {
625
+ throw new TypeError("preorder.launchCampaign: campaign " +
626
+ JSON.stringify(input.slug) + " was already launched concurrently");
627
+ }
519
628
  var activeRows = (await query(
520
629
  "SELECT * FROM preorder_reservations WHERE campaign_slug = ?1 AND status = 'active' " +
521
630
  "ORDER BY id ASC",
522
631
  [input.slug],
523
632
  )).rows;
524
- var ts = _now();
525
633
  var converted = [];
526
- for (var i = 0; i < activeRows.length; i += 1) {
527
- var orderId = await _convertOne(activeRows[i], campaign, ts);
528
- converted.push({ reservation_id: activeRows[i].id, converted_order_id: orderId });
634
+ try {
635
+ for (var i = 0; i < activeRows.length; i += 1) {
636
+ var outcome = await _convertOne(activeRows[i], campaign, ts);
637
+ // A reservation cancelled/converted out-of-band between the
638
+ // SELECT and the per-row claim is skipped silently — it's no
639
+ // longer 'active', so it isn't ours to convert.
640
+ if (outcome.won) {
641
+ converted.push({ reservation_id: activeRows[i].id, converted_order_id: outcome.orderId });
642
+ }
643
+ }
644
+ } catch (convErr) {
645
+ // A conversion threw mid-sweep. Revert the campaign claim back to
646
+ // 'active' so a retry can re-launch and pick up the reservations
647
+ // still left active (the ones already converted stay converted —
648
+ // _convertOne skips non-active rows on the retry). Without this
649
+ // the campaign is stranded 'launched' and the unconverted
650
+ // reservations can never be swept into orders.
651
+ await query(
652
+ "UPDATE preorder_campaigns SET status = 'active', launched_at = NULL, updated_at = ?1 " +
653
+ "WHERE slug = ?2 AND status = 'launched'",
654
+ [_now(), input.slug],
655
+ );
656
+ throw convErr;
529
657
  }
530
- await query(
531
- "UPDATE preorder_campaigns SET status = 'launched', launched_at = ?1, updated_at = ?1 " +
532
- "WHERE slug = ?2",
533
- [ts, input.slug],
534
- );
535
658
  return {
536
659
  slug: input.slug,
537
660
  launched_at: ts,
@@ -437,12 +437,28 @@ function create(opts) {
437
437
  JSON.stringify(input.station_id));
438
438
  }
439
439
  var ts = _now();
440
- await query(
440
+ // The in_progress -> failed transition IS the atomic claim: gate
441
+ // it on `status = 'in_progress'` in the WHERE so two concurrent
442
+ // markFailed callers (e.g. an operator retry plus an automated
443
+ // jam-detector) can't both win the JS check above and both fire
444
+ // the retry INSERT below. SQLite serializes writers, so exactly
445
+ // one UPDATE flips the row; the loser sees rowCount === 0 and
446
+ // returns the already-failed state WITHOUT re-requeueing —
447
+ // otherwise a single failure could mint two duplicate queued
448
+ // jobs (double-print).
449
+ var upd = await query(
441
450
  "UPDATE print_jobs SET status = 'failed', failed_at = ?1, fail_reason = ?2 " +
442
- "WHERE id = ?3",
451
+ "WHERE id = ?3 AND status = 'in_progress'",
443
452
  [ts, reason, jobId],
444
453
  );
445
454
  var failed = await _getRow(jobId);
455
+ if (Number(upd.rowCount || 0) !== 1) {
456
+ // Another caller already transitioned this job out of
457
+ // in_progress. Return its current state; do not re-insert a
458
+ // retry row (that's the exactly-once side-effect we're
459
+ // protecting).
460
+ return { failed: failed };
461
+ }
446
462
  var out = { failed: failed };
447
463
 
448
464
  if (retry) {
@@ -599,17 +599,103 @@ function create(opts) {
599
599
  }
600
600
  }
601
601
 
602
- // Compose inventoryReceive.draft + apply BEFORE advancing the
603
- // PO counters. If the catalog refuses (unknown SKU, decimal
604
- // qty, etc.) the PO state stays consistent with the catalog —
605
- // a half-applied receipt across two writeable surfaces is the
606
- // worst-case for reconciliation. The receipt primitive's draft
607
- // step requires a unique `reference`; when the caller didn't
608
- // supply one, derive a deterministic-per-call value from the
609
- // PO id + occurrence timestamp so two recordPartialReceipt
610
- // calls on the same PO produce distinct receipt rows.
611
- var ts = _now();
602
+ var ts = _now();
603
+ var priorStatus = current.status;
604
+
605
+ // Claim the receipt atomically by advancing the per-line
606
+ // counters BEFORE composing the inventoryReceive restock — the
607
+ // line counter is the true exactly-once gate here. A PO-level
608
+ // status claim can't serialize two concurrent receipts on its
609
+ // own: 'partially_received' is both a legal source AND a legal
610
+ // destination (a partial receipt that leaves the PO still
611
+ // partial is a status self-loop), so a status-only WHERE matches
612
+ // for both racers. Instead each line increment is guarded by the
613
+ // exact prior quantity the caller observed AND the ordered cap:
614
+ // SET quantity_received = quantity_received + addQ
615
+ // WHERE id = lineId
616
+ // AND quantity_received = observedPrior -- optimistic CAS
617
+ // AND quantity_received + addQ <= quantity_ordered -- cap
618
+ // Two concurrent receipts both observe prior=P; the winner's
619
+ // increment lands (P -> P+addQ), the loser's `= P` no longer
620
+ // matches (the row now reads P+addQ) so its rowCount is 0 — it
621
+ // refuses, and the restock + counter advance run for exactly one
622
+ // caller. The cap clause also makes over-receipt a hard refusal
623
+ // at write time, not just at the JS pre-flight above.
624
+ var claimed = [];
625
+ for (var c = 0; c < rxSkus.length; c += 1) {
626
+ var cLine = skuToLine[rxSkus[c]];
627
+ var cRxe = rxMap[rxSkus[c]];
628
+ var cPrior = Number(cLine.quantity_received || 0);
629
+ var cAdd = cRxe.quantity_received;
630
+ var lineClaim;
631
+ if (cRxe.unit_cost_minor != null) {
632
+ lineClaim = await query(
633
+ "UPDATE purchase_order_lines SET quantity_received = quantity_received + ?1, " +
634
+ "unit_cost_minor = ?2, updated_at = ?3 " +
635
+ "WHERE id = ?4 AND quantity_received = ?5 AND quantity_received + ?1 <= quantity_ordered",
636
+ [cAdd, cRxe.unit_cost_minor, ts, cLine.id, cPrior],
637
+ );
638
+ } else {
639
+ lineClaim = await query(
640
+ "UPDATE purchase_order_lines SET quantity_received = quantity_received + ?1, " +
641
+ "updated_at = ?2 " +
642
+ "WHERE id = ?3 AND quantity_received = ?4 AND quantity_received + ?1 <= quantity_ordered",
643
+ [cAdd, ts, cLine.id, cPrior],
644
+ );
645
+ }
646
+ if (Number(lineClaim.rowCount || 0) !== 1) {
647
+ // Lost the race on this line (a concurrent receipt already
648
+ // advanced it past the value we observed) — roll back the
649
+ // line claims we already won this call, self-targeting the
650
+ // exact amount each added so a concurrent winner isn't
651
+ // clobbered, then refuse. No restock has fired yet.
652
+ for (var rb = 0; rb < claimed.length; rb += 1) {
653
+ if (claimed[rb].costChanged) {
654
+ await query(
655
+ "UPDATE purchase_order_lines SET quantity_received = quantity_received - ?1, " +
656
+ "unit_cost_minor = ?2, updated_at = ?3 WHERE id = ?4 AND quantity_received >= ?1",
657
+ [claimed[rb].add, claimed[rb].priorCost, ts, claimed[rb].id],
658
+ );
659
+ } else {
660
+ await query(
661
+ "UPDATE purchase_order_lines SET quantity_received = quantity_received - ?1, updated_at = ?2 " +
662
+ "WHERE id = ?3 AND quantity_received >= ?1",
663
+ [claimed[rb].add, ts, claimed[rb].id],
664
+ );
665
+ }
666
+ }
667
+ var lost = new Error("purchase-orders.recordPartialReceipt: refused — PO " +
668
+ poId + " line for sku " + JSON.stringify(rxSkus[c]) +
669
+ " changed under us (receipt recorded by a concurrent call)");
670
+ lost.code = "PO_TRANSITION_REFUSED";
671
+ throw lost;
672
+ }
673
+ claimed.push({
674
+ id: cLine.id, add: cAdd,
675
+ // Capture whether this claim overwrote unit_cost_minor and its
676
+ // prior value, so a rollback can restore the cost — not just
677
+ // the quantity — when no stock ends up landing.
678
+ costChanged: cRxe.unit_cost_minor != null,
679
+ priorCost: cLine.unit_cost_minor,
680
+ });
681
+ }
682
+
683
+ // From here the line claims are held. Any throw past this point
684
+ // must release them (self-targeting subtraction) so the PO isn't
685
+ // stranded with advanced counters but no restock landed.
612
686
  var receiptResult = null;
687
+ try {
688
+
689
+ // Compose inventoryReceive.draft + apply for the winner. If the
690
+ // catalog refuses (unknown SKU, decimal qty, etc.) the rollback
691
+ // below releases the line claims so the PO state stays
692
+ // consistent with the catalog — a half-applied receipt across
693
+ // two writeable surfaces is the worst-case for reconciliation.
694
+ // The receipt primitive's draft step requires a unique
695
+ // `reference`; when the caller didn't supply one, derive a
696
+ // deterministic-per-call value from the PO id + occurrence
697
+ // timestamp so two recordPartialReceipt calls on the same PO
698
+ // produce distinct receipt rows.
613
699
  if (receiveHandle) {
614
700
  var receiptRef = reference != null
615
701
  ? reference
@@ -645,33 +731,18 @@ function create(opts) {
645
731
  };
646
732
  }
647
733
 
648
- // Now advance the PO counters. The pre-flight cap above
649
- // guarantees no over-receipt; the inventoryReceive composition
650
- // (if wired) has already landed without throwing.
651
- for (var v = 0; v < rxSkus.length; v += 1) {
652
- var line2 = skuToLine[rxSkus[v]];
653
- var rxe2 = rxMap[rxSkus[v]];
654
- var newQty = Number(line2.quantity_received || 0) + rxe2.quantity_received;
655
- if (rxe2.unit_cost_minor != null) {
656
- await query(
657
- "UPDATE purchase_order_lines SET quantity_received = ?1, " +
658
- "unit_cost_minor = ?2, updated_at = ?3 WHERE id = ?4",
659
- [newQty, rxe2.unit_cost_minor, ts, line2.id],
660
- );
661
- } else {
662
- await query(
663
- "UPDATE purchase_order_lines SET quantity_received = ?1, updated_at = ?2 WHERE id = ?3",
664
- [newQty, ts, line2.id],
665
- );
666
- }
667
- }
668
-
669
- // Recompute the PO-level status from the now-current line
670
- // state. The status transition only flips forward (no
671
- // 'received' -> 'partially_received' regression).
734
+ // The line counters were already advanced by the claim loop
735
+ // above (the SET quantity_received = quantity_received + addQ
736
+ // increment); no further per-line write is needed. Recompute the
737
+ // PO-level status from the now-current line state and flip it
738
+ // forward (any-received -> partially_received, every-line-full ->
739
+ // received). The status read is from the freshest line state so a
740
+ // concurrent winner that advanced a different line is reflected;
741
+ // the transition only moves forward (no received -> partial
742
+ // regression).
672
743
  var updatedLines = await _getLinesRaw(poId);
673
- var nextStatus = _statusAfterReceipt(updatedLines, current.status);
674
- if (nextStatus !== current.status) {
744
+ var nextStatus = _statusAfterReceipt(updatedLines, priorStatus);
745
+ if (nextStatus !== priorStatus) {
675
746
  await query(
676
747
  "UPDATE purchase_orders SET status = ?1, updated_at = ?2 WHERE id = ?3",
677
748
  [nextStatus, ts, poId],
@@ -683,6 +754,30 @@ function create(opts) {
683
754
  );
684
755
  }
685
756
 
757
+ } catch (e) {
758
+ // A step after the line claims threw (catalog refusal). Release
759
+ // every line claim this call won, self-targeting the exact
760
+ // amount each added (guarded by `>= add` so a concurrent winner
761
+ // that advanced the line further isn't clobbered) so the PO
762
+ // isn't stranded with advanced counters but no restock landed.
763
+ for (var rk = 0; rk < claimed.length; rk += 1) {
764
+ if (claimed[rk].costChanged) {
765
+ await query(
766
+ "UPDATE purchase_order_lines SET quantity_received = quantity_received - ?1, " +
767
+ "unit_cost_minor = ?2, updated_at = ?3 WHERE id = ?4 AND quantity_received >= ?1",
768
+ [claimed[rk].add, claimed[rk].priorCost, ts, claimed[rk].id],
769
+ );
770
+ } else {
771
+ await query(
772
+ "UPDATE purchase_order_lines SET quantity_received = quantity_received - ?1, updated_at = ?2 " +
773
+ "WHERE id = ?3 AND quantity_received >= ?1",
774
+ [claimed[rk].add, ts, claimed[rk].id],
775
+ );
776
+ }
777
+ }
778
+ throw e;
779
+ }
780
+
686
781
  var hydrated = await _hydrated(poId);
687
782
  hydrated.receipt = receiptResult;
688
783
  return hydrated;
@@ -561,16 +561,20 @@ function create(opts) {
561
561
  fail_reason: null,
562
562
  }));
563
563
  } else {
564
- // The send failed after we won the claim. Roll the cursor
565
- // back to the observed value so the retry sweep picks this
566
- // row up on the next tick (failed rows stay due — matching
567
- // the single-caller contract). The rollback is guarded on
568
- // the value we set so a concurrent re-enrolment that moved
569
- // the cursor forward in the meantime isn't clobbered.
564
+ // The send failed after we won the claim. Roll the cursor to a
565
+ // value DISTINCT from the one this tick observed (observed + 1ms),
566
+ // NOT back to the exact observed value: a concurrent overlapping
567
+ // tick that read the same observed cursor is waiting to claim
568
+ // `next_remind_at = observed`, and restoring the exact observed
569
+ // value would let it match and re-send in the same overlap.
570
+ // observed+1 is still <= now, so the row stays due and a LATER
571
+ // tick retries it (deferred, not re-fired in this overlap). The
572
+ // guard on the value we set means a concurrent re-enrolment that
573
+ // moved the cursor forward isn't clobbered.
570
574
  await query(
571
575
  "UPDATE reorder_enrollments SET next_remind_at = ?1 " +
572
576
  "WHERE id = ?2 AND next_remind_at = ?3",
573
- [enrollment.next_remind_at, enrollment.id, nextRemindAt],
577
+ [enrollment.next_remind_at + 1, enrollment.id, nextRemindAt],
574
578
  );
575
579
  await query(
576
580
  "INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
@@ -826,14 +826,21 @@ function create(opts) {
826
826
  miss.code = "APPLICATION_NOT_FOUND";
827
827
  throw miss;
828
828
  }
829
- if (TERMINAL_APPLICATION_STATUSES.indexOf(app.status) !== -1) {
829
+ // A stranded approval: a prior approve claimed 'approved' but the
830
+ // vendor was never persisted (the worker died / timed out between
831
+ // the status claim and the vendor_slug stamp). 'approved' is
832
+ // terminal, so without this the application could never be approved,
833
+ // rejected, or revised again. Treat an 'approved' row with no
834
+ // vendor_slug as re-approvable so the registration can be retried.
835
+ var recoverable = app.status === "approved" && app.vendor_slug == null;
836
+ if (TERMINAL_APPLICATION_STATUSES.indexOf(app.status) !== -1 && !recoverable) {
830
837
  var term = new Error(
831
838
  "seller-signup.approveApplication: refused — application is " + app.status + " (terminal)"
832
839
  );
833
840
  term.code = "APPLICATION_TERMINAL";
834
841
  throw term;
835
842
  }
836
- if (app.status !== "in_review") {
843
+ if (app.status !== "in_review" && !recoverable) {
837
844
  var notReady = new Error(
838
845
  "seller-signup.approveApplication: refused — application is " + app.status +
839
846
  "; approve requires status='in_review' (all requested documents uploaded)"
@@ -851,15 +858,25 @@ function create(opts) {
851
858
  // the loser is refused before registering anything. vendor_slug is
852
859
  // stamped in a second UPDATE after the (winner-only) registration.
853
860
  var ts = _now();
861
+ // The recovery branch (re-approving a stranded 'approved' row with
862
+ // no vendor) must be EXCLUSIVE. Without gating on the observed
863
+ // approved_at, two concurrent recovery approvals would BOTH match
864
+ // (the UPDATE changes nothing that makes the recoverable predicate
865
+ // false) and both call registerVendor — the duplicate-vendor race
866
+ // the claim exists to prevent. Gating the recovery branch on
867
+ // `approved_at = <observed>` (while the UPDATE bumps approved_at to
868
+ // a fresh ts) makes a second concurrent caller's predicate no longer
869
+ // match. The in_review branch stays exclusive via the status flip.
854
870
  var claim = await query(
855
871
  "UPDATE seller_applications " +
856
872
  "SET status = 'approved', approved_at = ?1, approved_by = ?2, updated_at = ?1 " +
857
- "WHERE id = ?3 AND status = 'in_review'",
858
- [ts, approvedBy, applicationId],
873
+ "WHERE id = ?3 AND (status = 'in_review' " +
874
+ "OR (status = 'approved' AND vendor_slug IS NULL AND approved_at = ?4))",
875
+ [ts, approvedBy, applicationId, app.approved_at],
859
876
  );
860
877
  if (Number(claim.rowCount || 0) !== 1) {
861
878
  var raced = new Error(
862
- "seller-signup.approveApplication: refused — application is no longer in_review (concurrent approval)"
879
+ "seller-signup.approveApplication: refused — application is not in an approvable state (concurrent approval)"
863
880
  );
864
881
  raced.code = "APPLICATION_NOT_READY";
865
882
  throw raced;
@@ -883,15 +900,17 @@ function create(opts) {
883
900
  status: "active",
884
901
  });
885
902
  } catch (e) {
886
- // Roll the claim back so the application returns to in_review and
887
- // can be re-approved the winning claim must not strand the app
888
- // in 'approved' with no vendor behind it.
903
+ // Roll back ONLY the claim THIS call won, to in_review so it can be
904
+ // re-approved. Gate on `approved_at = ts` (our claim's stamp, never
905
+ // a subsequent re-claim's) AND `vendor_slug IS NULL` (no vendor was
906
+ // stamped) so a delayed rollback can't clobber another caller's
907
+ // successful approval.
889
908
  try {
890
909
  await query(
891
910
  "UPDATE seller_applications " +
892
911
  "SET status = 'in_review', approved_at = NULL, approved_by = NULL, updated_at = ?1 " +
893
- "WHERE id = ?2 AND status = 'approved'",
894
- [_now(), applicationId],
912
+ "WHERE id = ?2 AND status = 'approved' AND approved_at = ?3 AND vendor_slug IS NULL",
913
+ [_now(), applicationId, ts],
895
914
  );
896
915
  } catch (_e2) { /* drop-silent — the registerVendor error is the caller's signal */ }
897
916
  // Surface the vendors error with our typed-code wrapper so