@blamejs/blamejs-shop 0.4.63 → 0.4.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/customer-surveys.js +20 -5
- package/lib/dropship-forwarding.js +72 -20
- package/lib/order-export.js +85 -32
- package/lib/payment-retries.js +41 -10
- package/lib/reorder-reminders.js +11 -7
- package/lib/seller-signup.js +29 -10
- package/lib/wishlist-digest.js +10 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.65 (2026-06-19) — **Dropship-forwarding and scheduled-export status transitions are now claimed atomically.** Closes two status-transition races in modules that validated the transition with the state-machine primitive but then persisted it with an unconditional update keyed only on the row id. Because the state-machine validation runs in process (each request rebuilds the machine from a fresh row), two concurrent transitions on the same row both passed validation and the second write silently clobbered the first — for example a dropship forwarding being marked shipped while a retry marks it failed, or a scheduled export being cancelled while a worker marks it running. Both now gate the write on the row still being in the state that was validated against (the same single-statement compare-and-swap the order and inventory-receipt lifecycles already use), so exactly one transition lands and a concurrent loser is refused rather than overwriting it. No API change. **Fixed:** *Dropship-forwarding transitions claim the from-state* — Every dropship-forwarding lifecycle edge routes through one helper that validated the transition against the state machine and then wrote UPDATE … WHERE id = ?. The write now also requires the row to still be in the state the transition was validated against (… AND status = <from>) and treats a zero-row result as a lost race (a typed refusal), so two concurrent vendor callbacks (e.g. accept racing ship, or a retry) can't both advance the row and silently overwrite each other. · *Scheduled-export status writers claim the from-state* — The four scheduled-export transitions (cancel, mark-running, mark-complete, mark-failed) validated against the export state machine and then wrote keyed only on the export id. Each now gates the write on the observed from-state and refuses on a zero-row result, so a cancel racing a mark-running (or a duplicate worker callback) no longer last-writer-wins into an illegal state. The state machine still enforces the legal-edge validation; the conditional update is the atomic claim.
|
|
12
|
+
|
|
13
|
+
- v0.4.64 (2026-06-19) — **A failed side-effect can no longer strand a claimed record.** Hardens the atomic-claim concurrency fixes (shipped over the previous releases) against the case where the side-effect fails or the worker crashes AFTER a record was claimed. Each claim now restores or recovers the record so a transient failure can't leave it permanently stuck. A scheduled payment retry that is claimed out of the due set but then fails to advance (a malformed policy or a database write error) restores its next-retry time, so the retry isn't silently dropped. Approving a seller application that crashes between claiming the approval and persisting the vendor is now recoverable — an application left approved with no vendor can be approved again rather than being stranded in a terminal state. A survey response whose insert fails after the invitation was claimed reverts the invitation to issued so it can be answered. A reorder reminder or wishlist digest whose send fails defers the retry to a later tick on a distinct cursor, so a concurrent overlapping tick can't pick up the rolled-back row and send a duplicate in the same window. No API change. **Fixed:** *Payment retry restores its schedule on a failed advance* — The retry scheduler claims an enrollment out of the due set by clearing its next-retry time before doing processor work. If advancing the enrollment then throws (a malformed retry policy, or a database write failure while recording the attempt), the next-retry time is restored to the value the tick observed, so a later tick re-attempts the retry. Previously the enrollment was left active with no next-retry time, and the due query (which requires a next-retry time) would never pick it up again, silently stopping the customer's recovery retries. · *Seller approval is recoverable after a mid-approval crash* — Approving a seller application claims the in-review-to-approved transition before registering the vendor. If the worker crashes or times out between the claim and persisting the vendor, the application is left approved with no vendor — a terminal state the rest of the primitive could never move out of. Approval now treats an approved application that has no vendor attached as re-approvable, so the registration can be retried and the application recovered instead of stranded. · *Survey response keeps the invitation retryable on insert failure* — Submitting a survey response claims the invitation (issued to responded) before inserting the response row. If that insert fails (a transient write error), the invitation is reverted to issued so the response can be submitted again, rather than being stuck responded with no response recorded and every retry rejected as already-responded. · *Reorder reminder and wishlist digest defer a failed send instead of re-firing it* — When a reorder reminder or a wishlist digest send fails after the row was claimed, the schedule cursor is rolled to a value distinct from the one the tick observed (still due, so a later tick retries) rather than back to the exact observed value. A concurrent overlapping tick that read the same cursor was waiting to claim it; restoring the exact value let it match and send a duplicate in the same window. The retry is now deferred to a later tick with no duplicate.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
package/lib/customer-surveys.js
CHANGED
|
@@ -749,11 +749,26 @@ function create(opts) {
|
|
|
749
749
|
}
|
|
750
750
|
|
|
751
751
|
var responseId = b.uuid.v7();
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
752
|
+
try {
|
|
753
|
+
await query(
|
|
754
|
+
"INSERT INTO survey_responses (id, invitation_id, answers_json, occurred_at) " +
|
|
755
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
756
|
+
[responseId, invitation.id, JSON.stringify(canonicalAnswers), occurredAt],
|
|
757
|
+
);
|
|
758
|
+
} catch (insErr) {
|
|
759
|
+
// The claim above already flipped the invitation to 'responded'. If
|
|
760
|
+
// the response insert fails (a transient D1/SQLite write error),
|
|
761
|
+
// revert the claim to 'issued' so the invitation stays retryable —
|
|
762
|
+
// otherwise it is stranded 'responded' with no response row and
|
|
763
|
+
// every retry is rejected as ALREADY_RESPONDED, so the response can
|
|
764
|
+
// never be recorded.
|
|
765
|
+
await query(
|
|
766
|
+
"UPDATE survey_invitations SET status = 'issued', responded_at = NULL " +
|
|
767
|
+
"WHERE id = ?1 AND status = 'responded'",
|
|
768
|
+
[invitation.id],
|
|
769
|
+
);
|
|
770
|
+
throw insErr;
|
|
771
|
+
}
|
|
757
772
|
|
|
758
773
|
return {
|
|
759
774
|
response_id: responseId,
|
|
@@ -95,6 +95,33 @@ var MAX_LINES = 1000;
|
|
|
95
95
|
|
|
96
96
|
// ---- FSM definition -----------------------------------------------------
|
|
97
97
|
|
|
98
|
+
// The legal edges, hoisted so the same table drives both b.fsm's
|
|
99
|
+
// in-memory validation AND the side-effect-free (from,event)->to
|
|
100
|
+
// resolver below. Keeping one source means the resolver can never
|
|
101
|
+
// drift from the machine the guard validates against.
|
|
102
|
+
var DROPSHIP_TRANSITIONS = [
|
|
103
|
+
{ from: "queued", to: "accepted", on: "accept" },
|
|
104
|
+
{ from: "queued", to: "failed", on: "fail" },
|
|
105
|
+
{ from: "accepted", to: "shipped", on: "ship" },
|
|
106
|
+
{ from: "accepted", to: "failed", on: "fail" },
|
|
107
|
+
{ from: "shipped", to: "delivered", on: "deliver" },
|
|
108
|
+
{ from: "shipped", to: "failed", on: "fail" },
|
|
109
|
+
{ from: "shipped", to: "returned", on: "return" },
|
|
110
|
+
{ from: "delivered", to: "returned", on: "return" },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
// (from,event) -> to-state. State + event names are identifier-shaped
|
|
114
|
+
// (no whitespace, enforced by b.fsm at define time), so a single space
|
|
115
|
+
// is an unambiguous key separator. Lets us compute the destination
|
|
116
|
+
// status for the atomic-claim UPDATE without calling b.fsm.transition()
|
|
117
|
+
// (which would emit a "success" audit event before we know the claim
|
|
118
|
+
// actually won the race).
|
|
119
|
+
var _DROPSHIP_TO = Object.create(null);
|
|
120
|
+
for (var _dti = 0; _dti < DROPSHIP_TRANSITIONS.length; _dti += 1) {
|
|
121
|
+
_DROPSHIP_TO[DROPSHIP_TRANSITIONS[_dti].from + " " + DROPSHIP_TRANSITIONS[_dti].on] =
|
|
122
|
+
DROPSHIP_TRANSITIONS[_dti].to;
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
var _dsFsm = null;
|
|
99
126
|
function _getDropshipFsm() {
|
|
100
127
|
if (_dsFsm) return _dsFsm;
|
|
@@ -113,16 +140,7 @@ function _getDropshipFsm() {
|
|
|
113
140
|
failed: {},
|
|
114
141
|
returned: {},
|
|
115
142
|
},
|
|
116
|
-
transitions:
|
|
117
|
-
{ from: "queued", to: "accepted", on: "accept" },
|
|
118
|
-
{ from: "queued", to: "failed", on: "fail" },
|
|
119
|
-
{ from: "accepted", to: "shipped", on: "ship" },
|
|
120
|
-
{ from: "accepted", to: "failed", on: "fail" },
|
|
121
|
-
{ from: "shipped", to: "delivered", on: "deliver" },
|
|
122
|
-
{ from: "shipped", to: "failed", on: "fail" },
|
|
123
|
-
{ from: "shipped", to: "returned", on: "return" },
|
|
124
|
-
{ from: "delivered", to: "returned", on: "return" },
|
|
125
|
-
],
|
|
143
|
+
transitions: DROPSHIP_TRANSITIONS,
|
|
126
144
|
});
|
|
127
145
|
return _dsFsm;
|
|
128
146
|
}
|
|
@@ -288,16 +306,25 @@ function create(opts) {
|
|
|
288
306
|
history: [],
|
|
289
307
|
context: {},
|
|
290
308
|
});
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
309
|
+
// Validate the legal edge IN MEMORY with `can` — it runs the same
|
|
310
|
+
// edge + guard check `transition` does but is side-effect-free (no
|
|
311
|
+
// state mutation, no audit emit). We DON'T call `transition` here:
|
|
312
|
+
// it emits a "success" `fsm.dropship_forwarding.transition` audit
|
|
313
|
+
// event, and emitting before the atomic claim below would record a
|
|
314
|
+
// phantom success for a caller that goes on to LOSE the race (the
|
|
315
|
+
// conditional UPDATE matches zero rows). The real transition() emit
|
|
316
|
+
// is deferred to the winning branch.
|
|
317
|
+
if (!instance.can(event)) {
|
|
318
|
+
var err = new Error("dropship-forwarding: forwarding transition refused — illegal edge '" +
|
|
319
|
+
event + "' from state '" + current.status + "'");
|
|
320
|
+
err.code = "DROPSHIP_FORWARDING_TRANSITION_REFUSED";
|
|
297
321
|
throw err;
|
|
298
322
|
}
|
|
323
|
+
// `can` returned true, so (current.status, event) is a declared edge
|
|
324
|
+
// and the resolver always has its to-state.
|
|
325
|
+
var toState = _DROPSHIP_TO[current.status + " " + event];
|
|
299
326
|
var sets = ["status = ?1"];
|
|
300
|
-
var params = [
|
|
327
|
+
var params = [toState];
|
|
301
328
|
var p = 2;
|
|
302
329
|
var keys = Object.keys(patch || {});
|
|
303
330
|
for (var i = 0; i < keys.length; i += 1) {
|
|
@@ -316,11 +343,36 @@ function create(opts) {
|
|
|
316
343
|
params.push(patch[k]);
|
|
317
344
|
p += 1;
|
|
318
345
|
}
|
|
319
|
-
params.push(forwardingId);
|
|
320
|
-
|
|
321
|
-
|
|
346
|
+
params.push(forwardingId); // ?p
|
|
347
|
+
params.push(current.status); // ?(p+1)
|
|
348
|
+
// Cross-instance atomic claim — the transaction substitute on the D1
|
|
349
|
+
// bridge. b.fsm `can` above only validates the legal edge IN MEMORY
|
|
350
|
+
// (its lock is per-instance, and each request restores a fresh
|
|
351
|
+
// instance), so two concurrent transitions on the same row both pass
|
|
352
|
+
// it. Gating the write on the row STILL being in the from-state we
|
|
353
|
+
// validated against (`AND status = <from>`) is the single-statement
|
|
354
|
+
// serialization point: exactly one writer changes the row, the loser
|
|
355
|
+
// changes zero rows and is refused (no stale-edge clobber).
|
|
356
|
+
var upd = await query(
|
|
357
|
+
"UPDATE dropship_forwardings SET " + sets.join(", ") +
|
|
358
|
+
" WHERE id = ?" + p + " AND status = ?" + (p + 1),
|
|
322
359
|
params,
|
|
323
360
|
);
|
|
361
|
+
if (Number(upd.rowCount || 0) !== 1) {
|
|
362
|
+
var raced = new Error("dropship-forwarding: forwarding " + forwardingId +
|
|
363
|
+
" changed state concurrently (no longer " + current.status + ") — transition refused");
|
|
364
|
+
raced.code = "DROPSHIP_FORWARDING_TRANSITION_REFUSED";
|
|
365
|
+
throw raced;
|
|
366
|
+
}
|
|
367
|
+
// We won the atomic claim — the row really did advance
|
|
368
|
+
// current.status -> toState. NOW drive the transition through b.fsm
|
|
369
|
+
// so its `fsm.dropship_forwarding.transition=success` audit event
|
|
370
|
+
// fires exactly once and only for the writer that actually changed
|
|
371
|
+
// the row. `can` above already validated this edge, so transition()
|
|
372
|
+
// cannot refuse; its own audit emit is drop-silent, and the row is
|
|
373
|
+
// already persisted, so a throw here must never fail the caller.
|
|
374
|
+
try { await instance.transition(event, null); }
|
|
375
|
+
catch (_emitErr) { /* edge pre-validated by can(); audit emit is best-effort */ }
|
|
324
376
|
return await _getForwardingRow(forwardingId);
|
|
325
377
|
}
|
|
326
378
|
|
package/lib/order-export.js
CHANGED
|
@@ -576,18 +576,42 @@ function create(opts) {
|
|
|
576
576
|
if (!row) throw new TypeError("order-export.cancelExport: " + exportId + " not found");
|
|
577
577
|
var fsm = _getExportFsm();
|
|
578
578
|
var inst = fsm.restore({ state: row.status, history: [], context: {} });
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
579
|
+
// Validate the legal edge with `can` — same edge + guard check as
|
|
580
|
+
// transition() but side-effect-free (no state mutation, no audit
|
|
581
|
+
// emit). We deliberately do NOT call transition() here: it emits a
|
|
582
|
+
// "success" fsm.scheduled_export.transition audit event, and
|
|
583
|
+
// emitting before the atomic claim below would record a phantom
|
|
584
|
+
// success for a caller that then LOSES the race (the conditional
|
|
585
|
+
// UPDATE matches zero rows). transition()'s real emit is deferred
|
|
586
|
+
// to the winning branch.
|
|
587
|
+
if (!inst.can("cancel")) {
|
|
588
|
+
var err = new Error("order-export.cancelExport: refused — illegal edge 'cancel' from state '" + row.status + "'");
|
|
589
|
+
err.code = "ORDER_EXPORT_CANCEL_REFUSED";
|
|
584
590
|
throw err;
|
|
585
591
|
}
|
|
586
592
|
var ts = _now();
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
593
|
+
// Atomic claim — the cross-instance serialization point. b.fsm
|
|
594
|
+
// validated the edge IN MEMORY only (per-request instance, per-
|
|
595
|
+
// instance lock), so two concurrent transitions (e.g. cancel racing
|
|
596
|
+
// markExportRunning) both pass it; gating the write on the row STILL
|
|
597
|
+
// being in the from-state we read makes the loser change 0 rows.
|
|
598
|
+
var upd = await query(
|
|
599
|
+
"UPDATE scheduled_exports SET status = 'cancelled', completed_at = ?1 " +
|
|
600
|
+
"WHERE id = ?2 AND status = ?3",
|
|
601
|
+
[ts, exportId, row.status],
|
|
590
602
|
);
|
|
603
|
+
if (Number(upd.rowCount || 0) !== 1) {
|
|
604
|
+
var raced = new Error("order-export.cancelExport: " + exportId +
|
|
605
|
+
" changed state concurrently (no longer " + row.status + ") — refused");
|
|
606
|
+
raced.code = "ORDER_EXPORT_CANCEL_REFUSED";
|
|
607
|
+
throw raced;
|
|
608
|
+
}
|
|
609
|
+
// Won the claim — drive the real transition so its audit event
|
|
610
|
+
// fires exactly once, only for the writer that changed the row.
|
|
611
|
+
// can() pre-validated the edge; transition()'s emit is drop-silent
|
|
612
|
+
// and the row is already persisted, so its throw never reaches the
|
|
613
|
+
// caller.
|
|
614
|
+
try { await inst.transition("cancel", null); } catch (_emitErr) { /* audit emit best-effort */ }
|
|
591
615
|
return await _getExport(exportId);
|
|
592
616
|
},
|
|
593
617
|
|
|
@@ -626,18 +650,28 @@ function create(opts) {
|
|
|
626
650
|
if (!row) throw new TypeError("order-export.markExportRunning: " + exportId + " not found");
|
|
627
651
|
var fsm = _getExportFsm();
|
|
628
652
|
var inst = fsm.restore({ state: row.status, history: [], context: {} });
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
err
|
|
633
|
-
err.
|
|
653
|
+
// Validate with side-effect-free `can` (no audit emit); defer the
|
|
654
|
+
// real transition() emit to the winning branch (see cancelExport).
|
|
655
|
+
if (!inst.can("start")) {
|
|
656
|
+
var err = new Error("order-export.markExportRunning: refused — illegal edge 'start' from state '" + row.status + "'");
|
|
657
|
+
err.code = "ORDER_EXPORT_START_REFUSED";
|
|
634
658
|
throw err;
|
|
635
659
|
}
|
|
636
660
|
var ts = _now();
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
661
|
+
// Atomic claim (see cancelExport): gate on the observed from-state.
|
|
662
|
+
var upd = await query(
|
|
663
|
+
"UPDATE scheduled_exports SET status = 'running', started_at = ?1 " +
|
|
664
|
+
"WHERE id = ?2 AND status = ?3",
|
|
665
|
+
[ts, exportId, row.status],
|
|
640
666
|
);
|
|
667
|
+
if (Number(upd.rowCount || 0) !== 1) {
|
|
668
|
+
var raced = new Error("order-export.markExportRunning: " + exportId +
|
|
669
|
+
" changed state concurrently (no longer " + row.status + ") — refused");
|
|
670
|
+
raced.code = "ORDER_EXPORT_START_REFUSED";
|
|
671
|
+
throw raced;
|
|
672
|
+
}
|
|
673
|
+
// Won the claim — emit the real transition (see cancelExport).
|
|
674
|
+
try { await inst.transition("start", null); } catch (_emitErr) { /* audit emit best-effort */ }
|
|
641
675
|
return await _getExport(exportId);
|
|
642
676
|
},
|
|
643
677
|
|
|
@@ -655,19 +689,28 @@ function create(opts) {
|
|
|
655
689
|
if (!row) throw new TypeError("order-export.markExportComplete: " + input.export_id + " not found");
|
|
656
690
|
var fsm = _getExportFsm();
|
|
657
691
|
var inst = fsm.restore({ state: row.status, history: [], context: {} });
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
err
|
|
662
|
-
err.
|
|
692
|
+
// Validate with side-effect-free `can` (no audit emit); defer the
|
|
693
|
+
// real transition() emit to the winning branch (see cancelExport).
|
|
694
|
+
if (!inst.can("complete")) {
|
|
695
|
+
var err = new Error("order-export.markExportComplete: refused — illegal edge 'complete' from state '" + row.status + "'");
|
|
696
|
+
err.code = "ORDER_EXPORT_COMPLETE_REFUSED";
|
|
663
697
|
throw err;
|
|
664
698
|
}
|
|
665
699
|
var ts = _now();
|
|
666
|
-
|
|
700
|
+
// Atomic claim (see cancelExport): gate on the observed from-state.
|
|
701
|
+
var upd = await query(
|
|
667
702
|
"UPDATE scheduled_exports SET status = 'complete', row_count = ?1, " +
|
|
668
|
-
"byte_size = ?2, file_sha3_512 = ?3, completed_at = ?4 WHERE id = ?5",
|
|
669
|
-
[input.row_count, input.byte_size, input.file_sha3_512, ts, input.export_id],
|
|
703
|
+
"byte_size = ?2, file_sha3_512 = ?3, completed_at = ?4 WHERE id = ?5 AND status = ?6",
|
|
704
|
+
[input.row_count, input.byte_size, input.file_sha3_512, ts, input.export_id, row.status],
|
|
670
705
|
);
|
|
706
|
+
if (Number(upd.rowCount || 0) !== 1) {
|
|
707
|
+
var raced = new Error("order-export.markExportComplete: " + input.export_id +
|
|
708
|
+
" changed state concurrently (no longer " + row.status + ") — refused");
|
|
709
|
+
raced.code = "ORDER_EXPORT_COMPLETE_REFUSED";
|
|
710
|
+
throw raced;
|
|
711
|
+
}
|
|
712
|
+
// Won the claim — emit the real transition (see cancelExport).
|
|
713
|
+
try { await inst.transition("complete", null); } catch (_emitErr) { /* audit emit best-effort */ }
|
|
671
714
|
return await _getExport(input.export_id);
|
|
672
715
|
},
|
|
673
716
|
|
|
@@ -687,18 +730,28 @@ function create(opts) {
|
|
|
687
730
|
if (!row) throw new TypeError("order-export.markExportFailed: " + input.export_id + " not found");
|
|
688
731
|
var fsm = _getExportFsm();
|
|
689
732
|
var inst = fsm.restore({ state: row.status, history: [], context: {} });
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
err
|
|
694
|
-
err.
|
|
733
|
+
// Validate with side-effect-free `can` (no audit emit); defer the
|
|
734
|
+
// real transition() emit to the winning branch (see cancelExport).
|
|
735
|
+
if (!inst.can("fail")) {
|
|
736
|
+
var err = new Error("order-export.markExportFailed: refused — illegal edge 'fail' from state '" + row.status + "'");
|
|
737
|
+
err.code = "ORDER_EXPORT_FAIL_REFUSED";
|
|
695
738
|
throw err;
|
|
696
739
|
}
|
|
697
740
|
var ts = _now();
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
741
|
+
// Atomic claim (see cancelExport): gate on the observed from-state.
|
|
742
|
+
var upd = await query(
|
|
743
|
+
"UPDATE scheduled_exports SET status = 'failed', error = ?1, completed_at = ?2 " +
|
|
744
|
+
"WHERE id = ?3 AND status = ?4",
|
|
745
|
+
[errorText, ts, input.export_id, row.status],
|
|
701
746
|
);
|
|
747
|
+
if (Number(upd.rowCount || 0) !== 1) {
|
|
748
|
+
var raced = new Error("order-export.markExportFailed: " + input.export_id +
|
|
749
|
+
" changed state concurrently (no longer " + row.status + ") — refused");
|
|
750
|
+
raced.code = "ORDER_EXPORT_FAIL_REFUSED";
|
|
751
|
+
throw raced;
|
|
752
|
+
}
|
|
753
|
+
// Won the claim — emit the real transition (see cancelExport).
|
|
754
|
+
try { await inst.transition("fail", null); } catch (_emitErr) { /* audit emit best-effort */ }
|
|
702
755
|
return await _getExport(input.export_id);
|
|
703
756
|
},
|
|
704
757
|
|
package/lib/payment-retries.js
CHANGED
|
@@ -604,19 +604,50 @@ function create(opts) {
|
|
|
604
604
|
);
|
|
605
605
|
if (Number(claim.rowCount || 0) !== 1) continue; // another tick claimed it
|
|
606
606
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
607
|
+
// Pre-charge prep. The claim above nulled next_retry_at to take
|
|
608
|
+
// the row out of the due set; a throw HERE (a missing policy /
|
|
609
|
+
// malformed retry schedule) is BEFORE any processor work, so it is
|
|
610
|
+
// safe to restore the observed next_retry_at (only while still
|
|
611
|
+
// NULL, so a partial re-stamp isn't clobbered) and let a later
|
|
612
|
+
// tick re-attempt — otherwise the enrollment is stranded active
|
|
613
|
+
// with next_retry_at NULL and the due SELECT (next_retry_at IS NOT
|
|
614
|
+
// NULL) never picks it up again.
|
|
615
|
+
var policy, schedule;
|
|
616
|
+
try {
|
|
617
|
+
policy = await _getPolicy(enrollment.policy_slug);
|
|
618
|
+
if (!policy) {
|
|
619
|
+
// Policy went missing between enrollment + tick — close
|
|
620
|
+
// as abandoned so the row doesn't spin forever.
|
|
621
|
+
await query(
|
|
622
|
+
"UPDATE payment_retry_enrollments SET status = 'abandoned', next_retry_at = NULL, " +
|
|
623
|
+
"ended_at = ?1, end_reason = 'policy_missing' WHERE id = ?2",
|
|
624
|
+
[now, enrollment.id],
|
|
625
|
+
);
|
|
626
|
+
advanced.push(await _refetchEnrollment(enrollment.id));
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
schedule = _parseSchedule(policy.retry_schedule_json);
|
|
630
|
+
} catch (prepErr) {
|
|
611
631
|
await query(
|
|
612
|
-
"UPDATE payment_retry_enrollments SET
|
|
613
|
-
"
|
|
614
|
-
[
|
|
632
|
+
"UPDATE payment_retry_enrollments SET next_retry_at = ?1 " +
|
|
633
|
+
"WHERE id = ?2 AND status = 'active' AND next_retry_at IS NULL",
|
|
634
|
+
[enrollment.next_retry_at, enrollment.id],
|
|
615
635
|
);
|
|
616
|
-
|
|
617
|
-
continue;
|
|
636
|
+
throw prepErr;
|
|
618
637
|
}
|
|
619
|
-
|
|
638
|
+
|
|
639
|
+
// _tickAdvance composes paymentHandle.retryIntent — the real
|
|
640
|
+
// card-charge retry — which runs BEFORE the attempt-ledger and
|
|
641
|
+
// status writes. By the time any throw escapes _tickAdvance the
|
|
642
|
+
// charge has already been submitted, so DO NOT restore
|
|
643
|
+
// next_retry_at on such a throw: re-stamping it would let a later
|
|
644
|
+
// or overlapping tick call retryIntent AGAIN for the same attempt
|
|
645
|
+
// and double-move money. The enrollment is left active with
|
|
646
|
+
// next_retry_at NULL for a recovery sweep / operator to advance;
|
|
647
|
+
// retryIntent is passed attempt_number as its idempotency
|
|
648
|
+
// dimension, so a conformant handle de-dupes a deliberate
|
|
649
|
+
// re-attempt at the same number. (Not stranding without a charge
|
|
650
|
+
// is the prep branch above; not double-charging is here.)
|
|
620
651
|
var refreshed = await _tickAdvance(enrollment, policy, schedule, now);
|
|
621
652
|
advanced.push(refreshed);
|
|
622
653
|
}
|
package/lib/reorder-reminders.js
CHANGED
|
@@ -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
|
-
//
|
|
566
|
-
//
|
|
567
|
-
// the
|
|
568
|
-
//
|
|
569
|
-
//
|
|
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) " +
|
package/lib/seller-signup.js
CHANGED
|
@@ -826,14 +826,21 @@ function create(opts) {
|
|
|
826
826
|
miss.code = "APPLICATION_NOT_FOUND";
|
|
827
827
|
throw miss;
|
|
828
828
|
}
|
|
829
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
887
|
-
//
|
|
888
|
-
//
|
|
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
|
package/lib/wishlist-digest.js
CHANGED
|
@@ -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