@blamejs/blamejs-shop 0.4.62 → 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.
- package/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/cart-bulk-ops.js +15 -4
- package/lib/customer-impersonation.js +26 -3
- package/lib/customer-merge.js +109 -28
- package/lib/order-exchanges.js +49 -18
- package/lib/pick-lists.js +60 -22
- package/lib/preorder.js +163 -40
- package/lib/print-queue.js +18 -2
- package/lib/purchase-orders.js +131 -36
- package/lib/split-shipments.js +57 -16
- package/lib/suggestion-box.js +35 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.63 (2026-06-19) — **Fulfillment and customer-data actions can't double-fire under concurrent calls.** Closes a further set of concurrency races where a fulfillment or customer-data action read a row, checked its status in application code, and then performed an irreversible side-effect (open an inventory hold, create shipments, restock received stock, create an order, reparent customer records, requeue a print job, mint child carts) before an unconditional update committed — so two concurrent calls could both pass the check and both perform the side-effect. Each now claims the transition with a single conditional update and performs the side-effect only when that claim wins, reverting the claim if the side-effect then fails so a row is never stranded. Approving an exchange opens its replacement hold once; executing a split-shipment plan creates the parcels once; recording a partial purchase-order receipt restocks each line once (claimed per line); completing a pick list creates its shipment once; converting or launching a pre-order creates the order once and a reservation can't oversell its cap; merging or rolling back a customer reparents the records once; an impersonation notice, a duplicate-suggestion link, a print-job retry, and a cart split each happen once. Behavior for a single sequential caller is unchanged. **Fixed:** *Fulfillment transitions claim before their side-effect* — Approving an exchange now claims pending-to-approved (WHERE id = ? AND status = 'pending') before opening the replacement inventory hold, reverting to pending if the hold fails. Executing a split-shipment plan claims proposed-to-executed before creating any shipment. Recording a partial purchase-order receipt claims each receipt line by advancing its received quantity conditionally before restocking, rolling the claimed lines back if a later line fails. Completing a pick list claims its completion before creating the shipment. In each case two concurrent calls can no longer both create the holds / shipments / restock for one row. · *Pre-order conversion, launch, cancel, and reserve are atomic* — Converting a reservation to an order claims active-to-converted before creating the order (reverting on failure), so a reservation can't spawn two orders; launching a campaign claims active-to-launched before walking reservations; cancelling claims active-to-cancelled before decrementing the reserved counter. Reserving now enforces the campaign cap inside the increment itself (units_reserved + qty <= max_units_available in the update's WHERE), so concurrent reservations can't oversell the pool; a reservation that loses the cap is compensated. · *Customer merge/rollback, impersonation notice, suggestion dedup, print retry, and cart split run once* — Executing or rolling back a customer merge claims the transition before reparenting records, reverting if the reparent fails. Sending the 'an operator viewed your account' notice claims the notified stamp (WHERE customer_notified_at IS NULL) before enqueuing, so it's sent once. Linking a duplicate suggestion claims the source before migrating its votes to the canonical row. Marking a print job failed claims the transition before inserting the retry job, so a job isn't reprinted twice. Splitting a cart claims the source's abandon before minting the child carts, so a concurrent split can't create two sets of children.
|
|
12
|
+
|
|
11
13
|
- v0.4.62 (2026-06-19) — **Money and approval actions can't double-fire under concurrent calls.** Closes a set of concurrency races in money- and approval-critical paths where the action read a row, checked its status in application code, and then performed an irreversible side-effect (retry a card, execute an approved action, register a vendor, bill a subscription change) before an unconditional update committed — so two concurrent calls could both pass the check and both perform the side-effect. Each now claims the transition with a single conditional update and performs the side-effect only when that claim wins. A scheduled payment retry is claimed out of the due set before the processor is called, so an overlapping scheduler tick can't retry the same card twice; a payment outcome is claimed by its attempt number before the attempt is recorded, so a redelivered webhook can't double-count it. Executing an approved operator request claims the approved-to-executed transition before returning success, so the underlying money move runs once. Approving a seller application claims the in-review-to-approved transition before registering the vendor (rolling back to in-review if registration fails), so one application can't create two vendors. Applying scheduled subscription plan changes claims each pending change before updating the plan and recording its proration invoice, and stamps a deterministic idempotency key on the invoice, so an overlapping or retried scheduler run can't bill the proration twice. Behavior for a single sequential caller is unchanged. **Fixed:** *Scheduled payment retries and recorded outcomes claim before acting* — The retry scheduler now claims each due enrollment (nulling its next-retry time, conditional on the value the tick observed) before composing the processor retry, so two overlapping ticks can't both retry the same card for one scheduled slot — only the claim winner calls the processor. Recording a retry outcome claims the enrollment by bumping its attempt count conditionally on the observed value before writing the attempt row, so a redelivered webhook (or a manual record racing it) can't write a duplicate attempt or double-count toward the cap; the loser returns the already-advanced row. · *Executing an approved request runs the action once* — markExecuted now flips the request from approved to executed with a conditional update (WHERE id = ? AND status = 'approved') and returns success only when that update changes one row; a concurrent second call is refused with a typed error before the application runs the underlying action (a large refund, vendor payout, or bulk operation) a second time. · *Approving a seller application creates one vendor* — approveApplication now claims the in-review-to-approved transition first and registers the vendor only when the claim wins, so two concurrent approvals can't both register a vendor for one application. If vendor registration fails after the claim, the application is rolled back to in-review so it can be approved again rather than stranded as approved with no vendor. · *Applying scheduled plan changes bills the proration once* — applyScheduledChanges now claims each pending change (WHERE id = ? AND status = 'pending') before updating the subscription plan and recording the proration invoice, so an overlapping or retried scheduler run skips an already-applied change instead of billing it twice. Both the scheduled and the immediate plan-change paths now stamp a deterministic idempotency key on the proration invoice, so even a re-run that reached the billing call would dedupe against the unique invoice id rather than write a second charge. A regression test asserts a second apply pass records no additional invoice.
|
|
12
14
|
|
|
13
15
|
- v0.4.61 (2026-06-19) — **Scheduled and dispatched sends no longer double-fire under concurrent ticks.** Closes a class of concurrency races in the scheduled-send and dispatch paths where a tick read a row, checked its status or schedule cursor in application code, and then performed the send (and its ledger write) before an unconditional update committed — so two overlapping ticks (a cron overrun, two workers, or a manual tick racing the schedule) could both pass the check and both send. Each path now claims the row with a single conditional update — flipping the status, or advancing the schedule cursor, only when the row is still in the expected state — and performs the send only when that claim wins; the losing tick changes zero rows and skips. The affected sends: an email campaign drain (the whole audience could be mailed twice), a queued push notification, a wishlist digest email and its sent-ledger row, a reorder reminder, a survey response record, and a dunning step's reminder email / subscription action. Behavior for a single (sequential) caller is unchanged; only the concurrent double-fire is removed. Where a send fails after the claim wins, the schedule cursor is rolled back so the row is retried on the next tick rather than skipped. **Fixed:** *Email campaign sends claim the campaign before draining* — sendNow, the scheduled dispatch tick, and the consent-gated broadcast drain now flip the campaign from scheduled to sending with a conditional update (WHERE slug = ? AND status = 'scheduled') and drain only when that update changes exactly one row. Previously the status check ran in application code before an unconditional flip, so two concurrent sends could both drain and mail the entire audience twice. The losing caller refuses (a typed send-race error) or, for the tick, skips the campaign. · *Push, wishlist-digest, and reorder-reminder ticks claim each row before sending* — Each per-row advance is now the atomic claim: a queued push notification advances to sent/failed only via WHERE id = ? AND status = 'queued'; a wishlist digest and a reorder reminder advance their schedule cursor with WHERE id = ? AND next_*_at = <the observed value>. The external send (and, for the digest, its sent-ledger row) runs only when the claim changes one row, so two overlapping ticks can't both dispatch the same row. A send that fails after winning the reminder claim rolls the cursor back so the row retries next tick. · *Survey responses and dunning steps gate their side-effect on an atomic claim* — Submitting a survey response now flips the invitation from issued to responded with WHERE id = ? AND status = 'issued' and inserts the response row only when that claim wins (a lost race returns a typed already-responded error), so a double-submit can't record two responses for one invitation. A dunning tick now claims the enrollment with WHERE id = ? AND status = 'active' AND next_action_at = <the observed value> before executing the step (the reminder email / in-app notification / pause or cancel), so overlapping dunning ticks can't send a step twice; the subsequent state transitions carry the same status guard.
|
package/lib/asset-manifest.json
CHANGED
package/lib/cart-bulk-ops.js
CHANGED
|
@@ -516,12 +516,23 @@ function create(opts) {
|
|
|
516
516
|
// carts are not the shopper's working cart, they're scratch
|
|
517
517
|
// carts for downstream fulfillment / split-order quoting).
|
|
518
518
|
var ts = _now();
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
519
|
+
// Atomic claim: mark the source abandoned in a single
|
|
520
|
+
// conditional UPDATE that re-checks `status = 'active'` in its
|
|
521
|
+
// own WHERE. `_loadCart` read the status in JS, but two
|
|
522
|
+
// concurrent splitCart calls would both pass that check and
|
|
523
|
+
// both mint a full set of child carts (double-create). SQLite /
|
|
524
|
+
// D1 serializes writers, so exactly one of the racing UPDATEs
|
|
525
|
+
// flips active→abandoned (rowCount === 1) and earns the right
|
|
526
|
+
// to mint the children; the loser sees rowCount === 0 and
|
|
527
|
+
// refuses rather than producing a second set of child carts.
|
|
528
|
+
// This also frees the active-session index for any reuse.
|
|
529
|
+
var claim = await query(
|
|
530
|
+
"UPDATE carts SET status = 'abandoned', updated_at = ?1 WHERE id = ?2 AND status = 'active'",
|
|
523
531
|
[ts, input.cart_id],
|
|
524
532
|
);
|
|
533
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
534
|
+
throw new TypeError("cartBulkOps.splitCart: cart " + input.cart_id + " already split/modified");
|
|
535
|
+
}
|
|
525
536
|
var children = [];
|
|
526
537
|
for (var gi = 0; gi < groupOrder.length; gi += 1) {
|
|
527
538
|
var groupKey = groupOrder[gi];
|
|
@@ -492,6 +492,20 @@ function create(opts) {
|
|
|
492
492
|
if (!notifications) {
|
|
493
493
|
return { notified: false, customer_notified_at: null };
|
|
494
494
|
}
|
|
495
|
+
|
|
496
|
+
// Enqueue FIRST, then record the confirmation stamp — so
|
|
497
|
+
// `customer_notified_at` only ever means "the notice was
|
|
498
|
+
// successfully enqueued", never "claimed but the enqueue may have
|
|
499
|
+
// failed". A claim-before-enqueue stamp let a concurrent caller
|
|
500
|
+
// observe the provisional stamp and report `notified: true` while
|
|
501
|
+
// the winner's enqueue was still pending (and might then throw and
|
|
502
|
+
// roll the stamp back), silently dropping the privacy notice.
|
|
503
|
+
// Recording the stamp only on enqueue success closes that. A
|
|
504
|
+
// concurrent caller that also slipped past the null-check above may
|
|
505
|
+
// enqueue a second time; a duplicate "an operator viewed your
|
|
506
|
+
// account" notice is benign and strictly preferable to missing it,
|
|
507
|
+
// and the audit stamp still resolves to a single value.
|
|
508
|
+
var now = _now();
|
|
495
509
|
await notifications.enqueue({
|
|
496
510
|
recipient_id: existing.customer_id,
|
|
497
511
|
channel: "account-impersonation",
|
|
@@ -506,12 +520,21 @@ function create(opts) {
|
|
|
506
520
|
expires_at: Number(existing.expires_at),
|
|
507
521
|
},
|
|
508
522
|
});
|
|
509
|
-
|
|
523
|
+
|
|
524
|
+
// Stamp the confirmation idempotently — the first writer to land
|
|
525
|
+
// wins (WHERE customer_notified_at IS NULL); a concurrent second
|
|
526
|
+
// caller's UPDATE matches zero rows and returns the already-
|
|
527
|
+
// recorded stamp. An enqueue throw above propagates before this,
|
|
528
|
+
// leaving the row un-stamped so a retry re-attempts delivery.
|
|
510
529
|
await query(
|
|
511
|
-
"UPDATE impersonations SET customer_notified_at = ?1
|
|
530
|
+
"UPDATE impersonations SET customer_notified_at = ?1 " +
|
|
531
|
+
"WHERE id = ?2 AND customer_notified_at IS NULL",
|
|
512
532
|
[now, id],
|
|
513
533
|
);
|
|
514
|
-
|
|
534
|
+
var stampedRow = await _getRow(id);
|
|
535
|
+
var stampedAt = stampedRow && stampedRow.customer_notified_at != null
|
|
536
|
+
? Number(stampedRow.customer_notified_at) : now;
|
|
537
|
+
return { notified: true, customer_notified_at: stampedAt };
|
|
515
538
|
},
|
|
516
539
|
|
|
517
540
|
// ---- listForOperator ------------------------------------------------
|
package/lib/customer-merge.js
CHANGED
|
@@ -693,26 +693,70 @@ function create(opts) {
|
|
|
693
693
|
throw drErr;
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
//
|
|
700
|
-
|
|
696
|
+
// Atomically CLAIM the proposed->executed transition BEFORE
|
|
697
|
+
// running any exactly-once side effect. SQLite serializes
|
|
698
|
+
// writers, so this conditional UPDATE is the race-free claim:
|
|
699
|
+
// two concurrent executeMerge calls on the same proposed row
|
|
700
|
+
// both pass the JS status check above, but only one lands
|
|
701
|
+
// rowCount===1 here — the loser sees rowCount 0 and bails out
|
|
702
|
+
// returning the now-executed row, so the reparent / archive /
|
|
703
|
+
// redirect-insert fire exactly once.
|
|
701
704
|
var ts = _now();
|
|
702
|
-
await query(
|
|
703
|
-
"
|
|
704
|
-
"
|
|
705
|
-
|
|
706
|
-
[row.source_customer_id, row.target_customer_id, mergeId, ts],
|
|
705
|
+
var claim = await query(
|
|
706
|
+
"UPDATE customer_merges SET status = 'executed', " +
|
|
707
|
+
"executed_at = ?1, executed_by = ?2 WHERE id = ?3 AND status = 'proposed'",
|
|
708
|
+
[ts, executedBy, mergeId],
|
|
707
709
|
);
|
|
710
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
711
|
+
// Lost the race (or the row left 'proposed' between our read
|
|
712
|
+
// and the claim). Re-read: an already-executed row is the
|
|
713
|
+
// winner's result; anything else is a genuine state error.
|
|
714
|
+
var current = await _getMergeRow(mergeId);
|
|
715
|
+
if (current && current.status === "executed") {
|
|
716
|
+
return _hydrateMerge(current);
|
|
717
|
+
}
|
|
718
|
+
var raceErr = new Error("customerMerge.executeMerge: merge_id " + mergeId +
|
|
719
|
+
" is no longer proposed, only proposed merges can be executed");
|
|
720
|
+
raceErr.code = "CUSTOMER_MERGE_NOT_PROPOSED";
|
|
721
|
+
throw raceErr;
|
|
722
|
+
}
|
|
708
723
|
|
|
709
|
-
//
|
|
710
|
-
//
|
|
724
|
+
// Claim won — now run the exactly-once side effects. If any
|
|
725
|
+
// throws, REVERT the claim (self-targeting the row we just
|
|
726
|
+
// flipped) so the merge isn't stranded in 'executed' with the
|
|
727
|
+
// reparents only partially applied; then rethrow.
|
|
728
|
+
var actual;
|
|
729
|
+
try {
|
|
730
|
+
// Commit every reparent.
|
|
731
|
+
actual = await _reparentAll(row.source_customer_id, row.target_customer_id);
|
|
732
|
+
|
|
733
|
+
// Archive the source customer + insert the redirect marker.
|
|
734
|
+
await customers.archiveCustomer(row.source_customer_id);
|
|
735
|
+
await query(
|
|
736
|
+
"INSERT INTO customer_merge_redirects " +
|
|
737
|
+
"(source_customer_id, target_customer_id, merge_id, executed_at) " +
|
|
738
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
739
|
+
[row.source_customer_id, row.target_customer_id, mergeId, ts],
|
|
740
|
+
);
|
|
741
|
+
} catch (sideErr) {
|
|
742
|
+
await query(
|
|
743
|
+
"UPDATE customer_merges SET status = 'proposed', " +
|
|
744
|
+
"executed_at = NULL, executed_by = NULL WHERE id = ?1 AND status = 'executed'",
|
|
745
|
+
[mergeId],
|
|
746
|
+
);
|
|
747
|
+
throw sideErr;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Freeze the actual reparent counts on plan_json so rollback
|
|
751
|
+
// has the exact footprint. Status / executed_at / executed_by
|
|
752
|
+
// already landed in the claim above.
|
|
711
753
|
var sealed = Object.assign({}, capturedPlan, { actual: actual });
|
|
754
|
+
// Gate on still-'executed' so this metadata seal can't land on a
|
|
755
|
+
// row a concurrent rollback (now allowed once the redirect exists)
|
|
756
|
+
// has already flipped to 'rolled_back'.
|
|
712
757
|
await query(
|
|
713
|
-
"UPDATE customer_merges SET
|
|
714
|
-
|
|
715
|
-
[JSON.stringify(sealed), ts, executedBy, mergeId],
|
|
758
|
+
"UPDATE customer_merges SET plan_json = ?1 WHERE id = ?2 AND status = 'executed'",
|
|
759
|
+
[JSON.stringify(sealed), mergeId],
|
|
716
760
|
);
|
|
717
761
|
return _hydrateMerge(await _getMergeRow(mergeId));
|
|
718
762
|
},
|
|
@@ -743,6 +787,8 @@ function create(opts) {
|
|
|
743
787
|
throw stErr;
|
|
744
788
|
}
|
|
745
789
|
var now = _now();
|
|
790
|
+
// Keep the in-window check BEFORE the claim — a past-window
|
|
791
|
+
// merge must refuse without flipping status.
|
|
746
792
|
if (now - Number(row.executed_at) > ROLLBACK_WINDOW_MS) {
|
|
747
793
|
var winErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
|
|
748
794
|
" executed " + Math.floor((now - Number(row.executed_at)) / C.TIME.days(1)) +
|
|
@@ -752,21 +798,56 @@ function create(opts) {
|
|
|
752
798
|
throw winErr;
|
|
753
799
|
}
|
|
754
800
|
|
|
755
|
-
//
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
801
|
+
// Atomically CLAIM the executed->rolled_back transition BEFORE
|
|
802
|
+
// running the reverse reparent / restore / redirect-delete.
|
|
803
|
+
// The conditional UPDATE is the race-free guard: two concurrent
|
|
804
|
+
// rollbackMerge calls both pass the JS status + window checks,
|
|
805
|
+
// but only one lands rowCount===1 here — the loser bails out so
|
|
806
|
+
// the reverse side effects fire exactly once.
|
|
807
|
+
// Gate the claim on the redirect row EXISTING. executeMerge flips
|
|
808
|
+
// status to 'executed' BEFORE its reparent/archive complete and
|
|
809
|
+
// inserts the redirect as its LAST side effect, so the redirect's
|
|
810
|
+
// presence is the proof that the reparent + archive are durable.
|
|
811
|
+
// Without this gate a rollback racing an in-flight execute (status
|
|
812
|
+
// already 'executed', reparents not yet done) would flip to
|
|
813
|
+
// 'rolled_back' and reverse nothing. If the redirect isn't there
|
|
814
|
+
// yet the merge is still finalizing — refuse so the caller retries.
|
|
815
|
+
var claim = await query(
|
|
766
816
|
"UPDATE customer_merges SET status = 'rolled_back', " +
|
|
767
|
-
"rolled_back_at = ?1, rollback_reason = ?2 WHERE id = ?3"
|
|
768
|
-
|
|
817
|
+
"rolled_back_at = ?1, rollback_reason = ?2 WHERE id = ?3 AND status = 'executed' " +
|
|
818
|
+
"AND EXISTS (SELECT 1 FROM customer_merge_redirects WHERE source_customer_id = ?4)",
|
|
819
|
+
[now, reason, mergeId, row.source_customer_id],
|
|
769
820
|
);
|
|
821
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
822
|
+
var raceErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
|
|
823
|
+
" is no longer executed (or its merge is still finalizing); only fully-executed merges can be rolled back");
|
|
824
|
+
raceErr.code = "CUSTOMER_MERGE_NOT_EXECUTED";
|
|
825
|
+
throw raceErr;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Claim won — now run the reverse side effects. If any throws,
|
|
829
|
+
// REVERT the claim (self-targeting the row we just flipped) so
|
|
830
|
+
// the merge isn't stranded in 'rolled_back' with the reparents
|
|
831
|
+
// only partially reversed; then rethrow.
|
|
832
|
+
try {
|
|
833
|
+
// Reverse every reparent (target -> source).
|
|
834
|
+
await _reparentAll(row.target_customer_id, row.source_customer_id);
|
|
835
|
+
|
|
836
|
+
// Restore the source customer + drop the redirect marker.
|
|
837
|
+
await customers.restoreCustomer(row.source_customer_id);
|
|
838
|
+
await query(
|
|
839
|
+
"DELETE FROM customer_merge_redirects WHERE source_customer_id = ?1",
|
|
840
|
+
[row.source_customer_id],
|
|
841
|
+
);
|
|
842
|
+
} catch (sideErr) {
|
|
843
|
+
await query(
|
|
844
|
+
"UPDATE customer_merges SET status = 'executed', " +
|
|
845
|
+
"rolled_back_at = NULL, rollback_reason = NULL WHERE id = ?1 AND status = 'rolled_back'",
|
|
846
|
+
[mergeId],
|
|
847
|
+
);
|
|
848
|
+
throw sideErr;
|
|
849
|
+
}
|
|
850
|
+
|
|
770
851
|
return _hydrateMerge(await _getMergeRow(mergeId));
|
|
771
852
|
},
|
|
772
853
|
|
package/lib/order-exchanges.js
CHANGED
|
@@ -286,28 +286,59 @@ function create(opts) {
|
|
|
286
286
|
}
|
|
287
287
|
_assertTransition(existing.status, "approveExchange");
|
|
288
288
|
|
|
289
|
-
//
|
|
290
|
-
// hold
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
sku: existing.replacement_sku,
|
|
298
|
-
variant_id: existing.replacement_variant_id || null,
|
|
299
|
-
quantity: existing.replacement_qty,
|
|
300
|
-
// allow:raw-time-literal — hold TTL in SECONDS (passed to holdForCart's ttl_seconds); C.TIME returns ms
|
|
301
|
-
ttl_seconds: input.hold_ttl_seconds || 86400,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
289
|
+
// Claim pending -> approved ATOMICALLY before the inventory
|
|
290
|
+
// hold. The conditional WHERE (status = 'pending') is the
|
|
291
|
+
// serialization point: two operators racing the same exchange
|
|
292
|
+
// both pass the JS _assertTransition above, but only the writer
|
|
293
|
+
// whose UPDATE matches a still-pending row wins (rowCount === 1).
|
|
294
|
+
// The loser's UPDATE matches zero rows -> it refuses here and
|
|
295
|
+
// never opens a duplicate hold. Without this, both callers would
|
|
296
|
+
// reach holdForCart and pin the replacement SKU twice.
|
|
305
297
|
var ts = _monotonicTs();
|
|
306
|
-
await query(
|
|
298
|
+
var claim = await query(
|
|
307
299
|
"UPDATE order_exchanges SET status = 'approved', approver_id = ?1, updated_at = ?2 " +
|
|
308
|
-
"WHERE id = ?3",
|
|
300
|
+
"WHERE id = ?3 AND status = 'pending'",
|
|
309
301
|
[approverId, ts, exchangeId],
|
|
310
302
|
);
|
|
303
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
304
|
+
var refused = new Error(
|
|
305
|
+
"order-exchanges: transition 'approveExchange' refused from state '" +
|
|
306
|
+
existing.status + "' (lost the race to a concurrent transition)"
|
|
307
|
+
);
|
|
308
|
+
refused.code = "EXCHANGE_TRANSITION_REFUSED";
|
|
309
|
+
throw refused;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Pin the replacement shelf AFTER winning the claim. A hold
|
|
313
|
+
// failure (insufficient stock, unknown location) surfaces to the
|
|
314
|
+
// caller; we revert the claim back to pending so the next
|
|
315
|
+
// operator attempt decides whether to retry, source from a
|
|
316
|
+
// different shelf, or reject the exchange — the row is never
|
|
317
|
+
// stranded in `approved` without a hold.
|
|
318
|
+
if (invAllocs) {
|
|
319
|
+
try {
|
|
320
|
+
await invAllocs.holdForCart({
|
|
321
|
+
cart_id: existing.id,
|
|
322
|
+
sku: existing.replacement_sku,
|
|
323
|
+
variant_id: existing.replacement_variant_id || null,
|
|
324
|
+
quantity: existing.replacement_qty,
|
|
325
|
+
// allow:raw-time-literal — hold TTL in SECONDS (passed to holdForCart's ttl_seconds); C.TIME returns ms
|
|
326
|
+
ttl_seconds: input.hold_ttl_seconds || 86400,
|
|
327
|
+
});
|
|
328
|
+
} catch (holdErr) {
|
|
329
|
+
// Self-targeting revert: only un-claim the row WE just
|
|
330
|
+
// claimed (status still 'approved'). A concurrent follow-on
|
|
331
|
+
// transition that already moved the row off `approved` is
|
|
332
|
+
// left untouched.
|
|
333
|
+
await query(
|
|
334
|
+
"UPDATE order_exchanges SET status = 'pending', approver_id = NULL, updated_at = ?1 " +
|
|
335
|
+
"WHERE id = ?2 AND status = 'approved'",
|
|
336
|
+
[_monotonicTs(), exchangeId],
|
|
337
|
+
);
|
|
338
|
+
throw holdErr;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
311
342
|
return await _getRow(exchangeId);
|
|
312
343
|
},
|
|
313
344
|
|
package/lib/pick-lists.js
CHANGED
|
@@ -548,31 +548,69 @@ function create(opts) {
|
|
|
548
548
|
}
|
|
549
549
|
perOrder[oid].push(list.lines[k]);
|
|
550
550
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
carrier: "other",
|
|
563
|
-
carrier_other_name: "pickup",
|
|
564
|
-
notes: "pick-list:" + listId,
|
|
565
|
-
});
|
|
566
|
-
shipments.push({
|
|
567
|
-
order_id: ord,
|
|
568
|
-
shipment_id: s.id,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
551
|
+
|
|
552
|
+
// Atomically claim completion BEFORE fanning out any shipment.
|
|
553
|
+
// The JS status check above is necessary (it produces the
|
|
554
|
+
// operator-facing "list is <status>" refusal) but NOT sufficient:
|
|
555
|
+
// two concurrent markListComplete callers both read 'in_progress'
|
|
556
|
+
// and would each run the createShipment loop, double-creating one
|
|
557
|
+
// shipment per parent order. The conditional UPDATE — guarded by
|
|
558
|
+
// the same precondition in its own WHERE — is the atomic claim
|
|
559
|
+
// (SQLite/D1 serializes writers, so exactly one UPDATE matches a
|
|
560
|
+
// row in an eligible state). The loser sees rowCount===0 and
|
|
561
|
+
// refuses; only the winner proceeds to createShipment.
|
|
571
562
|
var ts = _now();
|
|
572
|
-
await query(
|
|
573
|
-
"UPDATE pick_lists SET status = 'complete', completed_at = ?1
|
|
563
|
+
var claim = await query(
|
|
564
|
+
"UPDATE pick_lists SET status = 'complete', completed_at = ?1 " +
|
|
565
|
+
"WHERE id = ?2 AND status IN ('generated', 'in_progress')",
|
|
574
566
|
[ts, listId],
|
|
575
567
|
);
|
|
568
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
569
|
+
// Lost the race (or the row left an eligible state between the
|
|
570
|
+
// read and the claim). Surface the same terminal-state refusal
|
|
571
|
+
// a sequential caller would see — the shipments belong to the
|
|
572
|
+
// caller that won the claim.
|
|
573
|
+
throw new TypeError("pick-lists.markListComplete: list is no longer " +
|
|
574
|
+
"generated or in_progress (already completed or cancelled by a concurrent caller)");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
var shipments = [];
|
|
578
|
+
try {
|
|
579
|
+
for (var m = 0; m < orderSeq.length; m += 1) {
|
|
580
|
+
var ord = orderSeq[m];
|
|
581
|
+
// A picked-from-shelf parcel has no carrier yet — the operator
|
|
582
|
+
// assigns one when they hand it off. orderTracking's carrier enum
|
|
583
|
+
// doesn't carry a "pickup" / "none" value, so the parcel rides the
|
|
584
|
+
// 'other' escape hatch with a "pickup" label until a real carrier
|
|
585
|
+
// is recorded against it (the operator updates the shipment, or a
|
|
586
|
+
// shipping-label record supplies the carrier).
|
|
587
|
+
var s = await orderTracking.createShipment({
|
|
588
|
+
order_id: ord,
|
|
589
|
+
carrier: "other",
|
|
590
|
+
carrier_other_name: "pickup",
|
|
591
|
+
notes: "pick-list:" + listId,
|
|
592
|
+
});
|
|
593
|
+
shipments.push({
|
|
594
|
+
order_id: ord,
|
|
595
|
+
shipment_id: s.id,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
} catch (e) {
|
|
599
|
+
// The side-effect threw after the claim landed. Revert the claim
|
|
600
|
+
// (self-targeting WHERE so we only undo a row WE moved to
|
|
601
|
+
// 'complete') so the list isn't stranded terminal with a
|
|
602
|
+
// partial / zero shipment fan-out — the operator can retry once
|
|
603
|
+
// the createShipment fault clears.
|
|
604
|
+
try {
|
|
605
|
+
await query(
|
|
606
|
+
"UPDATE pick_lists SET status = ?1, completed_at = NULL " +
|
|
607
|
+
"WHERE id = ?2 AND status = 'complete'",
|
|
608
|
+
[list.status, listId],
|
|
609
|
+
);
|
|
610
|
+
} catch (_e) { /* drop-silent — the original createShipment error is what the operator needs */ }
|
|
611
|
+
throw e;
|
|
612
|
+
}
|
|
613
|
+
|
|
576
614
|
var hydrated = await _getHydrated(listId);
|
|
577
615
|
hydrated.shipments = shipments;
|
|
578
616
|
return hydrated;
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
267
|
-
"
|
|
268
|
-
[
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
var
|
|
528
|
-
|
|
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,
|
package/lib/print-queue.js
CHANGED
|
@@ -437,12 +437,28 @@ function create(opts) {
|
|
|
437
437
|
JSON.stringify(input.station_id));
|
|
438
438
|
}
|
|
439
439
|
var ts = _now();
|
|
440
|
-
|
|
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) {
|
package/lib/purchase-orders.js
CHANGED
|
@@ -599,17 +599,103 @@ function create(opts) {
|
|
|
599
599
|
}
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
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
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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,
|
|
674
|
-
if (nextStatus !==
|
|
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;
|
package/lib/split-shipments.js
CHANGED
|
@@ -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
|
-
|
|
608
|
-
var
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
|
624
|
-
|
|
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
|
},
|
package/lib/suggestion-box.js
CHANGED
|
@@ -754,11 +754,44 @@ function create(opts) {
|
|
|
754
754
|
var ts = _now();
|
|
755
755
|
var srcVotes = Number(src.vote_count) || 0;
|
|
756
756
|
|
|
757
|
-
|
|
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
|
|
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