@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.
@@ -603,26 +603,67 @@ function create(opts) {
603
603
  }
604
604
 
605
605
  var hydrated = _hydratePlan(row);
606
+ var ts = _now();
607
+ // Atomic claim BEFORE any shipment is created. The JS status
608
+ // check above is racy: two concurrent callers both read a
609
+ // `proposed` row and both fall through to createShipment,
610
+ // double-creating shipments. The conditional UPDATE is the real
611
+ // gate — SQLite serializes writers, so exactly one caller flips
612
+ // proposed → executed and gets rowCount === 1; the loser sees 0
613
+ // and refuses without touching orderTracking.
614
+ var claim = await query(
615
+ "UPDATE split_shipment_plans SET status = 'executed', executed_at = ?1 " +
616
+ "WHERE id = ?2 AND status = 'proposed'",
617
+ [ts, input.plan.id],
618
+ );
619
+ if (Number(claim.rowCount || 0) !== 1) {
620
+ // Lost the race (or the row was concurrently cancelled/executed
621
+ // between the read above and the claim). Re-read so the message
622
+ // reflects the row's actual current status.
623
+ var lost = await _getPlanRow(input.plan.id);
624
+ throw new TypeError("split-shipments.executeSplit: plan " + input.plan.id +
625
+ " is " + ((lost && lost.status) || "gone") +
626
+ ", only proposed plans can be executed");
627
+ }
628
+
606
629
  var shipmentIds = [];
607
- for (var i = 0; i < hydrated.shipments.length; i += 1) {
608
- var parcel = hydrated.shipments[i];
609
- var notes = "split:" + parcel.rationale + " (" + (i + 1) + "/" + hydrated.shipments.length + ")";
610
- var createInput = {
611
- order_id: input.order_id,
612
- carrier: carrier,
613
- notes: notes,
614
- };
615
- if (carrier === "other") {
616
- createInput.carrier_other_name = carrierOtherName;
630
+ try {
631
+ for (var i = 0; i < hydrated.shipments.length; i += 1) {
632
+ var parcel = hydrated.shipments[i];
633
+ var notes = "split:" + parcel.rationale + " (" + (i + 1) + "/" + hydrated.shipments.length + ")";
634
+ var createInput = {
635
+ order_id: input.order_id,
636
+ carrier: carrier,
637
+ notes: notes,
638
+ };
639
+ if (carrier === "other") {
640
+ createInput.carrier_other_name = carrierOtherName;
641
+ }
642
+ var s = await orderTracking.createShipment(createInput);
643
+ shipmentIds.push(s.id);
617
644
  }
618
- var s = await orderTracking.createShipment(createInput);
619
- shipmentIds.push(s.id);
645
+ } catch (e) {
646
+ // A createShipment throw after the claim would strand the plan
647
+ // in `executed` with a partial shipment set. Revert the claim
648
+ // (self-targeting WHERE so it only fires on the row WE claimed)
649
+ // and rethrow so the operator can retry once the underlying
650
+ // failure clears. Any shipment rows already written by earlier
651
+ // parcels are reconciled via the orderTracking primitive's own
652
+ // per-shipment flow — the plan returns to `proposed`.
653
+ await query(
654
+ "UPDATE split_shipment_plans SET status = 'proposed', executed_at = NULL " +
655
+ "WHERE id = ?1 AND status = 'executed'",
656
+ [input.plan.id],
657
+ );
658
+ throw e;
620
659
  }
621
- var ts = _now();
660
+
661
+ // Second UPDATE persists the shipment-id list now that every
662
+ // parcel landed. Self-targeting WHERE id keyed off the row we
663
+ // already claimed — the status gate was the first UPDATE.
622
664
  await query(
623
- "UPDATE split_shipment_plans SET status = 'executed', executed_shipment_ids_json = ?1, " +
624
- "executed_at = ?2 WHERE id = ?3",
625
- [JSON.stringify(shipmentIds), ts, input.plan.id],
665
+ "UPDATE split_shipment_plans SET executed_shipment_ids_json = ?1 WHERE id = ?2",
666
+ [JSON.stringify(shipmentIds), input.plan.id],
626
667
  );
627
668
  return _hydratePlan(await _getPlanRow(input.plan.id));
628
669
  },
@@ -754,11 +754,44 @@ function create(opts) {
754
754
  var ts = _now();
755
755
  var srcVotes = Number(src.vote_count) || 0;
756
756
 
757
- await query(
757
+ // Atomic claim: flip the source to 'duplicate' under a WHERE that
758
+ // re-asserts the JS preconditions (not already a duplicate, not
759
+ // archived). SQLite/D1 serializes writers, so this conditional
760
+ // UPDATE is the exactly-once gate — two concurrent linkers both
761
+ // pass the read-time checks above, but only one wins this claim.
762
+ // The loser (rowCount === 0) did NOT transition the row, so it
763
+ // must NOT migrate votes a second time (which would double-count
764
+ // srcVotes onto the canonical); it returns the already-linked row.
765
+ var claim = await query(
758
766
  "UPDATE suggestions SET status = 'duplicate', canonical_id = ?1, " +
759
- "vote_count = 0, updated_at = ?2 WHERE id = ?3",
767
+ "vote_count = 0, updated_at = ?2 " +
768
+ "WHERE id = ?3 AND status <> 'duplicate' AND archived_at IS NULL",
760
769
  [cid, ts, sid],
761
770
  );
771
+ if (Number(claim.rowCount || 0) !== 1) {
772
+ // Lost the race (a concurrent linkDuplicates already flipped the
773
+ // source) — or it was archived between the read and the claim.
774
+ // Re-read to distinguish: a row now in 'duplicate' is the
775
+ // expected concurrent-link outcome, so return it idempotently;
776
+ // anything else means the precondition genuinely no longer holds.
777
+ var current = await _getRaw(sid);
778
+ if (current && current.status === "duplicate") {
779
+ return {
780
+ suggestion_id: sid,
781
+ canonical_id: current.canonical_id,
782
+ migrated_votes: 0,
783
+ source: _decode(current),
784
+ canonical: _decode(await _getRaw(current.canonical_id)),
785
+ };
786
+ }
787
+ var lost = new Error("suggestionBox.linkDuplicates: source could not be claimed for linking");
788
+ lost.code = "SUGGESTION_ALREADY_DUPLICATE";
789
+ throw lost;
790
+ }
791
+
792
+ // Only the claim winner reaches here — migrate the source's net
793
+ // vote_count (captured from the row this claim transitioned) onto
794
+ // the canonical exactly once.
762
795
  if (srcVotes !== 0) {
763
796
  await query(
764
797
  "UPDATE suggestions SET vote_count = vote_count + ?1, updated_at = ?2 WHERE id = ?3",
@@ -941,10 +941,19 @@ function create(opts) {
941
941
  // the cursor back to observedNext, guarded on the value we claimed
942
942
  // so a concurrent winner's advance is never clobbered.
943
943
  var _unclaim = async function () {
944
+ // Roll the cursor to a value DISTINCT from the one this tick
945
+ // observed (observedNext + 1ms), NOT back to the exact observed
946
+ // value: a concurrent overlapping tick that read the same cursor
947
+ // is waiting to claim `next_dispatch_at = observedNext`, and
948
+ // restoring the exact value would let it match and re-send the
949
+ // digest in the same overlap. observedNext+1 stays due, so a LATER
950
+ // tick re-attempts (deferred, not re-fired in this overlap); the
951
+ // guard on the claimed value means a concurrent winner's advance
952
+ // is never clobbered.
944
953
  await query(
945
954
  "UPDATE wishlist_digest_enrollments SET next_dispatch_at = ?1 " +
946
955
  "WHERE id = ?2 AND next_dispatch_at = ?3",
947
- [observedNext, enrollment.id, nextDispatchAt],
956
+ [observedNext + 1, enrollment.id, nextDispatchAt],
948
957
  );
949
958
  };
950
959
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.62",
3
+ "version": "0.4.64",
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": {