@blamejs/blamejs-shop 0.4.65 → 0.4.67
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 +22 -6
- package/lib/inventory-audits.js +86 -24
- package/lib/storefront.js +3 -1
- package/lib/support-tickets.js +17 -2
- 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.67 (2026-06-20) — **Subject-access exports now carry the customer's complete record, not just the first page.** A data-subject access export assembled each domain by reading a single page from that domain's reader. A customer with more than one page in any paginated domain — orders, the loyalty points ledger, product reviews, wishlist entries, survey invitations, or support tickets — silently received a truncated export, which is an incomplete response to a GDPR Article 15 request. Each of those domains is now drained to the end by following the reader's own cursor, so the export carries every row. Readers that already return the whole set in one read (addresses, the consent ledger, subscriptions) were already complete and are unchanged. As part of this, the support-ticket reader keyed on customer id (the one the export uses, since it holds no plaintext email) gained cursor pagination so it can be paged to completion. **Changed:** *supportTickets.listByCustomerId and customerSurveys.invitationsForCustomer return { rows, next_cursor }* — Both customer-scoped readers that the export uses now return a paginated envelope — { rows, next_cursor } — instead of a bare array, so a caller can page a customer's full history to the end. listByCustomerId pages on the (opened_at desc, id desc) tuple (matching the email-keyed listForCustomer); invitationsForCustomer pages on the invitation id. If you call either directly, read the rows off the .rows field and pass the previous next_cursor as the cursor option to continue where the last page ended. **Fixed:** *Access exports drain every paginated domain to completion* — The export now follows each reader's cursor to the end for orders, the loyalty points ledger, product reviews, wishlist entries, survey invitations, and support tickets, concatenating every page. A customer with hundreds of orders or ledger entries receives all of them rather than the most recent hundred. A page-count backstop far above any real per-customer record guards against a misbehaving cursor without ever truncating a genuine export.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
package/lib/customer-surveys.js
CHANGED
|
@@ -651,14 +651,30 @@ function create(opts) {
|
|
|
651
651
|
var custId = _uuid(customerId, "customer_id");
|
|
652
652
|
listOpts = listOpts || {};
|
|
653
653
|
var limit = _limit(listOpts.limit);
|
|
654
|
-
var
|
|
655
|
-
|
|
656
|
-
"
|
|
657
|
-
|
|
658
|
-
|
|
654
|
+
var cursor = listOpts.cursor;
|
|
655
|
+
if (cursor != null && (typeof cursor !== "string" || !cursor.length)) {
|
|
656
|
+
throw new TypeError("customerSurveys.invitationsForCustomer: cursor must be a non-empty string when provided");
|
|
657
|
+
}
|
|
658
|
+
var sql = "SELECT * FROM survey_invitations WHERE customer_id = ?1";
|
|
659
|
+
var params = [custId];
|
|
660
|
+
var idx = 2;
|
|
661
|
+
if (cursor != null) {
|
|
662
|
+
// Cursor is the last-seen invitation id; v7 ids are lexicographically
|
|
663
|
+
// monotonic (issued_at sits in the prefix), so a single `id < cursor`
|
|
664
|
+
// over (id DESC) preserves total ordering — the same idiom
|
|
665
|
+
// responsesForSurvey uses. Returning { rows, next_cursor } lets a
|
|
666
|
+
// caller that needs the customer's COMPLETE invitation set — the
|
|
667
|
+
// subject-access export — page to the end instead of stopping at the
|
|
668
|
+
// first `limit`.
|
|
669
|
+
sql += " AND id < ?" + idx; params.push(cursor); idx += 1;
|
|
670
|
+
}
|
|
671
|
+
sql += " ORDER BY id DESC LIMIT ?" + idx;
|
|
672
|
+
params.push(limit);
|
|
673
|
+
var r = await query(sql, params);
|
|
659
674
|
var out = [];
|
|
660
675
|
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeInvitation(r.rows[i]));
|
|
661
|
-
|
|
676
|
+
var nextCursor = out.length === limit ? out[out.length - 1].id : null;
|
|
677
|
+
return { rows: out, next_cursor: nextCursor };
|
|
662
678
|
}
|
|
663
679
|
|
|
664
680
|
// ---- submitResponse ---------------------------------------------------
|
package/lib/inventory-audits.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/lib/storefront.js
CHANGED
|
@@ -19684,7 +19684,9 @@ function mount(router, deps) {
|
|
|
19684
19684
|
// The customer's own ticket list, scoped to their session customer_id.
|
|
19685
19685
|
router.get("/account/support", async function (req, res) {
|
|
19686
19686
|
var auth = await _supportAuth(req, res); if (!auth) return;
|
|
19687
|
-
|
|
19687
|
+
// First page only — the account list shows the customer's most
|
|
19688
|
+
// recent tickets; listByCustomerId now returns { rows, next_cursor }.
|
|
19689
|
+
var tickets = (await support.listByCustomerId(auth.customer_id, { limit: 100 })).rows;
|
|
19688
19690
|
var cartCount = await _cartCountForReq(req);
|
|
19689
19691
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
19690
19692
|
_send(res, 200, renderSupportList({
|
package/lib/support-tickets.js
CHANGED
|
@@ -821,11 +821,17 @@ function create(opts) {
|
|
|
821
821
|
// storefront, which holds only the session customer id (the raw
|
|
822
822
|
// email is never on disk), can list + ownership-gate a signed-in
|
|
823
823
|
// shopper's tickets without re-deriving the email hash. Composes the
|
|
824
|
-
// same id / status / limit validators as the rest of the surface
|
|
824
|
+
// same id / status / limit validators as the rest of the surface, and
|
|
825
|
+
// (like listForCustomer) returns `{ rows, next_cursor }` on the
|
|
826
|
+
// (opened_at DESC, id DESC) tuple so a caller that needs the customer's
|
|
827
|
+
// COMPLETE ticket set — e.g. the subject-access export, which keys on
|
|
828
|
+
// customer_id and has no email to drive listForCustomer — can page to
|
|
829
|
+
// the end instead of stopping at the first `limit`.
|
|
825
830
|
listByCustomerId: async function (customerId, listOpts) {
|
|
826
831
|
customerId = _uuid(customerId, "customer_id");
|
|
827
832
|
listOpts = listOpts || {};
|
|
828
833
|
var limit = _limit(listOpts.limit);
|
|
834
|
+
var cursorVals = _decodeCursor(listOpts.cursor, "listByCustomerId");
|
|
829
835
|
var where = ["customer_id = ?1"];
|
|
830
836
|
var params = [customerId];
|
|
831
837
|
var idx = 2;
|
|
@@ -834,11 +840,20 @@ function create(opts) {
|
|
|
834
840
|
params.push(_status(listOpts.status_filter, "status_filter"));
|
|
835
841
|
idx += 1;
|
|
836
842
|
}
|
|
843
|
+
if (cursorVals) {
|
|
844
|
+
where.push(
|
|
845
|
+
"(opened_at < ?" + idx + " OR " +
|
|
846
|
+
"(opened_at = ?" + idx + " AND id < ?" + (idx + 1) + "))"
|
|
847
|
+
);
|
|
848
|
+
params.push(cursorVals[0], cursorVals[1]);
|
|
849
|
+
idx += 2;
|
|
850
|
+
}
|
|
837
851
|
params.push(limit);
|
|
838
852
|
var sql = "SELECT * FROM support_tickets WHERE " + where.join(" AND ") +
|
|
839
853
|
" ORDER BY opened_at DESC, id DESC LIMIT ?" + idx;
|
|
840
854
|
var r = await query(sql, params);
|
|
841
|
-
|
|
855
|
+
var rows = r.rows.map(_hydrate);
|
|
856
|
+
return { rows: rows, next_cursor: _encodeNext(rows, limit) };
|
|
842
857
|
},
|
|
843
858
|
|
|
844
859
|
// Operator queue across every assignment state — newest-stale first
|
package/package.json
CHANGED