@blamejs/blamejs-shop 0.0.53 → 0.0.56

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/lib/webhooks.js CHANGED
@@ -53,6 +53,38 @@ var KNOWN_EVENTS = Object.freeze([
53
53
 
54
54
  var SECRET_BYTES = 32;
55
55
 
56
+ // Exponential backoff schedule (seconds) for failed deliveries.
57
+ //
58
+ // Budget rationale — the receiver can be down for a full day before
59
+ // we give up. The five-step ramp is deliberately conservative:
60
+ //
61
+ // 60s → cover a transient blip (LB reload, instance restart,
62
+ // garbage-collection pause). Quick retry catches the
63
+ // 99% of failures that resolve themselves in <1 minute.
64
+ // 300s → after the second failure assume the outage is real;
65
+ // back off to five minutes so we don't hammer a receiver
66
+ // whose maintenance window is mid-deploy.
67
+ // 1800s → thirty minutes — covers a typical incident-response
68
+ // rollback / hotfix-deploy turnaround.
69
+ // 14400s → four hours — covers an overnight outage where the
70
+ // on-call hasn't been paged yet.
71
+ // 86400s → one day — last attempt before DLQ. If a receiver is
72
+ // still down 24h after the first failure the operator
73
+ // needs to look at the row by hand; further retries
74
+ // would risk DOS'ing the receiver's recovery path.
75
+ //
76
+ // After the fifth failed attempt the delivery moves to webhook_dlq
77
+ // for operator investigation — see `_moveToDlq` below.
78
+ var BACKOFF_SCHEDULE_S = Object.freeze([60, 300, 1800, 14400, 86400]);
79
+ var MAX_ATTEMPTS = 5;
80
+
81
+ // Window used by the per-endpoint rate-limit sliding count. One
82
+ // minute is short enough that a wedged receiver doesn't permanently
83
+ // brick its own delivery feed (the window naturally slides forward),
84
+ // long enough that bursty fan-out from a single high-volume event
85
+ // doesn't trip the throttle on a healthy endpoint.
86
+ var RATE_WINDOW_MS = 60 * 1000;
87
+
56
88
  function _now() { return Date.now(); }
57
89
 
58
90
  function _validateUrl(url) {
@@ -120,10 +152,25 @@ function create(opts) {
120
152
  }
121
153
  var transport = typeof opts.transport === "function" ? opts.transport : _defaultTransport;
122
154
  var retryOpts = opts.retry || { maxAttempts: 2, baseDelayMs: 25, maxDelayMs: 250 };
155
+ // Operator-injectable clock — tests fast-forward through the
156
+ // exponential backoff schedule by passing a synthetic `now()` that
157
+ // jumps ahead by the scheduled interval. Production callers leave
158
+ // it undefined and pick up the wall clock.
159
+ var nowFn = (typeof opts.now === "function") ? opts.now : _now;
123
160
 
124
161
  async function _deliver(endpointRow, eventType, payloadJson) {
162
+ // Rate-limit gate — count deliveries created in the last 60s for
163
+ // this endpoint and refuse the new fan-out when the configured
164
+ // ceiling would be exceeded. The refusal persists a delivery row
165
+ // (last_error = "rate-limited", delivered_at = NULL, no
166
+ // next_retry_at) so the operator sees the throttle in the admin
167
+ // feed instead of silently dropping the event.
168
+ var rateOk = await _checkRateLimit(endpointRow);
169
+ if (!rateOk) {
170
+ return await _persistRateLimited(endpointRow, eventType, payloadJson);
171
+ }
125
172
  var deliveryId = _b().uuid.v7();
126
- var createdAt = _now();
173
+ var createdAt = nowFn();
127
174
  await query(
128
175
  "INSERT INTO webhook_deliveries (id, endpoint_id, event_type, payload_json, attempts, created_at) " +
129
176
  "VALUES (?1, ?2, ?3, ?4, 0, ?5)",
@@ -132,6 +179,31 @@ function create(opts) {
132
179
  return await _attempt(deliveryId, endpointRow, eventType, payloadJson);
133
180
  }
134
181
 
182
+ async function _checkRateLimit(endpointRow) {
183
+ var limit = endpointRow.rate_limit_per_minute;
184
+ if (typeof limit !== "number" || !isFinite(limit) || limit <= 0) return true;
185
+ var windowStart = nowFn() - RATE_WINDOW_MS;
186
+ var r = await query(
187
+ "SELECT count(*) AS n FROM webhook_deliveries WHERE endpoint_id = ?1 AND created_at > ?2",
188
+ [endpointRow.id, windowStart],
189
+ );
190
+ var n = (r.rows[0] && (r.rows[0].n != null ? r.rows[0].n : r.rows[0]["count(*)"])) || 0;
191
+ return n < limit;
192
+ }
193
+
194
+ async function _persistRateLimited(endpointRow, eventType, payloadJson) {
195
+ var deliveryId = _b().uuid.v7();
196
+ var ts = nowFn();
197
+ await query(
198
+ "INSERT INTO webhook_deliveries " +
199
+ "(id, endpoint_id, event_type, payload_json, attempts, last_status, last_error, " +
200
+ " last_attempted_at, created_at) " +
201
+ "VALUES (?1, ?2, ?3, ?4, 0, NULL, ?5, ?6, ?7)",
202
+ [deliveryId, endpointRow.id, eventType, payloadJson, "rate-limited", ts, ts],
203
+ );
204
+ return await _getDelivery(deliveryId);
205
+ }
206
+
135
207
  async function _attempt(deliveryId, endpointRow, eventType, payloadJson) {
136
208
  var signer = _b().webhook.signer({
137
209
  algo: "hmac-sha3-512",
@@ -157,24 +229,70 @@ function create(opts) {
157
229
  errMsg = (e && e.message) || String(e);
158
230
  }
159
231
 
160
- var ts = _now();
232
+ var ts = nowFn();
161
233
  var delivered = (status >= 100 && status < 400);
162
234
  if (delivered) {
163
235
  await query(
164
- "UPDATE webhook_deliveries SET attempts = attempts + 1, last_status = ?1, last_error = NULL, delivered_at = ?2 " +
236
+ "UPDATE webhook_deliveries SET attempts = attempts + 1, last_status = ?1, " +
237
+ "last_error = NULL, delivered_at = ?2, last_attempted_at = ?2, next_retry_at = NULL " +
165
238
  "WHERE id = ?3",
166
239
  [status, ts, deliveryId],
167
240
  );
168
- } else {
169
- await query(
170
- "UPDATE webhook_deliveries SET attempts = attempts + 1, last_status = ?1, last_error = ?2 " +
171
- "WHERE id = ?3",
172
- [status || null, errMsg || ("http-" + status), deliveryId],
173
- );
241
+ return await _getDelivery(deliveryId);
242
+ }
243
+
244
+ // Failure path — increment attempts, record the error, then
245
+ // either schedule the next retry (attempts < MAX_ATTEMPTS) or
246
+ // move the delivery to webhook_dlq for operator review. The
247
+ // backoff index uses `attempts - 1` after increment so the first
248
+ // failure gets BACKOFF_SCHEDULE_S[0] (60s), the second gets [1]
249
+ // (300s), etc.
250
+ var current = await _getDelivery(deliveryId);
251
+ var nextAttempts = ((current && current.attempts) || 0) + 1;
252
+ var willDlq = (nextAttempts >= MAX_ATTEMPTS);
253
+ var nextRetryAt = null;
254
+ if (!willDlq) {
255
+ var idx = nextAttempts - 1;
256
+ if (idx < BACKOFF_SCHEDULE_S.length) {
257
+ nextRetryAt = ts + (BACKOFF_SCHEDULE_S[idx] * 1000);
258
+ }
259
+ }
260
+ await query(
261
+ "UPDATE webhook_deliveries SET attempts = ?1, last_status = ?2, last_error = ?3, " +
262
+ "last_attempted_at = ?4, next_retry_at = ?5 WHERE id = ?6",
263
+ [nextAttempts, status || null, errMsg || ("http-" + status), ts, nextRetryAt, deliveryId],
264
+ );
265
+ if (willDlq) {
266
+ await _moveToDlq(deliveryId, endpointRow, eventType, payloadJson, nextAttempts,
267
+ status || null, errMsg || ("http-" + status), ts);
174
268
  }
175
269
  return await _getDelivery(deliveryId);
176
270
  }
177
271
 
272
+ async function _moveToDlq(deliveryId, endpointRow, eventType, payloadJson, attempts,
273
+ lastStatus, lastError, ts) {
274
+ // The original delivery row stays in webhook_deliveries (with
275
+ // attempts === MAX_ATTEMPTS, delivered_at NULL) so the
276
+ // per-endpoint feed still surfaces the failure. The DLQ row
277
+ // carries the full payload so replayFromDlq can re-queue
278
+ // without consulting the original delivery.
279
+ var dlqId = _b().uuid.v7();
280
+ var firstAttemptedAt;
281
+ var r = await query(
282
+ "SELECT created_at FROM webhook_deliveries WHERE id = ?1",
283
+ [deliveryId],
284
+ );
285
+ firstAttemptedAt = (r.rows[0] && r.rows[0].created_at) || ts;
286
+ await query(
287
+ "INSERT INTO webhook_dlq " +
288
+ "(id, endpoint_id, delivery_id, event_type, payload_json, attempts, " +
289
+ " last_status, last_error, first_attempted_at, last_attempted_at, dropped_at) " +
290
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
291
+ [dlqId, endpointRow.id, deliveryId, eventType, payloadJson, attempts,
292
+ lastStatus, lastError, firstAttemptedAt, ts, ts],
293
+ );
294
+ }
295
+
178
296
  async function _getDelivery(id) {
179
297
  var r = await query("SELECT * FROM webhook_deliveries WHERE id = ?1", [id]);
180
298
  return r.rows[0] || null;
@@ -196,10 +314,25 @@ function create(opts) {
196
314
  var id = _b().uuid.v7();
197
315
  var secret = _b().crypto.generateToken(SECRET_BYTES);
198
316
  var ts = _now();
317
+ // rate_limit_per_minute defaults to 60 at the schema level
318
+ // (see migration 0017). Operators wiring a slow receiver opt
319
+ // into a lower ceiling on create; raising it past the default
320
+ // is supported but undocumented — most receivers handle 60
321
+ // req/min comfortably.
322
+ var rate = 60;
323
+ if (Object.prototype.hasOwnProperty.call(input, "rate_limit_per_minute")) {
324
+ if (typeof input.rate_limit_per_minute !== "number" ||
325
+ !Number.isInteger(input.rate_limit_per_minute) ||
326
+ input.rate_limit_per_minute <= 0) {
327
+ throw new TypeError("webhooks.endpoints.create: rate_limit_per_minute must be a positive integer");
328
+ }
329
+ rate = input.rate_limit_per_minute;
330
+ }
199
331
  await query(
200
- "INSERT INTO webhook_endpoints (id, url, secret, events, active, created_at, updated_at) " +
201
- "VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5)",
202
- [id, input.url, secret, events, ts],
332
+ "INSERT INTO webhook_endpoints " +
333
+ "(id, url, secret, events, active, rate_limit_per_minute, created_at, updated_at) " +
334
+ "VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?6)",
335
+ [id, input.url, secret, events, rate, ts],
203
336
  );
204
337
  return await _getEndpoint(id);
205
338
  },
@@ -222,6 +355,7 @@ function create(opts) {
222
355
  var nextUrl = current.url;
223
356
  var nextEvents = current.events;
224
357
  var nextActive = current.active;
358
+ var nextRate = current.rate_limit_per_minute;
225
359
  if (Object.prototype.hasOwnProperty.call(patch, "url")) {
226
360
  _validateUrl(patch.url);
227
361
  nextUrl = patch.url;
@@ -235,10 +369,18 @@ function create(opts) {
235
369
  }
236
370
  nextActive = (patch.active === true || patch.active === 1) ? 1 : 0;
237
371
  }
372
+ if (Object.prototype.hasOwnProperty.call(patch, "rate_limit_per_minute")) {
373
+ var rate = patch.rate_limit_per_minute;
374
+ if (typeof rate !== "number" || !Number.isInteger(rate) || rate <= 0) {
375
+ throw new TypeError("webhooks.endpoints.update: rate_limit_per_minute must be a positive integer");
376
+ }
377
+ nextRate = rate;
378
+ }
238
379
  var ts = _now();
239
380
  await query(
240
- "UPDATE webhook_endpoints SET url = ?1, events = ?2, active = ?3, updated_at = ?4 WHERE id = ?5",
241
- [nextUrl, nextEvents, nextActive, ts, id],
381
+ "UPDATE webhook_endpoints SET url = ?1, events = ?2, active = ?3, " +
382
+ "rate_limit_per_minute = ?4, updated_at = ?5 WHERE id = ?6",
383
+ [nextUrl, nextEvents, nextActive, nextRate, ts, id],
242
384
  );
243
385
  return await _getEndpoint(id);
244
386
  },
@@ -272,6 +414,139 @@ function create(opts) {
272
414
  },
273
415
  },
274
416
 
417
+ dlq: {
418
+ list: async function (endpointId, listOpts) {
419
+ _uuid(endpointId, "endpoint id");
420
+ var limit = (listOpts && Number.isInteger(listOpts.limit) && listOpts.limit > 0) ? listOpts.limit : 50;
421
+ if (limit > 500) limit = 500;
422
+ var r = await query(
423
+ "SELECT * FROM webhook_dlq WHERE endpoint_id = ?1 ORDER BY dropped_at DESC LIMIT ?2",
424
+ [endpointId, limit],
425
+ );
426
+ return r.rows;
427
+ },
428
+
429
+ get: async function (id) {
430
+ _uuid(id, "dlq id");
431
+ var r = await query("SELECT * FROM webhook_dlq WHERE id = ?1", [id]);
432
+ return r.rows[0] || null;
433
+ },
434
+ },
435
+
436
+ // Re-queue a DLQ row as a fresh delivery on its original
437
+ // endpoint. The DLQ row is removed once the new delivery row is
438
+ // persisted — keeping both would leave duplicate state for the
439
+ // admin feed to reconcile. The new delivery starts at attempts=0
440
+ // so the full backoff schedule applies if the receiver is still
441
+ // unhappy.
442
+ replayFromDlq: async function (dlqId) {
443
+ _uuid(dlqId, "dlq id");
444
+ var r = await query("SELECT * FROM webhook_dlq WHERE id = ?1", [dlqId]);
445
+ var row = r.rows[0];
446
+ if (!row) return null;
447
+ var endpoint = await _getEndpoint(row.endpoint_id);
448
+ if (!endpoint) return null;
449
+ // Delete first — if the subsequent _deliver fails we don't
450
+ // want a permanent ghost; the new delivery row carries the
451
+ // failure state forward and (on five failures) lands back in
452
+ // the DLQ as a fresh row.
453
+ await query("DELETE FROM webhook_dlq WHERE id = ?1", [dlqId]);
454
+ return await _deliver(endpoint, row.event_type, row.payload_json);
455
+ },
456
+
457
+ // Polled scheduler entry point — picks deliveries whose
458
+ // next_retry_at has come due and re-attempts each. Operators
459
+ // wire this to a cron / Durable Object alarm / queue worker.
460
+ // Returns the list of deliveries the call attempted.
461
+ //
462
+ // No claim/lock here — the framework's single-writer assumption
463
+ // for the deliveries table holds (the worker is the only writer).
464
+ // A multi-writer deployment composes external locking before
465
+ // calling processRetries.
466
+ processRetries: async function (procOpts) {
467
+ var now = (procOpts && typeof procOpts.now === "number") ? procOpts.now : nowFn();
468
+ var max = (procOpts && Number.isInteger(procOpts.max) && procOpts.max > 0) ? procOpts.max : 100;
469
+ if (max > 1000) max = 1000;
470
+ var r = await query(
471
+ "SELECT * FROM webhook_deliveries " +
472
+ "WHERE next_retry_at IS NOT NULL AND next_retry_at <= ?1 AND delivered_at IS NULL " +
473
+ "ORDER BY next_retry_at ASC LIMIT ?2",
474
+ [now, max],
475
+ );
476
+ var attempted = [];
477
+ for (var i = 0; i < r.rows.length; i += 1) {
478
+ var row = r.rows[i];
479
+ var endpoint = await _getEndpoint(row.endpoint_id);
480
+ if (!endpoint) continue;
481
+ var d = await _attempt(row.id, endpoint, row.event_type, row.payload_json);
482
+ attempted.push(d);
483
+ }
484
+ return attempted;
485
+ },
486
+
487
+ // Inbound signature verification — operators receiving webhooks
488
+ // from peers (or replaying their own outbound for end-to-end
489
+ // testing) call this to validate the Stripe-shaped header
490
+ // `t=<unix>,v1=<hmac-sha256(secret, ts + "." + body)>` before
491
+ // trusting the payload. Delegates to b.webhook.verify — the
492
+ // framework's Stripe-compatible verifier handles tolerance,
493
+ // constant-time compare, and HMAC-SHA-256 computation.
494
+ //
495
+ // `toleranceSeconds` defaults to 300 (5 min) to match the
496
+ // framework default; the floor is 30 s — anything shorter risks
497
+ // false rejects from clock skew.
498
+ verifyIncoming: async function (payload, signatureHeader, secret, toleranceSeconds) {
499
+ if (payload == null) {
500
+ throw new TypeError("webhooks.verifyIncoming: payload required");
501
+ }
502
+ if (typeof signatureHeader !== "string" || signatureHeader.length === 0) {
503
+ throw new TypeError("webhooks.verifyIncoming: signatureHeader must be a non-empty string");
504
+ }
505
+ if ((typeof secret !== "string" || secret.length === 0) && !Buffer.isBuffer(secret)) {
506
+ throw new TypeError("webhooks.verifyIncoming: secret must be a non-empty string or Buffer");
507
+ }
508
+ var tolMs;
509
+ if (toleranceSeconds === undefined) {
510
+ tolMs = 300 * 1000;
511
+ } else {
512
+ if (typeof toleranceSeconds !== "number" || !isFinite(toleranceSeconds) || toleranceSeconds < 30) {
513
+ throw new TypeError("webhooks.verifyIncoming: toleranceSeconds must be >= 30");
514
+ }
515
+ tolMs = Math.floor(toleranceSeconds * 1000);
516
+ }
517
+ return await _b().webhook.verify({
518
+ alg: "hmac-sha256-stripe",
519
+ secret: secret,
520
+ header: signatureHeader,
521
+ body: payload,
522
+ toleranceMs: tolMs,
523
+ });
524
+ },
525
+
526
+ // Companion to verifyIncoming — produce the Stripe-shaped
527
+ // `t=<unix>,v1=<hex>` header for a given payload + secret. The
528
+ // round-trip is what end-to-end tests + downstream emitters
529
+ // need; operators forwarding events to peer systems use this to
530
+ // sign the relay body.
531
+ signOutgoing: function (payload, secret, timestampSeconds) {
532
+ if (payload == null) {
533
+ throw new TypeError("webhooks.signOutgoing: payload required");
534
+ }
535
+ if ((typeof secret !== "string" || secret.length === 0) && !Buffer.isBuffer(secret)) {
536
+ throw new TypeError("webhooks.signOutgoing: secret must be a non-empty string or Buffer");
537
+ }
538
+ var input = {
539
+ alg: "hmac-sha256-stripe",
540
+ secret: secret,
541
+ body: payload,
542
+ };
543
+ if (timestampSeconds !== undefined) input.timestamp = timestampSeconds;
544
+ return _b().webhook.sign(input);
545
+ },
546
+
547
+ BACKOFF_SCHEDULE_S: BACKOFF_SCHEDULE_S,
548
+ MAX_ATTEMPTS: MAX_ATTEMPTS,
549
+
275
550
  send: async function (eventType, payload) {
276
551
  if (typeof eventType !== "string" || eventType.length === 0) {
277
552
  throw new TypeError("webhooks.send: eventType must be a non-empty string");
@@ -300,6 +575,8 @@ function create(opts) {
300
575
  }
301
576
 
302
577
  module.exports = {
303
- create: create,
304
- KNOWN_EVENTS: KNOWN_EVENTS,
578
+ create: create,
579
+ KNOWN_EVENTS: KNOWN_EVENTS,
580
+ BACKOFF_SCHEDULE_S: BACKOFF_SCHEDULE_S,
581
+ MAX_ATTEMPTS: MAX_ATTEMPTS,
305
582
  };
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Wishlist — customers save products (or specific variants) for
3
+ * later. The storefront PDP renders an "Add to wishlist" toggle that
4
+ * resolves through `isWishlisted` + `add` / `remove`; the account
5
+ * page renders the customer's saved items via `listForCustomer`; and
6
+ * the "X people saved this" social-proof counter on the PDP reads
7
+ * `countForProduct`. The home page / category page surfaces a
8
+ * "Most-wishlisted" rail via `popularProducts`.
9
+ *
10
+ * Composes:
11
+ * - `b.guardUuid` — every customer / product / variant id is
12
+ * UUID-shape-validated at the entry point. The primitive itself
13
+ * never trusts an opaque string; the guard refuses on shape and
14
+ * surfaces a TypeError that the storefront translates to a 400.
15
+ * - `b.crypto.namespaceHash` — not used for row identity (the row
16
+ * id is a UUIDv7 from `b.uuid.v7`); reserved for forthcoming
17
+ * follower / shared-wishlist features that need a deterministic
18
+ * handle without leaking customer ids.
19
+ * - `b.uuid.v7` — row id.
20
+ * - `b.pagination.encodeCursor` / `decodeCursor` — HMAC-tagged
21
+ * opaque cursor on the `(created_at DESC, id DESC)` tuple so an
22
+ * operator can't hand-craft a cursor to skip past a hidden row
23
+ * or replay one across deployments.
24
+ *
25
+ * Surface:
26
+ * - `add({ customer_id, product_id, variant_id?, notes? })` —
27
+ * INSERT OR IGNORE on the unique (customer, product, variant)
28
+ * tuple; returns `{ id, status: "added" | "dedup" }`.
29
+ * - `remove({ customer_id, product_id, variant_id? })` — DELETE
30
+ * the matching row; returns `{ removed: boolean }`.
31
+ * - `listForCustomer(customer_id, { limit?, cursor? })` —
32
+ * paginated entries scoped to one customer; returns
33
+ * `{ rows, nextCursor }`.
34
+ * - `isWishlisted({ customer_id, product_id, variant_id? })` —
35
+ * boolean lookup used by the PDP toggle.
36
+ * - `countForProduct(product_id)` — how many customers wishlisted
37
+ * this product (variant-collapsed — counts distinct customers).
38
+ * - `popularProducts({ limit? })` — `[ { product_id, count } ]`
39
+ * sorted by count desc, used for the "Most-wishlisted" rail.
40
+ *
41
+ * Storage:
42
+ * - `wishlist_entries` (migration `0012_wishlist.sql`).
43
+ *
44
+ * @primitive wishlist
45
+ * @related b.guardUuid, b.pagination, b.uuid
46
+ */
47
+
48
+ "use strict";
49
+
50
+ var MAX_NOTES_LEN = 280;
51
+ var DEFAULT_LIMIT = 20;
52
+ var MAX_LIMIT = 100;
53
+ var POPULAR_LIMIT = 50;
54
+ var WISHLIST_ORDER_KEY = ["created_at:desc", "id:desc"];
55
+
56
+ // Lazy framework handle — matches the pattern used by the rest of
57
+ // the shop primitives; avoids the require cycle that would arise
58
+ // from importing `./index` at module-eval time.
59
+ var bShop;
60
+ function _b() {
61
+ if (!bShop) bShop = require("./index");
62
+ return bShop.framework;
63
+ }
64
+
65
+ function _uuid(s, label) {
66
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
67
+ catch (e) { throw new TypeError("wishlist: " + label + " — " + (e && e.message || "invalid UUID")); }
68
+ }
69
+
70
+ function _optUuid(s, label) {
71
+ if (s == null || s === "") return null;
72
+ return _uuid(s, label);
73
+ }
74
+
75
+ function _normalizeNotes(s) {
76
+ if (s == null) return "";
77
+ if (typeof s !== "string") {
78
+ throw new TypeError("wishlist: notes must be a string");
79
+ }
80
+ // Refuse control bytes (incl. CR/LF) so a malicious note can't
81
+ // smuggle header-injection-class content into operator dashboards
82
+ // or downstream email templates that render the note inline.
83
+ if (/[\x00-\x1f\x7f]/.test(s)) {
84
+ throw new TypeError("wishlist: notes must not contain control bytes");
85
+ }
86
+ if (s.length > MAX_NOTES_LEN) {
87
+ throw new TypeError("wishlist: notes must be <= " + MAX_NOTES_LEN + " chars");
88
+ }
89
+ return s;
90
+ }
91
+
92
+ function _limit(n, label) {
93
+ if (n == null) return DEFAULT_LIMIT;
94
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
95
+ throw new TypeError("wishlist: " + label + " must be 1..." + MAX_LIMIT);
96
+ }
97
+ return n;
98
+ }
99
+
100
+ function create(opts) {
101
+ opts = opts || {};
102
+ var query = opts.query;
103
+ if (!query) {
104
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
105
+ }
106
+ // Pagination cursors for listForCustomer are HMAC-tagged via
107
+ // b.pagination so an operator can't hand-craft one to skip past a
108
+ // hidden entry or replay across deployments. The secret defaults
109
+ // to a dev-only placeholder so the primitive boots in tests; the
110
+ // deployment is expected to supply a derived value (typically
111
+ // b.crypto.namespaceHash("wishlist-cursor", D1_BRIDGE_SECRET)).
112
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
113
+ if (process.env.NODE_ENV === "production") {
114
+ throw new Error("wishlist.create: opts.cursorSecret is required in production");
115
+ }
116
+ opts.cursorSecret = "wishlist-cursor-secret-dev-only";
117
+ }
118
+ var cursorSecret = opts.cursorSecret;
119
+
120
+ return {
121
+ MAX_NOTES_LEN: MAX_NOTES_LEN,
122
+
123
+ add: async function (input) {
124
+ if (!input || typeof input !== "object") {
125
+ throw new TypeError("wishlist.add: input object required");
126
+ }
127
+ var customerId = _uuid(input.customer_id, "customer_id");
128
+ var productId = _uuid(input.product_id, "product_id");
129
+ var variantId = _optUuid(input.variant_id, "variant_id");
130
+ var notes = _normalizeNotes(input.notes);
131
+ var now = Date.now();
132
+ var id = _b().uuid.v7();
133
+
134
+ // Idempotent insert — re-running with the same
135
+ // (customer, product, variant) tuple is a no-op on the storage
136
+ // side. The SELECT after the INSERT-OR-IGNORE tells us which
137
+ // one happened so the caller can distinguish "this just got
138
+ // added" from "we already had this entry" for UI copy.
139
+ await query(
140
+ "INSERT OR IGNORE INTO wishlist_entries " +
141
+ "(id, customer_id, product_id, variant_id, notes, created_at) " +
142
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
143
+ [id, customerId, productId, variantId, notes, now],
144
+ );
145
+ var existing = (await query(
146
+ "SELECT id FROM wishlist_entries " +
147
+ "WHERE customer_id = ?1 AND product_id = ?2 " +
148
+ "AND COALESCE(variant_id, '') = COALESCE(?3, '') LIMIT 1",
149
+ [customerId, productId, variantId],
150
+ )).rows[0];
151
+ var status = existing && existing.id === id ? "added" : "dedup";
152
+ return {
153
+ id: existing ? existing.id : id,
154
+ status: status,
155
+ };
156
+ },
157
+
158
+ remove: async function (input) {
159
+ if (!input || typeof input !== "object") {
160
+ throw new TypeError("wishlist.remove: input object required");
161
+ }
162
+ var customerId = _uuid(input.customer_id, "customer_id");
163
+ var productId = _uuid(input.product_id, "product_id");
164
+ var variantId = _optUuid(input.variant_id, "variant_id");
165
+ var r = await query(
166
+ "DELETE FROM wishlist_entries " +
167
+ "WHERE customer_id = ?1 AND product_id = ?2 " +
168
+ "AND COALESCE(variant_id, '') = COALESCE(?3, '')",
169
+ [customerId, productId, variantId],
170
+ );
171
+ return { removed: Number(r.rowCount || 0) > 0 };
172
+ },
173
+
174
+ listForCustomer: async function (customerId, listOpts) {
175
+ var cid = _uuid(customerId, "customer_id");
176
+ listOpts = listOpts || {};
177
+ var limit = _limit(listOpts.limit, "limit");
178
+ var cursorVals = null;
179
+ if (listOpts.cursor != null) {
180
+ if (typeof listOpts.cursor !== "string") {
181
+ throw new TypeError("wishlist.listForCustomer: cursor must be an opaque string or null");
182
+ }
183
+ try {
184
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
185
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(WISHLIST_ORDER_KEY)) {
186
+ throw new TypeError("wishlist.listForCustomer: cursor orderKey mismatch");
187
+ }
188
+ cursorVals = state.vals;
189
+ } catch (e) {
190
+ if (e instanceof TypeError) throw e;
191
+ throw new TypeError("wishlist.listForCustomer: cursor — " + (e && e.message || "malformed"));
192
+ }
193
+ }
194
+ var sql, params;
195
+ if (cursorVals) {
196
+ sql = "SELECT * FROM wishlist_entries WHERE customer_id = ?1 AND " +
197
+ "(created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
198
+ "ORDER BY created_at DESC, id DESC LIMIT ?4";
199
+ params = [cid, cursorVals[0], cursorVals[1], limit];
200
+ } else {
201
+ sql = "SELECT * FROM wishlist_entries WHERE customer_id = ?1 " +
202
+ "ORDER BY created_at DESC, id DESC LIMIT ?2";
203
+ params = [cid, limit];
204
+ }
205
+ var rows = (await query(sql, params)).rows;
206
+ var last = rows[rows.length - 1];
207
+ var nextCursor = null;
208
+ if (last && rows.length === limit) {
209
+ nextCursor = _b().pagination.encodeCursor({
210
+ orderKey: WISHLIST_ORDER_KEY,
211
+ vals: [last.created_at, last.id],
212
+ forward: true,
213
+ }, cursorSecret);
214
+ }
215
+ return { rows: rows, nextCursor: nextCursor };
216
+ },
217
+
218
+ isWishlisted: async function (input) {
219
+ if (!input || typeof input !== "object") {
220
+ throw new TypeError("wishlist.isWishlisted: input object required");
221
+ }
222
+ var customerId = _uuid(input.customer_id, "customer_id");
223
+ var productId = _uuid(input.product_id, "product_id");
224
+ var variantId = _optUuid(input.variant_id, "variant_id");
225
+ var r = await query(
226
+ "SELECT id FROM wishlist_entries " +
227
+ "WHERE customer_id = ?1 AND product_id = ?2 " +
228
+ "AND COALESCE(variant_id, '') = COALESCE(?3, '') LIMIT 1",
229
+ [customerId, productId, variantId],
230
+ );
231
+ return r.rows.length > 0;
232
+ },
233
+
234
+ countForProduct: async function (productId) {
235
+ var pid = _uuid(productId, "product_id");
236
+ var r = await query(
237
+ "SELECT COUNT(DISTINCT customer_id) AS n FROM wishlist_entries WHERE product_id = ?1",
238
+ [pid],
239
+ );
240
+ return Number((r.rows[0] || {}).n || 0);
241
+ },
242
+
243
+ popularProducts: async function (popOpts) {
244
+ popOpts = popOpts || {};
245
+ var limit = popOpts.limit == null ? POPULAR_LIMIT
246
+ : _limit(popOpts.limit, "limit");
247
+ var rows = (await query(
248
+ "SELECT product_id, COUNT(DISTINCT customer_id) AS count " +
249
+ "FROM wishlist_entries " +
250
+ "GROUP BY product_id " +
251
+ "ORDER BY count DESC, product_id ASC " +
252
+ "LIMIT ?1",
253
+ [limit],
254
+ )).rows;
255
+ var out = [];
256
+ for (var i = 0; i < rows.length; i += 1) {
257
+ out.push({
258
+ product_id: rows[i].product_id,
259
+ count: Number(rows[i].count),
260
+ });
261
+ }
262
+ return out;
263
+ },
264
+ };
265
+ }
266
+
267
+ module.exports = {
268
+ create: create,
269
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.0.53",
3
+ "version": "0.0.56",
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": {