@blamejs/blamejs-shop 0.4.18 → 0.4.19

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.19 (2026-06-06) — **Wishlist alerts fire once per event instead of repeating daily, stale digests catch up with a single email, and webhook delivery stops throttling itself.** A set of notification and delivery fixes. Back-in-stock and price-drop wishlist alerts now fire only when something actually changes — a steadily in-stock item no longer re-alerts every day. A wishlist digest whose schedule fell behind sends one catch-up email instead of one per minute until current. Outbound webhook delivery no longer counts its own rate-limit refusals against the limit (which kept the throttle tripped once hit), retrying an exhausted delivery no longer duplicates its dead-letter record, and the blog RSS feed renders correctly when a post title or body contains dollar-sign sequences. **Fixed:** *Back-in-stock and price-drop alerts fire on the change, not the state* — The wishlist alert sweep evaluated the current state of each item, so anything in stock re-alerted every wishlister on every sweep — the daily dedupe window just made the flood daily. Alerts now track the last-observed state per customer and item: back-in-stock fires only on an out-of-stock-to-in-stock transition, price-drop only when the price falls below the level already alerted, and a recovered price re-arms the alert. A steadily in-stock item never re-fires. The weekly cap and dedupe are also enforced atomically, so two overlapping sweeps can't double-send past the cap. · *A stale digest enrollment catches up with one email* — A wishlist digest whose next-send time had fallen far behind advanced one period per scheduler tick and sent an email each time — a subscriber could receive dozens of digests in an hour while the schedule caught up. Catching up now snaps the schedule to the present and sends at most one digest. · *Webhook delivery rate limiting no longer feeds on its own refusals* — The outbound webhook rate-limit gate counted its own refusal records toward the limit, so once an endpoint tripped the throttle it could stay tripped indefinitely, and every suppressed attempt persisted another row. Refusals are no longer counted by the gate and collapse to a single record per endpoint per window. · *Retrying an exhausted webhook delivery is idempotent* — Manually retrying a delivery that had already exhausted its attempts inserted a fresh dead-letter record on every click. Retry of an exhausted or already-delivered delivery now answers as a clean no-op, and the dead-letter table enforces one record per delivery. · *The RSS feed renders posts containing dollar-sign sequences* — The feed assembled items with a plain string replacement, which gives `$` sequences special meaning — a post title or body containing a dollar sign followed by a backtick or ampersand could corrupt the entire feed XML. The feed now uses the same literal splice the page renderers use. The feed's author field also no longer exposes an internal identifier; it carries the shop name, matching the blog pages.
12
+
11
13
  - v0.4.18 (2026-06-06) — **Staff accounts: per-operator credentials with owner, manager, and viewer roles — the shared admin key becomes break-glass.** The admin console gains multi-operator support. Create staff accounts at the new Operators screen, each with its own password and optional API key, and assign a least-privilege role: owner (everything, including operator management), manager (catalog, orders, customers, marketing), or viewer (read-only). Role enforcement happens at the admin write layer on every state-changing request — a viewer is refused the write itself, not just the menu link. The shared ADMIN_API_KEY keeps working as the bootstrap and break-glass owner credential, so upgrading can never lock a store out; once staff accounts exist, treat it like a recovery key. **Added:** *Operators console with per-operator credentials* — `/admin/operators` creates and disables staff accounts. Each operator gets their own password (Argon2id) and optionally a personal API key for the JSON surface, shown once at creation. The screen lists who created each operator and when; disabling one takes effect on their very next request — the signed-in session re-reads the live account row, so there is no revocation lag. · *Owner / manager / viewer roles, enforced on the write itself* — Every admin mutation passes a single permission gate keyed to the action being performed: owner holds every permission including operator management and settings; manager covers catalog, orders, customers, and marketing; viewer holds none — every POST, PUT, and DELETE is refused with a 403, regardless of how the request arrives (browser form or bearer JSON). Read screens remain available to any authenticated operator. Role-denied attempts are recorded in the audit chain alongside every operator-management action. · *Bootstrap and break-glass: the shared key still works* — `ADMIN_API_KEY` resolves to the owner role exactly as before — a store with no operator rows behaves identically to the previous release, and the first staff account is created while signed in with the shared key. Unknown sign-in attempts burn a real password verification against a fixed dummy hash, so response timing does not reveal whether an account exists.
12
14
 
13
15
  - v0.4.17 (2026-06-06) — **Email campaigns: consent-gated broadcasts to the newsletter list, with one-click unsubscribe and a send ledger that never re-mails.** The admin console gains an Email campaigns screen: author a broadcast, target a mailing audience, preview it, test-send to yourself, and send. Consent is the design center — the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists; customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the moment their message sends, and every message carries one-click unsubscribe headers plus an in-body link. Sending drains in rate-bounded batches on the scheduled tick, one bad address never aborts a campaign, and a per-recipient ledger makes a resumed send never deliver twice. **Added:** *Campaign console: author, preview, test-send, send* — `/admin/campaigns` lists campaigns with status and delivered / failed / skipped counts, and a campaign editor takes a subject and a Markdown-or-plaintext body. The body renders escape-by-default with an https-only link gate — the same rendering discipline as the blog — so markup or script in a body lands as inert text in the inbox and in the console list. Preview and an operator-addressed test send are available before any real send. · *Consent resolved per recipient, at the send moment* — The reachable-recipient count shown before sending is computed live from the newsletter list, excluding anyone unsubscribed or on the marketing suppression list — and the same two checks run again for each recipient at the moment their message sends, so someone who unsubscribes mid-broadcast is skipped. Customer accounts keep only an email hash by design, so the newsletter list is the only deliverable-address source and the console says exactly how many recipients are reachable. · *One-click unsubscribe on every broadcast* — Every campaign message carries the RFC 8058 `List-Unsubscribe` / `List-Unsubscribe-Post` headers (so mail clients show their native unsubscribe control), an RFC 2919 `List-Id`, and an in-body unsubscribe link through the existing newsletter opt-out flow. · *Rate-bounded, resumable sending* — Sends drain in batches on the scheduled tick with a reserved hourly budget, so a large campaign spreads out rather than bursting. Each recipient's outcome lands in a send ledger keyed uniquely per campaign and recipient — a send interrupted by the rate budget or a restart resumes where it left off and never mails anyone twice. A failing address is counted and shown, never fatal to the rest of the campaign.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.18",
2
+ "version": "0.4.19",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/webhooks.js CHANGED
@@ -214,27 +214,59 @@ function create(opts) {
214
214
  return await _attempt(deliveryId, endpointRow, eventType, payloadJson);
215
215
  }
216
216
 
217
+ // The marker a refusal row carries in `last_error`. Rows bearing it are
218
+ // EXCLUDED from the rate-limit count — a refusal is the gate's own
219
+ // output, not a real delivery, so counting it would let the gate
220
+ // perpetuate its own throttle: once tripped, the refusal rows alone keep
221
+ // the window "full" and every subsequent send is refused forever even
222
+ // after the real deliveries age out.
223
+ var RATE_LIMITED_MARKER = "rate-limited";
224
+
217
225
  async function _checkRateLimit(endpointRow) {
218
226
  var limit = endpointRow.rate_limit_per_minute;
219
227
  if (typeof limit !== "number" || !isFinite(limit) || limit <= 0) return true;
220
228
  var windowStart = nowFn() - RATE_WINDOW_MS;
221
229
  var r = await query(
222
- "SELECT count(*) AS n FROM webhook_deliveries WHERE endpoint_id = ?1 AND created_at > ?2",
223
- [endpointRow.id, windowStart],
230
+ "SELECT count(*) AS n FROM webhook_deliveries " +
231
+ "WHERE endpoint_id = ?1 AND created_at > ?2 " +
232
+ "AND (last_error IS NULL OR last_error != ?3)",
233
+ [endpointRow.id, windowStart, RATE_LIMITED_MARKER],
224
234
  );
225
235
  var n = (r.rows[0] && (r.rows[0].n != null ? r.rows[0].n : r.rows[0]["count(*)"])) || 0;
226
236
  return n < limit;
227
237
  }
228
238
 
239
+ // Surface a single refusal row per endpoint per window so the operator
240
+ // sees the throttle in the admin feed — but DON'T grow the table by one
241
+ // row per suppressed attempt. A flood of suppressed events against a
242
+ // wedged receiver would otherwise write unbounded refusal rows. When a
243
+ // recent refusal row already exists for this endpoint, refresh its
244
+ // last_attempted_at (collapse) instead of inserting another.
229
245
  async function _persistRateLimited(endpointRow, eventType, payloadJson) {
230
- var deliveryId = b.uuid.v7();
231
246
  var ts = nowFn();
247
+ var windowStart = ts - RATE_WINDOW_MS;
248
+ var existing = await query(
249
+ "SELECT id FROM webhook_deliveries " +
250
+ "WHERE endpoint_id = ?1 AND last_error = ?2 AND created_at > ?3 " +
251
+ "ORDER BY created_at DESC LIMIT 1",
252
+ [endpointRow.id, RATE_LIMITED_MARKER, windowStart],
253
+ );
254
+ if (existing.rows[0]) {
255
+ var existingId = existing.rows[0].id;
256
+ await query(
257
+ "UPDATE webhook_deliveries SET last_attempted_at = ?1, event_type = ?2, payload_json = ?3 " +
258
+ "WHERE id = ?4",
259
+ [ts, eventType, payloadJson, existingId],
260
+ );
261
+ return await _getDelivery(existingId);
262
+ }
263
+ var deliveryId = b.uuid.v7();
232
264
  await query(
233
265
  "INSERT INTO webhook_deliveries " +
234
266
  "(id, endpoint_id, event_type, payload_json, attempts, last_status, last_error, " +
235
267
  " last_attempted_at, created_at) " +
236
268
  "VALUES (?1, ?2, ?3, ?4, 0, NULL, ?5, ?6, ?7)",
237
- [deliveryId, endpointRow.id, eventType, payloadJson, "rate-limited", ts, ts],
269
+ [deliveryId, endpointRow.id, eventType, payloadJson, RATE_LIMITED_MARKER, ts, ts],
238
270
  );
239
271
  return await _getDelivery(deliveryId);
240
272
  }
@@ -311,6 +343,13 @@ function create(opts) {
311
343
  // per-endpoint feed still surfaces the failure. The DLQ row
312
344
  // carries the full payload so replayFromDlq can re-queue
313
345
  // without consulting the original delivery.
346
+ //
347
+ // Idempotent per delivery: the INSERT...SELECT writes only when no
348
+ // dead-letter row already exists for this delivery_id. Without it, a
349
+ // manual retry of an already-exhausted delivery dropped a duplicate
350
+ // DLQ row on every click. The migration 0215 UNIQUE(delivery_id) is
351
+ // the schema backstop; this guard avoids relying on a swallowed
352
+ // constraint error.
314
353
  var dlqId = b.uuid.v7();
315
354
  var firstAttemptedAt;
316
355
  var r = await query(
@@ -322,7 +361,8 @@ function create(opts) {
322
361
  "INSERT INTO webhook_dlq " +
323
362
  "(id, endpoint_id, delivery_id, event_type, payload_json, attempts, " +
324
363
  " last_status, last_error, first_attempted_at, last_attempted_at, dropped_at) " +
325
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
364
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 " +
365
+ "WHERE NOT EXISTS (SELECT 1 FROM webhook_dlq WHERE delivery_id = ?3)",
326
366
  [dlqId, endpointRow.id, deliveryId, eventType, payloadJson, attempts,
327
367
  lastStatus, lastError, firstAttemptedAt, ts, ts],
328
368
  );
@@ -443,6 +483,16 @@ function create(opts) {
443
483
  _uuid(deliveryId, "delivery id");
444
484
  var d = await _getDelivery(deliveryId);
445
485
  if (!d) return null;
486
+ // An exhausted delivery (attempts at/over the max) has already
487
+ // moved to the DLQ — re-attempting it would push attempts past the
488
+ // max and drop a DUPLICATE DLQ row on every click. Answer a no-op
489
+ // (return the row unchanged) so the operator UI shows the existing
490
+ // state; re-queuing an exhausted delivery is `replayFromDlq`, not
491
+ // `retry`. A successfully-delivered row is likewise a no-op.
492
+ var attempts = Number(d.attempts || 0);
493
+ if (attempts >= MAX_ATTEMPTS || d.delivered_at != null) {
494
+ return d;
495
+ }
446
496
  var endpoint = await _getEndpoint(d.endpoint_id);
447
497
  if (!endpoint) return null;
448
498
  return await _attempt(deliveryId, endpoint, d.event_type, d.payload_json);
@@ -550,16 +550,6 @@ function create(opts) {
550
550
 
551
551
  // ---- scanAndDispatch internals ----------------------------------------
552
552
 
553
- async function _countAlertsThisWeek(customerId, now) {
554
- var since = now - MS_PER_WEEK;
555
- var r = await query(
556
- "SELECT COUNT(*) AS n FROM wishlist_alerts_sent "
557
- + "WHERE customer_id = ?1 AND occurred_at >= ?2",
558
- [customerId, since],
559
- );
560
- return Number((r.rows[0] || {}).n || 0);
561
- }
562
-
563
553
  async function _hasRecentFire(customerId, sku, policySlug, now) {
564
554
  var since = now - DEDUPE_WINDOW_MS;
565
555
  var r = await query(
@@ -571,6 +561,81 @@ function create(opts) {
571
561
  return r.rows.length > 0;
572
562
  }
573
563
 
564
+ // Last-observed state for a (customer, sku, policy) tuple — the memory
565
+ // that turns a current-state read into a transition read. NULL when the
566
+ // scanner has never seen this tuple.
567
+ async function _lastState(customerId, sku, policySlug) {
568
+ var r = await query(
569
+ "SELECT last_in_stock, last_alerted_bps FROM wishlist_alert_state "
570
+ + "WHERE customer_id = ?1 AND sku = ?2 AND policy_slug = ?3 LIMIT 1",
571
+ [customerId, sku, policySlug],
572
+ );
573
+ var row = r.rows[0];
574
+ if (!row) return { last_in_stock: null, last_alerted_bps: null };
575
+ return {
576
+ last_in_stock: row.last_in_stock == null ? null : Number(row.last_in_stock),
577
+ last_alerted_bps: row.last_alerted_bps == null ? null : Number(row.last_alerted_bps),
578
+ };
579
+ }
580
+
581
+ // Upsert the observed state for a tuple. Only the columns named in
582
+ // `patch` are written; the row's other column keeps its prior value
583
+ // (read once here so the UNIQUE-keyed REPLACE doesn't clobber the
584
+ // sibling column). `inStock` / `alertedBps` are 0/1/null and int/null.
585
+ async function _recordState(customerId, sku, policySlug, patch, now) {
586
+ var prior = await _lastState(customerId, sku, policySlug);
587
+ var inStock = Object.prototype.hasOwnProperty.call(patch, "last_in_stock")
588
+ ? patch.last_in_stock
589
+ : prior.last_in_stock;
590
+ var alertedBps = Object.prototype.hasOwnProperty.call(patch, "last_alerted_bps")
591
+ ? patch.last_alerted_bps
592
+ : prior.last_alerted_bps;
593
+ // INSERT-or-UPDATE on the UNIQUE(customer_id, sku, policy_slug) key.
594
+ await query(
595
+ "INSERT INTO wishlist_alert_state "
596
+ + "(customer_id, sku, policy_slug, last_in_stock, last_alerted_bps, updated_at) "
597
+ + "VALUES (?1, ?2, ?3, ?4, ?5, ?6) "
598
+ + "ON CONFLICT (customer_id, sku, policy_slug) DO UPDATE SET "
599
+ + "last_in_stock = ?4, last_alerted_bps = ?5, updated_at = ?6",
600
+ [customerId, sku, policySlug, inStock, alertedBps, now],
601
+ );
602
+ }
603
+
604
+ // Atomic dedupe + weekly-cap CLAIM. Writes the sent-ledger row in a
605
+ // single conditional INSERT that refuses when (a) a fire for the same
606
+ // (customer, sku, policy) already landed inside the dedupe window, or
607
+ // (b) the customer is at/over the weekly cap. Because the guard and the
608
+ // write are one statement, two concurrent sweeps can't both pass the
609
+ // read and then both insert — the second's INSERT...SELECT sees the
610
+ // first's row and matches zero rows. Returns true when this call won
611
+ // the claim. Mirrors the conditional-UPDATE house pattern
612
+ // (catalog.inventory.adjustOnHand / hold).
613
+ async function _claimLedgerRow(id, customerId, policySlug, sku, now, weeklyCap) {
614
+ var dedupeSince = now - DEDUPE_WINDOW_MS;
615
+ var weekSince = now - MS_PER_WEEK;
616
+ // `weeklyCap` null → no cap; encode as a sentinel the SQL treats as
617
+ // "always under cap" by comparing against a value the count can never
618
+ // reach when cap is absent.
619
+ var capActive = weeklyCap != null ? 1 : 0;
620
+ var capValue = weeklyCap != null ? weeklyCap : 0;
621
+ var r = await query(
622
+ "INSERT INTO wishlist_alerts_sent (id, customer_id, policy_slug, sku, channel, occurred_at) "
623
+ + "SELECT ?1, ?2, ?3, ?4, 'email', ?5 "
624
+ + "WHERE NOT EXISTS ("
625
+ + " SELECT 1 FROM wishlist_alerts_sent "
626
+ + " WHERE customer_id = ?2 AND sku = ?4 AND policy_slug = ?3 AND occurred_at >= ?6"
627
+ + ") "
628
+ + "AND ("
629
+ + " ?7 = 0 OR ("
630
+ + " SELECT COUNT(*) FROM wishlist_alerts_sent "
631
+ + " WHERE customer_id = ?2 AND occurred_at >= ?8"
632
+ + " ) < ?9"
633
+ + ")",
634
+ [id, customerId, policySlug, sku, now, dedupeSince, capActive, weekSince, capValue],
635
+ );
636
+ return Number(r.rowCount || 0) === 1;
637
+ }
638
+
574
639
  // Walk every wishlist entry that has a non-null variant_id. Whole-
575
640
  // product (variant_id IS NULL) rows are skipped at v1 — the policy
576
641
  // evaluator can't resolve a single sku for them, and the operator-
@@ -596,9 +661,18 @@ function create(opts) {
596
661
  return defaultCurrency;
597
662
  }
598
663
 
599
- // Evaluate a single (entry, policy) pair. Returns either:
600
- // { fire: true, sku, payload } — dispatch + ledger
601
- // { fire: false, reason } accounted as `skipped`
664
+ // Evaluate a single (entry, policy) pair against the TRANSITION from
665
+ // the last-observed state, not the current state in isolation. Returns:
666
+ // { fire: true, sku, payload, state } dispatch + ledger; `state`
667
+ // is the observed-state patch
668
+ // to persist (always applied,
669
+ // fire or not).
670
+ // { fire: false, reason, sku?, state? } — accounted as `skipped`; a
671
+ // present `state` patch is
672
+ // still persisted so the next
673
+ // sweep reads a fresh baseline.
674
+ // The last-observed { last_in_stock, last_alerted_bps } for the tuple is
675
+ // loaded internally once the sku resolves (null fields when never seen).
602
676
  async function _evaluate(entry, policy, now) {
603
677
  // Map variant → sku.
604
678
  var variant;
@@ -608,12 +682,14 @@ function create(opts) {
608
682
  var sku = variant.sku;
609
683
  if (typeof sku !== "string" || !sku.length) return { fire: false, reason: "variant_has_no_sku" };
610
684
 
685
+ var prior = await _lastState(entry.customer_id, sku, policy.slug);
686
+
611
687
  if (policy.trigger === "price_drop") {
612
688
  var currency = await _resolveCurrency(entry.customer_id);
613
689
  var current;
614
690
  try { current = await catalog.prices.current(entry.variant_id, currency); }
615
691
  catch (_e) { current = null; }
616
- if (!current) return { fire: false, reason: "no_current_price" };
692
+ if (!current) return { fire: false, reason: "no_current_price", sku: sku };
617
693
  var history;
618
694
  try { history = await catalog.prices.history(entry.variant_id, currency); }
619
695
  catch (_e) { history = []; }
@@ -624,16 +700,29 @@ function create(opts) {
624
700
  var h = history[i];
625
701
  if (h.effective_until != null) { baseline = h; break; }
626
702
  }
627
- if (!baseline) return { fire: false, reason: "no_baseline_price" };
703
+ if (!baseline) return { fire: false, reason: "no_baseline_price", sku: sku };
628
704
  var baseAmt = Number(baseline.amount_minor);
629
705
  var curAmt = Number(current.amount_minor);
630
706
  if (!(baseAmt > 0) || !(curAmt >= 0) || curAmt >= baseAmt) {
631
- return { fire: false, reason: "no_drop" };
707
+ // No drop in effect the price recovered to/above baseline. Clear
708
+ // the last-alerted level so a fresh drop later is a transition.
709
+ return { fire: false, reason: "no_drop", sku: sku, state: { last_alerted_bps: null } };
632
710
  }
633
711
  // Percent-off in basis points: ((base - cur) / base) * 10000.
634
712
  var bps = Math.floor(((baseAmt - curAmt) * 10000) / baseAmt);
635
713
  var threshold = Number(policy.threshold.percent_off_bps_min);
636
- if (bps < threshold) return { fire: false, reason: "below_threshold" };
714
+ if (bps < threshold) {
715
+ // Below the policy gate — not an alertable drop. Don't touch the
716
+ // last-alerted level (a shallow dip after a deep alerted drop
717
+ // shouldn't reset the baseline and re-arm a re-fire).
718
+ return { fire: false, reason: "below_threshold", sku: sku };
719
+ }
720
+ // Transition gate: fire only on a NEW or DEEPER drop than the level
721
+ // we last alerted. A steady drop at the same depth must not re-fire,
722
+ // dedupe window or not.
723
+ if (prior.last_alerted_bps != null && bps <= prior.last_alerted_bps) {
724
+ return { fire: false, reason: "no_deeper_drop", sku: sku };
725
+ }
637
726
  return {
638
727
  fire: true,
639
728
  sku: sku,
@@ -644,6 +733,12 @@ function create(opts) {
644
733
  new_amount_minor: curAmt,
645
734
  discount_bps: bps,
646
735
  },
736
+ // `state` is the always-record observation (none here — recording
737
+ // the alerted level happens only on a committed send, below).
738
+ // `alertedBps` is recorded post-send so a lost claim / failed
739
+ // mailer doesn't suppress a legitimate later re-fire.
740
+ state: {},
741
+ alertedBps: bps,
647
742
  };
648
743
  }
649
744
 
@@ -651,9 +746,14 @@ function create(opts) {
651
746
  var inv;
652
747
  try { inv = await catalog.inventory.get(sku); }
653
748
  catch (_e) { inv = null; }
654
- if (!inv) return { fire: false, reason: "inventory_unresolvable" };
749
+ if (!inv) return { fire: false, reason: "inventory_unresolvable", sku: sku };
655
750
  var available = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
656
- if (!(available > 0)) return { fire: false, reason: "still_out_of_stock" };
751
+ var inStockNow = available > 0 ? 1 : 0;
752
+ if (inStockNow === 0) {
753
+ // Out of stock — record the observed state so a later restock is a
754
+ // 0 -> 1 transition.
755
+ return { fire: false, reason: "still_out_of_stock", sku: sku, state: { last_in_stock: 0 } };
756
+ }
657
757
  // Optional dep — when wired, suppress if the customer is already
658
758
  // subscribed via the PDP "notify me" toggle. The dispatcher can
659
759
  // only know the customer's email when we know they're a logged-
@@ -668,7 +768,19 @@ function create(opts) {
668
768
  var sub;
669
769
  try { sub = await stockAlerts.isSubscribedByCustomer({ customer_id: entry.customer_id, sku: sku }); }
670
770
  catch (_e) { sub = null; }
671
- if (sub && sub.subscribed === true) return { fire: false, reason: "duplicate_via_stock_alerts" };
771
+ // Still record the in-stock observation so the transition baseline
772
+ // tracks reality even when the stockAlerts path owns the send.
773
+ if (sub && sub.subscribed === true) {
774
+ return { fire: false, reason: "duplicate_via_stock_alerts", sku: sku, state: { last_in_stock: 1 } };
775
+ }
776
+ }
777
+ // Transition gate: fire only on the out -> in EDGE. A first
778
+ // observation (prior unknown) or a steady in-stock state records the
779
+ // observation but does not fire — there is no restock event to
780
+ // announce when we never saw the item go out.
781
+ var isRestock = prior.last_in_stock === 0;
782
+ if (!isRestock) {
783
+ return { fire: false, reason: "no_restock_transition", sku: sku, state: { last_in_stock: 1 } };
672
784
  }
673
785
  return {
674
786
  fire: true,
@@ -677,6 +789,7 @@ function create(opts) {
677
789
  variant_id: entry.variant_id,
678
790
  available: available,
679
791
  },
792
+ state: { last_in_stock: 1 },
680
793
  };
681
794
  }
682
795
 
@@ -722,8 +835,10 @@ function create(opts) {
722
835
  no_baseline_price: 0,
723
836
  no_current_price: 0,
724
837
  no_drop: 0,
838
+ no_deeper_drop: 0,
725
839
  below_threshold: 0,
726
840
  still_out_of_stock: 0,
841
+ no_restock_transition: 0,
727
842
  inventory_unresolvable: 0,
728
843
  variant_unresolvable: 0,
729
844
  variant_has_no_sku: 0,
@@ -738,19 +853,25 @@ function create(opts) {
738
853
  for (var pp = 0; pp < scannablePolicies.length; pp += 1) {
739
854
  var policy = scannablePolicies[pp];
740
855
 
741
- // Cheapest cuts first.
856
+ // Cheapest cut first.
742
857
  var optedOut = await isUnsubscribedFromTrigger(entry.customer_id, policy.trigger);
743
858
  if (optedOut) { skipped += 1; skippedBy.unsubscribed += 1; continue; }
744
859
 
745
- if (policy.max_alerts_per_week_per_customer != null) {
746
- var weekN = await _countAlertsThisWeek(entry.customer_id, now);
747
- if (weekN >= policy.max_alerts_per_week_per_customer) {
748
- skipped += 1; skippedBy.weekly_cap_reached += 1; continue;
749
- }
860
+ // Evaluate against the TRANSITION from the last-observed state —
861
+ // a steady in-stock / steady-depth-drop state never re-fires. The
862
+ // sku isn't known until the variant resolves inside _evaluate, so
863
+ // it loads the prior state for the resolved tuple itself.
864
+ var v = await _evaluate(entry, policy, now);
865
+
866
+ // Persist the always-record observation (current in-stock state /
867
+ // a cleared drop) regardless of fire, so the next sweep reads a
868
+ // fresh baseline even when this one didn't send. An empty patch
869
+ // (a price_drop fire — its alerted level is recorded only after a
870
+ // committed send) is a no-op, so skip the write.
871
+ if (v.sku && v.state && Object.keys(v.state).length > 0) {
872
+ await _recordState(entry.customer_id, v.sku, policy.slug, v.state, now);
750
873
  }
751
874
 
752
- // Evaluate the policy condition.
753
- var v = await _evaluate(entry, policy, now);
754
875
  if (!v.fire) {
755
876
  skipped += 1;
756
877
  if (skippedBy[v.reason] != null) skippedBy[v.reason] += 1;
@@ -758,13 +879,10 @@ function create(opts) {
758
879
  continue;
759
880
  }
760
881
 
761
- // Per-(customer, sku, policy) recent-fire suppression.
762
- var dup = await _hasRecentFire(entry.customer_id, v.sku, policy.slug, now);
763
- if (dup) { skipped += 1; skippedBy.recent_dedupe += 1; continue; }
764
-
765
882
  candidates += 1;
766
883
 
767
- // Resolve the customer's email. Absent → no_email, skipped.
884
+ // Resolve the customer's email. Absent → no_email, skipped — we
885
+ // never claim a ledger row we can't send.
768
886
  var customerEmail = null;
769
887
  if (emailForCustomer) {
770
888
  try { customerEmail = await emailForCustomer(entry.customer_id); }
@@ -774,6 +892,27 @@ function create(opts) {
774
892
  skipped += 1; skippedBy.no_email += 1; continue;
775
893
  }
776
894
 
895
+ // Atomic dedupe + weekly-cap CLAIM. The conditional INSERT is the
896
+ // serialization point: two concurrent sweeps can't both pass the
897
+ // dedupe/cap guard and then both write — the loser's INSERT...
898
+ // SELECT sees the winner's row and matches zero rows.
899
+ var id = b.uuid.v7();
900
+ var ts = _monotonicTs(now);
901
+ var claimed = await _claimLedgerRow(
902
+ id, entry.customer_id, policy.slug, v.sku, ts,
903
+ policy.max_alerts_per_week_per_customer,
904
+ );
905
+ if (!claimed) {
906
+ // Lost the claim — either a recent dedupe fire or the weekly
907
+ // cap. Distinguish for accurate accounting (read-only; the
908
+ // mutation already serialized at the INSERT).
909
+ var dup = await _hasRecentFire(entry.customer_id, v.sku, policy.slug, ts);
910
+ skipped += 1;
911
+ if (dup) skippedBy.recent_dedupe += 1;
912
+ else skippedBy.weekly_cap_reached += 1;
913
+ continue;
914
+ }
915
+
777
916
  // Resolve a product title for the email. Best-effort — falls
778
917
  // through to the sku string when the catalog read fails.
779
918
  var productTitle = v.sku;
@@ -810,20 +949,20 @@ function create(opts) {
810
949
  dispatchOk = true;
811
950
  } catch (_e4) {
812
951
  // Drop-silent at the row level — a flapping mailer must not
813
- // poison the rest of the scan. Account in skipped.
952
+ // poison the rest of the scan. Release the claimed ledger row
953
+ // so a later sweep retries instead of being suppressed by the
954
+ // dedupe window for a send that never happened.
955
+ await query("DELETE FROM wishlist_alerts_sent WHERE id = ?1", [id]);
814
956
  skipped += 1; skippedBy.email_dispatch_failed += 1; continue;
815
957
  }
816
958
  if (!dispatchOk) continue;
817
959
 
818
- // Ledger write — the source of truth for "we already fired
819
- // this alert" + the dedupe window.
820
- var id = b.uuid.v7();
821
- var ts = _monotonicTs(now);
822
- await query(
823
- "INSERT INTO wishlist_alerts_sent (id, customer_id, policy_slug, sku, channel, occurred_at) "
824
- + "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
825
- [id, entry.customer_id, policy.slug, v.sku, "email", ts],
826
- );
960
+ // Committed sendrecord the alerted depth so the same drop
961
+ // depth is steady on the next sweep (price_drop only; the
962
+ // back_in_stock observation was already recorded above).
963
+ if (v.alertedBps != null) {
964
+ await _recordState(entry.customer_id, v.sku, policy.slug, { last_alerted_bps: v.alertedBps }, ts);
965
+ }
827
966
  sent += 1;
828
967
  }
829
968
  }
@@ -972,7 +972,20 @@ function create(opts) {
972
972
  "VALUES (?1, ?2, ?3, ?4)",
973
973
  [sentId, enrollment.id, sentItemCount, sentAt],
974
974
  );
975
+ // Advance the cadence. The normal step is one period past the
976
+ // current next_dispatch_at — that keeps the schedule aligned to its
977
+ // target weekday / day-of-month when the tick fires on time. But a
978
+ // STALE enrollment (the cron was down, or the customer enrolled long
979
+ // ago) has a next_dispatch_at many periods in the past; advancing it
980
+ // one period at a time would leave it still due, so the very next
981
+ // tick re-fires, and a subscriber gets one digest per tick until the
982
+ // schedule catches up — a flood. Instead, when one period still
983
+ // doesn't clear `now`, snap straight to the next occurrence strictly
984
+ // after `now`. The catch-up costs at most ONE digest, never a storm.
975
985
  var nextDispatchAt = _advanceByOnePeriod(schedule, Number(enrollment.next_dispatch_at));
986
+ if (nextDispatchAt <= now) {
987
+ nextDispatchAt = _nextDispatchAt(schedule, now);
988
+ }
976
989
  await query(
977
990
  "UPDATE wishlist_digest_enrollments SET next_dispatch_at = ?1 WHERE id = ?2",
978
991
  [nextDispatchAt, enrollment.id],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.18",
3
+ "version": "0.4.19",
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": {