@blamejs/blamejs-shop 0.4.64 → 0.4.66

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 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.66 (2026-06-20) — **Inventory-audit finalization can no longer double-apply its stock adjustments.** Finalizing an inventory audit with apply_adjustments writes one shelf adjustment per counted line, then marks the audit finalized. Two failure modes could move a shelf twice. First, if a later line was refused mid-loop — most commonly a negative variance that would have pushed the shelf below its outstanding paid holds — the audit stayed open, but the lines already adjusted had moved; re-running the finalize re-applied those earlier adjustments on top of themselves. Second, two finalize calls overlapping on the same audit both applied every adjustment before either marked the audit finalized. Finalization now claims the audit's transition to finalized in a single conditional write BEFORE touching any shelf, so exactly one finalize applies the adjustments and a concurrent or duplicate call applies nothing. Each adjustment is applied as the difference between the counted variance and what this audit has already moved, so a retry lands only the not-yet-applied remainder and a recount made before the retry is reflected exactly. A finalize that is refused part-way re-opens the audit so the operator can clear the blocker and retry. No API change. **Fixed:** *Finalization claims the audit before moving any shelf* — The transition that marks an audit finalized now lands in one conditional write, gated on the audit still being open or in progress, BEFORE any stock adjustment is written. Two finalize calls racing the same audit — or a duplicate retry overlapping a still-running finalize — resolve to a single winner that applies the adjustments; the loser writes nothing and returns the computed totals. The claim also freezes the audit's scan lines, so the variances being applied can't change underneath the writer. · *Adjustments reconcile against what already landed* — Each shelf adjustment is applied as the counted variance minus what this audit has already moved for that SKU and location. A finalize refused part-way (a respect-holds floor on one line) re-opens the audit; the retry then applies only the remaining difference instead of re-applying lines that already succeeded, and a recount that changed a line's variance before the retry is corrected to the new value rather than left at the old one.
12
+
13
+ - 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.
14
+
11
15
  - 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.
12
16
 
13
17
  - 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.64",
2
+ "version": "0.4.66",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -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
- try {
292
- await instance.transition(event, null);
293
- } catch (e) {
294
- var err = new Error("dropship-forwarding: forwarding transition refused — " + (e && e.message || e));
295
- err.code = (e && e.code) || "DROPSHIP_FORWARDING_TRANSITION_REFUSED";
296
- err.cause = e;
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 = [instance.state];
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
- await query(
321
- "UPDATE dropship_forwardings SET " + sets.join(", ") + " WHERE id = ?" + p,
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
 
@@ -595,9 +595,12 @@ function create(opts) {
595
595
  ", only open or in_progress audits can be finalized");
596
596
  }
597
597
  var lines = await _getLines(auditId);
598
+ // PASS 1 — compute each line's variance, persist it (idempotent),
599
+ // and collect the shelf adjustments to apply. No shelf is touched
600
+ // yet: the atomic claim below decides who, if anyone, applies them.
598
601
  var varianceCount = 0;
599
602
  var varianceValueMinor = 0;
600
- var adjustmentsWritten = 0;
603
+ var adjustable = [];
601
604
  for (var i = 0; i < lines.length; i += 1) {
602
605
  var line = lines[i];
603
606
  // recount_qty wins when present; else counted_qty drives.
@@ -611,36 +614,95 @@ function create(opts) {
611
614
  varianceCount += 1;
612
615
  var unitValue = await _unitValueMinor(line.sku);
613
616
  varianceValueMinor += Math.abs(variance) * unitValue;
614
- if (applyAdjustments) {
615
- // Adjustments only land on lines that carry a
616
- // location_code. A whole-audit-summed line (no
617
- // location_code) computes variance for the operator's
618
- // reconciliation report but does NOT write to
619
- // inventory_locations the catalog-side correction is
620
- // the right verb for the single-bucket inventory.
621
- if (line.location_code) {
622
- await inventoryLocations.adjustStock({
623
- sku: line.sku,
624
- location_code: line.location_code,
625
- delta: variance,
626
- reason: "inventory-audit:" + header.slug,
627
- // A negative variance shrinks the shelf — guard it from
628
- // dropping below the outstanding paid holds for the SKU
629
- // so a later commitHold can't be stranded. Positive
630
- // variances (credits) ignore the flag.
631
- respect_holds: true,
632
- });
633
- adjustmentsWritten += 1;
634
- }
617
+ // Adjustments only land on lines that carry a location_code. A
618
+ // whole-audit-summed line (no location_code) computes variance
619
+ // for the operator's reconciliation report but does NOT write
620
+ // to inventory_locations the catalog-side correction is the
621
+ // right verb for the single-bucket inventory.
622
+ if (applyAdjustments && line.location_code) {
623
+ adjustable.push({ sku: line.sku, location_code: line.location_code, variance: variance });
635
624
  }
636
625
  }
637
626
  }
627
+
638
628
  var ts = _now();
639
- await query(
629
+ // ATOMIC CLAIM — advance the audit out of open/in_progress in a
630
+ // single statement BEFORE any shelf moves. On the D1 bridge there
631
+ // are no interactive transactions, so this UPDATE is the
632
+ // serialization point: two concurrent finalizeAudit calls both
633
+ // reach here, but only one matches a row. The loser changes zero
634
+ // rows, applies NOTHING, and returns the deterministic totals — so
635
+ // a shelf can never be double-adjusted by overlapping finalizes.
636
+ // The flip also freezes the scan lines (recordScanLine / markRecount
637
+ // refuse on a finalized audit), so the variances we're about to
638
+ // apply can't shift underfoot.
639
+ var claim = await query(
640
640
  "UPDATE inventory_audits SET status = 'finalized', variance_count = ?1, " +
641
- "variance_value_minor = ?2, finalized_at = ?3, updated_at = ?3 WHERE id = ?4",
641
+ "variance_value_minor = ?2, finalized_at = ?3, updated_at = ?3 " +
642
+ "WHERE id = ?4 AND status IN ('open', 'in_progress')",
642
643
  [varianceCount, varianceValueMinor, ts, auditId],
643
644
  );
645
+ if (Number(claim.rowCount || 0) !== 1) {
646
+ // Lost the claim — a concurrent (or prior) finalize already
647
+ // advanced this audit and owns the adjustment writes. Touch no
648
+ // shelves; return the totals computed from the now-frozen lines.
649
+ return {
650
+ variance_count: varianceCount,
651
+ variance_value_minor: varianceValueMinor,
652
+ adjustments_written: adjustable.length,
653
+ };
654
+ }
655
+
656
+ // PASS 2 (winner only) — apply each shelf adjustment, reconciling
657
+ // against what already landed under this audit's reason. The audit's
658
+ // slug is unique and (audit_id, sku, location_code) is UNIQUE, so
659
+ // every inventory_adjustments row under (sku, location_code, reason)
660
+ // belongs to this one logical adjustment; their summed delta is what
661
+ // the shelf has already moved. Applying only `variance - applied`
662
+ // makes a retry land exactly the remaining delta and never a double,
663
+ // and self-corrects if a recount changed the variance before the
664
+ // retry. The common abort is a respect_holds floor refusal on a
665
+ // negative variance; the catch re-opens the audit so the operator
666
+ // can clear the blocker and retry.
667
+ var adjustmentsWritten = 0;
668
+ var adjReason = "inventory-audit:" + header.slug;
669
+ try {
670
+ for (var j = 0; j < adjustable.length; j += 1) {
671
+ var adj = adjustable[j];
672
+ var appliedRow = await query(
673
+ "SELECT COALESCE(SUM(delta), 0) AS applied FROM inventory_adjustments " +
674
+ "WHERE sku = ?1 AND location_code = ?2 AND reason = ?3",
675
+ [adj.sku, adj.location_code, adjReason],
676
+ );
677
+ var alreadyApplied = Number((appliedRow.rows[0] || {}).applied || 0);
678
+ var needed = adj.variance - alreadyApplied;
679
+ if (needed !== 0) {
680
+ await inventoryLocations.adjustStock({
681
+ sku: adj.sku,
682
+ location_code: adj.location_code,
683
+ delta: needed,
684
+ reason: adjReason,
685
+ // A negative variance shrinks the shelf — guard it from
686
+ // dropping below the outstanding paid holds for the SKU so a
687
+ // later commitHold can't be stranded. Positive variances
688
+ // (credits) ignore the flag.
689
+ respect_holds: true,
690
+ });
691
+ }
692
+ adjustmentsWritten += 1;
693
+ }
694
+ } catch (applyErr) {
695
+ // Re-open the audit so the operator can clear the blocker and
696
+ // retry. The reconciling apply above guarantees the retry lands
697
+ // only the remaining delta, so nothing double-counts.
698
+ await query(
699
+ "UPDATE inventory_audits SET status = 'in_progress', finalized_at = NULL, " +
700
+ "updated_at = ?1 WHERE id = ?2 AND status = 'finalized'",
701
+ [_now(), auditId],
702
+ );
703
+ throw applyErr;
704
+ }
705
+
644
706
  return {
645
707
  variance_count: varianceCount,
646
708
  variance_value_minor: varianceValueMinor,
@@ -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
- try { await inst.transition("cancel", null); }
580
- catch (e) {
581
- var err = new Error("order-export.cancelExport: refused " + (e && e.message || e));
582
- err.code = (e && e.code) || "ORDER_EXPORT_CANCEL_REFUSED";
583
- err.cause = e;
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
- await query(
588
- "UPDATE scheduled_exports SET status = 'cancelled', completed_at = ?1 WHERE id = ?2",
589
- [ts, exportId],
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
- try { await inst.transition("start", null); }
630
- catch (e) {
631
- var err = new Error("order-export.markExportRunning: refused — " + (e && e.message || e));
632
- err.code = (e && e.code) || "ORDER_EXPORT_START_REFUSED";
633
- err.cause = e;
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
- await query(
638
- "UPDATE scheduled_exports SET status = 'running', started_at = ?1 WHERE id = ?2",
639
- [ts, exportId],
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
- try { await inst.transition("complete", null); }
659
- catch (e) {
660
- var err = new Error("order-export.markExportComplete: refused — " + (e && e.message || e));
661
- err.code = (e && e.code) || "ORDER_EXPORT_COMPLETE_REFUSED";
662
- err.cause = e;
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
- await query(
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
- try { await inst.transition("fail", null); }
691
- catch (e) {
692
- var err = new Error("order-export.markExportFailed: refused — " + (e && e.message || e));
693
- err.code = (e && e.code) || "ORDER_EXPORT_FAIL_REFUSED";
694
- err.cause = e;
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
- await query(
699
- "UPDATE scheduled_exports SET status = 'failed', error = ?1, completed_at = ?2 WHERE id = ?3",
700
- [errorText, ts, input.export_id],
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.64",
3
+ "version": "0.4.66",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {