@blamejs/blamejs-shop 0.4.61 → 0.4.63

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.
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.61",
3
+ "version": "0.4.63",
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": {