@blamejs/blamejs-shop 0.0.72 → 0.0.75

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,806 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.bannerABTests
4
+ * @title Banner A/B tests — split-test promo banners with deterministic
5
+ * per-session assignment + impression/click/conversion ledger
6
+ *
7
+ * @intro
8
+ * Operators run head-to-head split tests on `promoBanners` variants
9
+ * without taking a dependency on the broader experiments framework.
10
+ * Each test references N banner slugs as its variants (each with a
11
+ * positive integer weight); a visitor's session id deterministically
12
+ * maps to one variant for the test's life, so the visitor never
13
+ * experiences a mid-funnel banner flip. The storefront calls
14
+ * `recordImpression` when the banner renders, `recordClick` when the
15
+ * visitor follows the CTA, and `recordConversion` when the downstream
16
+ * purchase / signup / target action happens; `metricsForTest` rolls
17
+ * the ledger up with Wilson 95% confidence intervals for click-
18
+ * through and conversion rates so the operator dashboard can spot a
19
+ * statistically meaningful winner.
20
+ *
21
+ * Surface:
22
+ * - defineTest({ slug, title, hypothesis, variants, starts_at,
23
+ * ends_at? })
24
+ * Create the test. `variants` is an ordered array of
25
+ * { banner_slug, weight } records — `banner_slug` is the slug
26
+ * of an existing `promoBanners` row when a `promoBanners`
27
+ * handle was passed to `create`; without the handle the
28
+ * primitive treats the slug as opaque and just persists it.
29
+ * Variants are write-once: changing them after defineTest
30
+ * would corrupt assignments for sessions already seen.
31
+ * Status starts at `running`.
32
+ *
33
+ * - getVariantForSession({ test_slug, session_id, now? })
34
+ * Returns { test_slug, variant_slug, banner_slug,
35
+ * session_id_hash } for the assigned variant, or null when
36
+ * the test is not currently reading traffic (status not
37
+ * `running`, or `now` is outside [starts_at, ends_at]). The
38
+ * session id is namespace-hashed (`banner-ab-tests-session`)
39
+ * before any storage or assignment work. Assignment is
40
+ * deterministic: same session id + same test slug → same
41
+ * variant 100% of the time, for the lifetime of the test.
42
+ *
43
+ * - recordImpression({ test_slug, session_id, now? })
44
+ * Append an impression event for the session's currently-
45
+ * assigned variant. Drop-silent on unknown / archived / not-
46
+ * running test (hot-path observability sink — throwing here
47
+ * would crash the storefront response that triggered the
48
+ * render).
49
+ *
50
+ * - recordClick({ test_slug, session_id, now? })
51
+ * Same shape as recordImpression but for the click step.
52
+ *
53
+ * - recordConversion({ test_slug, session_id, value?, now? })
54
+ * Same shape, optional `value` for revenue attribution
55
+ * (non-negative integer).
56
+ *
57
+ * - metricsForTest({ test_slug, until? })
58
+ * Per-variant impression / click / conversion counts +
59
+ * distinct-session counts + Wilson 95% CI for CTR
60
+ * (clicks/impressions) and conversion rate
61
+ * (conversions/impressions). `until` defaults to `now`.
62
+ *
63
+ * - pauseTest(slug) / resumeTest(slug) / concludeTest(slug, opts?)
64
+ * / archiveTest(slug)
65
+ * FSM transitions:
66
+ * running -> paused (pauseTest)
67
+ * paused -> running (resumeTest)
68
+ * running -> concluded (concludeTest, opts.winner?)
69
+ * paused -> concluded (concludeTest)
70
+ * concluded -> archived (archiveTest, terminal)
71
+ * `concludeTest` accepts an optional `winner` (one of the
72
+ * test's variant banner slugs) to record the operator's
73
+ * declared winner. Resuming a concluded test is refused —
74
+ * operators define a new test if they want to re-run.
75
+ *
76
+ * - listTests({ status?, limit?, cursor? })
77
+ * Enumerate tests, optionally filtered by status.
78
+ *
79
+ * Composition:
80
+ * - b.crypto.namespaceHash — session id is hashed with namespace
81
+ * `banner-ab-tests-session` before any storage or assignment-
82
+ * hashing work. Assignment uses
83
+ * `namespaceHash("banner-ab-tests-assign", slug + ":" +
84
+ * sessionHash)` to derive a 64-bit integer modulo the
85
+ * cumulative weight.
86
+ * - b.uuid.v7 — every banner_ab_test_events row carries a v7 id
87
+ * so rows sort lexicographically by insertion time.
88
+ *
89
+ * Storage:
90
+ * - `banner_ab_tests` + `banner_ab_test_events`
91
+ * (migration `0174_banner_ab_tests.sql`).
92
+ *
93
+ * @primitive bannerABTests
94
+ * @related b.crypto.namespaceHash, b.uuid.v7, promoBanners
95
+ */
96
+
97
+ var MAX_SLUG_LEN = 80;
98
+ var MAX_TITLE_LEN = 200;
99
+ var MAX_HYPOTHESIS_LEN = 2000;
100
+ var MAX_VARIANTS = 16;
101
+ var MAX_WEIGHT = 1000000;
102
+ var MAX_VALUE = 1e12;
103
+ var DEFAULT_LIMIT = 50;
104
+ var MAX_LIMIT = 500;
105
+
106
+ var SESSION_NAMESPACE = "banner-ab-tests-session";
107
+ var ASSIGN_NAMESPACE = "banner-ab-tests-assign";
108
+
109
+ var ALLOWED_STATUSES = Object.freeze(["running", "paused", "concluded", "archived"]);
110
+ var ALLOWED_EVENT_KINDS = Object.freeze(["impression", "click", "conversion"]);
111
+
112
+ // FSM transition graph. Mirrors the migration header comment.
113
+ // Archived is terminal — no outbound edges.
114
+ var TRANSITIONS = Object.freeze({
115
+ running: { pause: "paused", resume: null, conclude: "concluded", archive: null },
116
+ paused: { pause: null, resume: "running", conclude: "concluded", archive: null },
117
+ concluded: { pause: null, resume: null, conclude: null, archive: "archived"},
118
+ archived: { pause: null, resume: null, conclude: null, archive: null },
119
+ });
120
+
121
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
122
+
123
+ // Refuse C0 control bytes + DEL in operator-authored strings. The
124
+ // title / hypothesis fields land in the operator dashboard, not the
125
+ // storefront — but the discipline is the same: strings reach the UI
126
+ // as inert text, never as live markup.
127
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
128
+ var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
129
+
130
+ // Zero-width / direction-override family — mirrors the promo-banners
131
+ // + experiments catalogues. Spelled with \u-escapes so ESLint's
132
+ // no-irregular-whitespace stays happy.
133
+ var ZERO_WIDTH_RE = new RegExp(
134
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
135
+ );
136
+
137
+ var bShop;
138
+ function _b() {
139
+ if (!bShop) bShop = require("./index");
140
+ return bShop.framework;
141
+ }
142
+
143
+ // ---- monotonic clock ----------------------------------------------------
144
+ //
145
+ // Tests + events persist epoch-ms timestamps. Operators occasionally
146
+ // backfill impressions / conversions (importing from a third-party
147
+ // analytics tool). The strict-monotonic clock here guarantees that two
148
+ // same-millisecond `_now()` calls produce distinct integers so the
149
+ // row-ordering on `occurred_at` is deterministic without an extra
150
+ // tiebreaker column. Tests that fan-out impressions in tight loops
151
+ // rely on this for ordering assertions.
152
+ var _lastTs = 0;
153
+ function _now() {
154
+ var t = Date.now();
155
+ if (t <= _lastTs) { t = _lastTs + 1; }
156
+ _lastTs = t;
157
+ return t;
158
+ }
159
+
160
+ // ---- validators ---------------------------------------------------------
161
+
162
+ function _slug(s, label) {
163
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
164
+ throw new TypeError("bannerABTests: " + (label || "slug") +
165
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
166
+ }
167
+ return s;
168
+ }
169
+
170
+ function _line(s, label, maxLen) {
171
+ if (typeof s !== "string" || !s.length || s.length > maxLen) {
172
+ throw new TypeError("bannerABTests: " + label + " must be a non-empty string <= " + maxLen + " chars");
173
+ }
174
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
175
+ throw new TypeError("bannerABTests: " + label + " contains control bytes (incl. CR/LF)");
176
+ }
177
+ if (ZERO_WIDTH_RE.test(s)) {
178
+ throw new TypeError("bannerABTests: " + label + " contains zero-width / direction-override characters");
179
+ }
180
+ return s;
181
+ }
182
+
183
+ function _block(s, label, maxLen) {
184
+ if (typeof s !== "string" || !s.length || s.length > maxLen) {
185
+ throw new TypeError("bannerABTests: " + label + " must be a non-empty string <= " + maxLen + " chars");
186
+ }
187
+ if (CONTROL_BYTE_BLOCK_RE.test(s)) {
188
+ throw new TypeError("bannerABTests: " + label + " contains control bytes");
189
+ }
190
+ if (ZERO_WIDTH_RE.test(s)) {
191
+ throw new TypeError("bannerABTests: " + label + " contains zero-width / direction-override characters");
192
+ }
193
+ return s;
194
+ }
195
+
196
+ function _status(s) {
197
+ if (typeof s !== "string" || ALLOWED_STATUSES.indexOf(s) === -1) {
198
+ throw new TypeError("bannerABTests: status must be one of " + JSON.stringify(ALLOWED_STATUSES));
199
+ }
200
+ return s;
201
+ }
202
+
203
+ function _epochMs(n, label) {
204
+ if (!Number.isInteger(n) || n < 0) {
205
+ throw new TypeError("bannerABTests: " + label + " must be a non-negative integer (epoch ms)");
206
+ }
207
+ return n;
208
+ }
209
+
210
+ function _epochOpt(n, label) {
211
+ if (n == null) return null;
212
+ return _epochMs(n, label);
213
+ }
214
+
215
+ function _limit(n) {
216
+ if (n == null) return DEFAULT_LIMIT;
217
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
218
+ throw new TypeError("bannerABTests: limit must be an integer in [1, " + MAX_LIMIT + "]");
219
+ }
220
+ return n;
221
+ }
222
+
223
+ function _sessionId(s) {
224
+ if (typeof s !== "string" || !s.length) {
225
+ throw new TypeError("bannerABTests: session_id must be a non-empty string");
226
+ }
227
+ return s;
228
+ }
229
+
230
+ function _variants(arr) {
231
+ if (!Array.isArray(arr) || arr.length < 2) {
232
+ throw new TypeError("bannerABTests: variants must be an array of at least 2 { banner_slug, weight } records");
233
+ }
234
+ if (arr.length > MAX_VARIANTS) {
235
+ throw new TypeError("bannerABTests: variants must be <= " + MAX_VARIANTS + " records");
236
+ }
237
+ var seen = Object.create(null);
238
+ var out = [];
239
+ for (var i = 0; i < arr.length; i += 1) {
240
+ var v = arr[i];
241
+ if (!v || typeof v !== "object") {
242
+ throw new TypeError("bannerABTests: variants[" + i + "] must be an object");
243
+ }
244
+ var vs = _slug(v.banner_slug, "variants[" + i + "].banner_slug");
245
+ if (seen[vs]) {
246
+ throw new TypeError("bannerABTests: variants[" + i + "].banner_slug duplicates an earlier variant");
247
+ }
248
+ seen[vs] = true;
249
+ if (!Number.isInteger(v.weight) || v.weight < 1 || v.weight > MAX_WEIGHT) {
250
+ throw new TypeError("bannerABTests: variants[" + i + "].weight must be an integer in [1, " +
251
+ MAX_WEIGHT + "]");
252
+ }
253
+ out.push({ banner_slug: vs, weight: v.weight });
254
+ }
255
+ return out;
256
+ }
257
+
258
+ // ---- assignment math ----------------------------------------------------
259
+ //
260
+ // The 64-bit modulus is computed from the first 16 hex chars (64
261
+ // bits) of the SHA3-512 hex output. JavaScript numbers are 53-bit
262
+ // safe, so the modulus is taken in two 32-bit halves to stay inside
263
+ // integer-arithmetic territory. Mathematically this is identical to
264
+ // taking the modulus of the full 64-bit unsigned integer.
265
+ function _modCumulativeWeight(sessionHashHex, cumulativeWeight) {
266
+ var high = parseInt(sessionHashHex.slice(0, 8), 16);
267
+ var low = parseInt(sessionHashHex.slice(8, 16), 16);
268
+ var cw = cumulativeWeight;
269
+ var twoToThe32ModCw = 4294967296 % cw;
270
+ return ((high % cw) * twoToThe32ModCw + low) % cw;
271
+ }
272
+
273
+ // ---- Wilson score interval ---------------------------------------------
274
+ //
275
+ // Two-sided 95% confidence interval for a Bernoulli proportion. The
276
+ // Wilson interval is well-behaved at the extremes (0% / 100%) and
277
+ // for small sample sizes — strictly preferable to the normal-
278
+ // approximation interval that newcomers reach for. Returns
279
+ // { lower, upper } with both bounds clamped into [0, 1].
280
+ var Z_95 = 1.959963984540054;
281
+
282
+ function _wilsonCi(successes, trials) {
283
+ if (trials <= 0) return { lower: 0, upper: 0 };
284
+ var z = Z_95;
285
+ var n = trials;
286
+ var p = successes / n;
287
+ var z2 = z * z;
288
+ var denom = 1 + z2 / n;
289
+ var center = (p + z2 / (2 * n)) / denom;
290
+ var half = (z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n)) / denom;
291
+ var lower = center - half;
292
+ var upper = center + half;
293
+ if (lower < 0) lower = 0;
294
+ if (upper > 1) upper = 1;
295
+ return { lower: lower, upper: upper };
296
+ }
297
+
298
+ // ---- row hydration ------------------------------------------------------
299
+
300
+ function _hydrateRow(r) {
301
+ if (!r) return null;
302
+ var variants;
303
+ try { variants = JSON.parse(r.variants_json); }
304
+ catch (_e) { variants = []; }
305
+ return {
306
+ slug: r.slug,
307
+ title: r.title,
308
+ hypothesis: r.hypothesis,
309
+ variants: variants,
310
+ status: r.status,
311
+ starts_at: Number(r.starts_at),
312
+ ends_at: r.ends_at == null ? null : Number(r.ends_at),
313
+ concluded_variant_slug: r.concluded_variant_slug == null ? null : r.concluded_variant_slug,
314
+ paused_at: r.paused_at == null ? null : Number(r.paused_at),
315
+ concluded_at: r.concluded_at == null ? null : Number(r.concluded_at),
316
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
317
+ created_at: Number(r.created_at),
318
+ updated_at: Number(r.updated_at),
319
+ };
320
+ }
321
+
322
+ // ---- factory ------------------------------------------------------------
323
+
324
+ function create(opts) {
325
+ opts = opts || {};
326
+ var query = opts.query;
327
+ if (!query) {
328
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
329
+ }
330
+ // Optional `promoBanners` handle. When supplied, defineTest verifies
331
+ // each variant's banner_slug references an existing, non-archived
332
+ // banner row before persisting. Without the handle the primitive
333
+ // treats banner_slug as opaque (so a test harness or a deployment
334
+ // that hasn't wired promoBanners can still drive the primitive).
335
+ var promo = opts.promoBanners || null;
336
+
337
+ function _hashSession(sessionId) {
338
+ return _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
339
+ }
340
+
341
+ function _assignVariant(testSlug, sessionHash, variants) {
342
+ var cw = 0;
343
+ for (var i = 0; i < variants.length; i += 1) cw += variants[i].weight;
344
+ var keyHash = _b().crypto.namespaceHash(ASSIGN_NAMESPACE, testSlug + ":" + sessionHash);
345
+ var bucket = _modCumulativeWeight(keyHash, cw);
346
+ var acc = 0;
347
+ for (var j = 0; j < variants.length; j += 1) {
348
+ acc += variants[j].weight;
349
+ if (bucket < acc) return variants[j];
350
+ }
351
+ // Defensive fallback — unreachable since bucket < cw = sum(weights).
352
+ return variants[variants.length - 1];
353
+ }
354
+
355
+ // ---- defineTest -------------------------------------------------------
356
+
357
+ async function defineTest(input) {
358
+ if (!input || typeof input !== "object") {
359
+ throw new TypeError("bannerABTests.defineTest: input object required");
360
+ }
361
+ var slug = _slug(input.slug, "slug");
362
+ var title = _line(input.title, "title", MAX_TITLE_LEN);
363
+ var hypothesis = _block(input.hypothesis, "hypothesis", MAX_HYPOTHESIS_LEN);
364
+ var variants = _variants(input.variants);
365
+ var startsAt = _epochMs(input.starts_at, "starts_at");
366
+ var endsAt = null;
367
+ if (input.ends_at != null) {
368
+ endsAt = _epochMs(input.ends_at, "ends_at");
369
+ if (endsAt <= startsAt) {
370
+ throw new TypeError("bannerABTests.defineTest: ends_at must be strictly greater than starts_at");
371
+ }
372
+ }
373
+
374
+ // Verify every variant references a real, non-archived banner
375
+ // when a promoBanners handle was supplied. Without the handle the
376
+ // primitive trusts the operator's banner_slug values (the schema
377
+ // doesn't FK into promo_banners because banner slugs evolve at a
378
+ // different pace than the test catalogue).
379
+ if (promo && typeof promo.getBanner === "function") {
380
+ for (var i = 0; i < variants.length; i += 1) {
381
+ var banner = await promo.getBanner(variants[i].banner_slug);
382
+ if (!banner) {
383
+ throw new TypeError("bannerABTests.defineTest: banner " +
384
+ JSON.stringify(variants[i].banner_slug) + " not found");
385
+ }
386
+ if (banner.archived_at != null) {
387
+ throw new TypeError("bannerABTests.defineTest: banner " +
388
+ JSON.stringify(variants[i].banner_slug) + " is archived");
389
+ }
390
+ }
391
+ }
392
+
393
+ var existing = await getTest(slug);
394
+ if (existing) {
395
+ throw new TypeError("bannerABTests.defineTest: slug " + JSON.stringify(slug) + " already defined");
396
+ }
397
+
398
+ var ts = _now();
399
+ await query(
400
+ "INSERT INTO banner_ab_tests " +
401
+ "(slug, title, hypothesis, variants_json, status, starts_at, ends_at, " +
402
+ " concluded_variant_slug, paused_at, concluded_at, archived_at, created_at, updated_at) " +
403
+ "VALUES (?1, ?2, ?3, ?4, 'running', ?5, ?6, NULL, NULL, NULL, NULL, ?7, ?7)",
404
+ [slug, title, hypothesis, JSON.stringify(variants), startsAt, endsAt, ts],
405
+ );
406
+ return await getTest(slug);
407
+ }
408
+
409
+ // ---- getTest / listTests ---------------------------------------------
410
+
411
+ async function getTest(slug) {
412
+ _slug(slug, "slug");
413
+ var r = (await query(
414
+ "SELECT * FROM banner_ab_tests WHERE slug = ?1 LIMIT 1",
415
+ [slug],
416
+ )).rows[0];
417
+ return _hydrateRow(r);
418
+ }
419
+
420
+ async function listTests(listOpts) {
421
+ listOpts = listOpts || {};
422
+ var limit = _limit(listOpts.limit);
423
+ var cursor = listOpts.cursor;
424
+ if (cursor != null && (typeof cursor !== "string" || !cursor.length)) {
425
+ throw new TypeError("bannerABTests.listTests: cursor must be a non-empty string when provided");
426
+ }
427
+ var sql, params, idx;
428
+ if (listOpts.status != null) {
429
+ _status(listOpts.status);
430
+ sql = "SELECT * FROM banner_ab_tests WHERE status = ?1";
431
+ params = [listOpts.status];
432
+ idx = 2;
433
+ } else {
434
+ sql = "SELECT * FROM banner_ab_tests WHERE 1=1";
435
+ params = [];
436
+ idx = 1;
437
+ }
438
+ if (cursor != null) {
439
+ // Cursor is the last-seen test slug. Sort is (created_at DESC,
440
+ // slug DESC) — the cursor predicate collapses to `slug < cursor`
441
+ // because slugs are unique. The created_at tiebreaker still
442
+ // governs the sort order itself.
443
+ sql += " AND slug < ?" + idx;
444
+ params.push(cursor);
445
+ idx += 1;
446
+ }
447
+ sql += " ORDER BY created_at DESC, slug DESC LIMIT ?" + idx;
448
+ params.push(limit);
449
+
450
+ var rows = (await query(sql, params)).rows.map(_hydrateRow);
451
+ var nextCursor = rows.length === limit ? rows[rows.length - 1].slug : null;
452
+ return { rows: rows, next_cursor: nextCursor };
453
+ }
454
+
455
+ // ---- getVariantForSession --------------------------------------------
456
+
457
+ async function getVariantForSession(input) {
458
+ if (!input || typeof input !== "object") {
459
+ throw new TypeError("bannerABTests.getVariantForSession: input object required");
460
+ }
461
+ _slug(input.test_slug, "test_slug");
462
+ _sessionId(input.session_id);
463
+ var nowTs = input.now != null ? _epochMs(input.now, "now") : _now();
464
+
465
+ var test = await getTest(input.test_slug);
466
+ if (!test) return null;
467
+ if (test.status !== "running") return null;
468
+ if (nowTs < test.starts_at) return null;
469
+ if (test.ends_at != null && nowTs >= test.ends_at) return null;
470
+
471
+ var sessionHash = _hashSession(input.session_id);
472
+ var v = _assignVariant(test.slug, sessionHash, test.variants);
473
+ return {
474
+ test_slug: test.slug,
475
+ variant_slug: v.banner_slug,
476
+ banner_slug: v.banner_slug,
477
+ session_id_hash: sessionHash,
478
+ };
479
+ }
480
+
481
+ // ---- recordImpression / recordClick / recordConversion ----------------
482
+ //
483
+ // Drop-silent on unknown / archived / not-running test. These run
484
+ // on the hot storefront path; throwing here would crash the
485
+ // request that observed the event. The validation layer at
486
+ // defineTest has already verified that legitimate slugs exist;
487
+ // a request that arrives with a stale slug after the test was
488
+ // archived simply doesn't record, which is the correct
489
+ // observability behavior.
490
+
491
+ async function _record(eventKind, input) {
492
+ if (!input || typeof input !== "object") return { recorded: false };
493
+ if (typeof input.test_slug !== "string" || !SLUG_RE.test(input.test_slug)) {
494
+ return { recorded: false };
495
+ }
496
+ if (typeof input.session_id !== "string" || !input.session_id.length) {
497
+ return { recorded: false };
498
+ }
499
+ var value = 0;
500
+ if (input.value != null) {
501
+ if (!Number.isInteger(input.value) || input.value < 0 || input.value > MAX_VALUE) {
502
+ return { recorded: false };
503
+ }
504
+ value = input.value;
505
+ }
506
+ if (input.now != null && (!Number.isInteger(input.now) || input.now < 0)) {
507
+ return { recorded: false };
508
+ }
509
+ try {
510
+ var test = await getTest(input.test_slug);
511
+ if (!test) return { recorded: false };
512
+ if (test.status === "archived" || test.status === "concluded") return { recorded: false };
513
+ if (test.status === "paused") return { recorded: false };
514
+ // status is "running" — also gate on the time window so a
515
+ // request that arrives outside [starts_at, ends_at] doesn't
516
+ // pollute the ledger with off-window events.
517
+ var nowTs = input.now != null ? input.now : _now();
518
+ if (nowTs < test.starts_at) return { recorded: false };
519
+ if (test.ends_at != null && nowTs >= test.ends_at) return { recorded: false };
520
+
521
+ var sessionHash = _hashSession(input.session_id);
522
+ var variant = _assignVariant(test.slug, sessionHash, test.variants);
523
+ var id = _b().uuid.v7();
524
+ await query(
525
+ "INSERT INTO banner_ab_test_events " +
526
+ "(id, test_slug, variant_slug, session_id_hash, event_kind, value, occurred_at) " +
527
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
528
+ [id, test.slug, variant.banner_slug, sessionHash, eventKind, value, nowTs],
529
+ );
530
+ return {
531
+ recorded: true,
532
+ id: id,
533
+ variant_slug: variant.banner_slug,
534
+ occurred_at: nowTs,
535
+ };
536
+ } catch (_e) {
537
+ return { recorded: false };
538
+ }
539
+ }
540
+
541
+ function recordImpression(input) { return _record("impression", input); }
542
+ function recordClick(input) { return _record("click", input); }
543
+ function recordConversion(input) { return _record("conversion", input); }
544
+
545
+ // ---- metricsForTest ---------------------------------------------------
546
+
547
+ async function metricsForTest(input) {
548
+ if (!input || typeof input !== "object") {
549
+ throw new TypeError("bannerABTests.metricsForTest: input object required");
550
+ }
551
+ _slug(input.test_slug, "test_slug");
552
+ var until = input.until != null ? _epochMs(input.until, "until") : _now();
553
+
554
+ var test = await getTest(input.test_slug);
555
+ if (!test) {
556
+ throw new TypeError("bannerABTests.metricsForTest: test " +
557
+ JSON.stringify(input.test_slug) + " not found");
558
+ }
559
+
560
+ // Per-variant aggregate counts + distinct-session counts in
561
+ // [test.starts_at, until]. The rollup is in SQL so the metrics
562
+ // report stays efficient as the events table grows. We pull
563
+ // counts per (variant, event_kind) and distinct-session counts
564
+ // per (variant, event_kind) in two queries; combining via SQL
565
+ // CASE would be a single query but the indexes line up cleaner
566
+ // this way.
567
+ var countRows = (await query(
568
+ "SELECT variant_slug, event_kind, COUNT(*) AS n, COALESCE(SUM(value), 0) AS value_sum " +
569
+ "FROM banner_ab_test_events " +
570
+ "WHERE test_slug = ?1 AND occurred_at <= ?2 " +
571
+ "GROUP BY variant_slug, event_kind",
572
+ [test.slug, until],
573
+ )).rows;
574
+ var distinctRows = (await query(
575
+ "SELECT variant_slug, event_kind, COUNT(DISTINCT session_id_hash) AS n " +
576
+ "FROM banner_ab_test_events " +
577
+ "WHERE test_slug = ?1 AND occurred_at <= ?2 " +
578
+ "GROUP BY variant_slug, event_kind",
579
+ [test.slug, until],
580
+ )).rows;
581
+
582
+ var byVariant = Object.create(null);
583
+ for (var v = 0; v < test.variants.length; v += 1) {
584
+ byVariant[test.variants[v].banner_slug] = {
585
+ variant_slug: test.variants[v].banner_slug,
586
+ weight: test.variants[v].weight,
587
+ impressions: 0,
588
+ clicks: 0,
589
+ conversions: 0,
590
+ conversion_value: 0,
591
+ distinct_impression_sessions: 0,
592
+ distinct_click_sessions: 0,
593
+ distinct_conversion_sessions: 0,
594
+ };
595
+ }
596
+ for (var i = 0; i < countRows.length; i += 1) {
597
+ var cr = countRows[i];
598
+ var b = byVariant[cr.variant_slug];
599
+ if (!b) continue;
600
+ var n = Number(cr.n);
601
+ if (cr.event_kind === "impression") b.impressions = n;
602
+ else if (cr.event_kind === "click") b.clicks = n;
603
+ else b.conversions = n;
604
+ if (cr.event_kind === "conversion") b.conversion_value = Number(cr.value_sum);
605
+ }
606
+ for (var d = 0; d < distinctRows.length; d += 1) {
607
+ var dr = distinctRows[d];
608
+ var db = byVariant[dr.variant_slug];
609
+ if (!db) continue;
610
+ var dn = Number(dr.n);
611
+ if (dr.event_kind === "impression") db.distinct_impression_sessions = dn;
612
+ else if (dr.event_kind === "click") db.distinct_click_sessions = dn;
613
+ else db.distinct_conversion_sessions = dn;
614
+ }
615
+
616
+ // Compute Wilson 95% CIs for CTR + conversion rate over the
617
+ // impression denominator. Click-through rate is clicks /
618
+ // impressions; conversion rate is conversions / impressions. The
619
+ // Wilson interval clamps gracefully when impressions = 0.
620
+ var out = [];
621
+ var variantSlugs = Object.keys(byVariant);
622
+ for (var k = 0; k < variantSlugs.length; k += 1) {
623
+ var entry = byVariant[variantSlugs[k]];
624
+ var ctrCi = _wilsonCi(entry.clicks, entry.impressions);
625
+ var convCi = _wilsonCi(entry.conversions, entry.impressions);
626
+ entry.ctr = entry.impressions > 0 ? entry.clicks / entry.impressions : 0;
627
+ entry.ctr_ci95_lower = ctrCi.lower;
628
+ entry.ctr_ci95_upper = ctrCi.upper;
629
+ entry.conversion_rate = entry.impressions > 0 ? entry.conversions / entry.impressions : 0;
630
+ entry.conversion_ci95_lower = convCi.lower;
631
+ entry.conversion_ci95_upper = convCi.upper;
632
+ out.push(entry);
633
+ }
634
+
635
+ // Preserve the operator-defined variant order in the output.
636
+ var ordered = [];
637
+ for (var o = 0; o < test.variants.length; o += 1) {
638
+ ordered.push(byVariant[test.variants[o].banner_slug]);
639
+ }
640
+
641
+ return {
642
+ test_slug: test.slug,
643
+ until: until,
644
+ status: test.status,
645
+ variants: ordered,
646
+ };
647
+ }
648
+
649
+ // ---- FSM transitions -------------------------------------------------
650
+
651
+ async function _transition(slug, event, transitionOpts) {
652
+ _slug(slug, "slug");
653
+ var existing = await getTest(slug);
654
+ if (!existing) {
655
+ throw new TypeError("bannerABTests." + event + ": slug " + JSON.stringify(slug) + " not found");
656
+ }
657
+ var allowed = TRANSITIONS[existing.status];
658
+ var next = allowed && allowed[event];
659
+ if (!next) {
660
+ var err = new TypeError("bannerABTests." + event + ": cannot " + event +
661
+ " a test in status " + JSON.stringify(existing.status));
662
+ err.code = "BANNER_AB_TEST_INVALID_TRANSITION";
663
+ throw err;
664
+ }
665
+ var ts = _now();
666
+ var sets = ["status = ?1", "updated_at = ?2"];
667
+ var params = [next, ts];
668
+ var idx = 3;
669
+ if (next === "paused") {
670
+ sets.push("paused_at = ?" + idx);
671
+ params.push(ts);
672
+ idx += 1;
673
+ } else if (next === "running") {
674
+ // Clear paused_at on resume so the column reflects the most
675
+ // recent pause window only.
676
+ sets.push("paused_at = NULL");
677
+ } else if (next === "concluded") {
678
+ sets.push("concluded_at = ?" + idx);
679
+ params.push(ts);
680
+ idx += 1;
681
+ var winner = transitionOpts && transitionOpts.winner;
682
+ if (winner != null) {
683
+ _slug(winner, "winner");
684
+ var inSet = false;
685
+ for (var i = 0; i < existing.variants.length; i += 1) {
686
+ if (existing.variants[i].banner_slug === winner) { inSet = true; break; }
687
+ }
688
+ if (!inSet) {
689
+ throw new TypeError("bannerABTests.concludeTest: winner " +
690
+ JSON.stringify(winner) + " is not one of the test's variant banner slugs");
691
+ }
692
+ sets.push("concluded_variant_slug = ?" + idx);
693
+ params.push(winner);
694
+ idx += 1;
695
+ }
696
+ } else if (next === "archived") {
697
+ sets.push("archived_at = ?" + idx);
698
+ params.push(ts);
699
+ idx += 1;
700
+ }
701
+ params.push(slug);
702
+ await query(
703
+ "UPDATE banner_ab_tests SET " + sets.join(", ") + " WHERE slug = ?" + idx,
704
+ params,
705
+ );
706
+ return await getTest(slug);
707
+ }
708
+
709
+ function pauseTest(slug) { return _transition(slug, "pause"); }
710
+ function resumeTest(slug) { return _transition(slug, "resume"); }
711
+ async function concludeTest(slug, concludeOpts) {
712
+ if (concludeOpts != null && typeof concludeOpts !== "object") {
713
+ throw new TypeError("bannerABTests.concludeTest: opts must be an object when provided");
714
+ }
715
+ return await _transition(slug, "conclude", concludeOpts || {});
716
+ }
717
+ function archiveTest(slug) { return _transition(slug, "archive"); }
718
+
719
+ // ---- eventsForTest (audit helper, supports pagination) ---------------
720
+
721
+ async function eventsForTest(input) {
722
+ if (!input || typeof input !== "object") {
723
+ throw new TypeError("bannerABTests.eventsForTest: input object required");
724
+ }
725
+ _slug(input.test_slug, "test_slug");
726
+ var from = _epochOpt(input.from, "from");
727
+ var to = _epochOpt(input.to, "to");
728
+ if (from != null && to != null && from > to) {
729
+ throw new TypeError("bannerABTests.eventsForTest: from must be <= to");
730
+ }
731
+ var limit = _limit(input.limit);
732
+ var cursor = input.cursor;
733
+ if (cursor != null && (typeof cursor !== "string" || !cursor.length)) {
734
+ throw new TypeError("bannerABTests.eventsForTest: cursor must be a non-empty string when provided");
735
+ }
736
+
737
+ var sql = "SELECT * FROM banner_ab_test_events WHERE test_slug = ?1";
738
+ var params = [input.test_slug];
739
+ var idx = 2;
740
+ if (from != null) {
741
+ sql += " AND occurred_at >= ?" + idx; params.push(from); idx += 1;
742
+ }
743
+ if (to != null) {
744
+ sql += " AND occurred_at <= ?" + idx; params.push(to); idx += 1;
745
+ }
746
+ if (cursor != null) {
747
+ // The v7 id encodes occurred_at in its prefix, so an `id <
748
+ // cursor` predicate collapses to "older than cursor" while
749
+ // matching the (occurred_at DESC, id DESC) sort order.
750
+ sql += " AND id < ?" + idx; params.push(cursor); idx += 1;
751
+ }
752
+ sql += " ORDER BY id DESC LIMIT ?" + idx;
753
+ params.push(limit);
754
+
755
+ var rows = (await query(sql, params)).rows.map(function (r) {
756
+ return {
757
+ id: r.id,
758
+ test_slug: r.test_slug,
759
+ variant_slug: r.variant_slug,
760
+ session_id_hash: r.session_id_hash,
761
+ event_kind: r.event_kind,
762
+ value: Number(r.value),
763
+ occurred_at: Number(r.occurred_at),
764
+ };
765
+ });
766
+ var nextCursor = rows.length === limit ? rows[rows.length - 1].id : null;
767
+ return { rows: rows, next_cursor: nextCursor };
768
+ }
769
+
770
+ return {
771
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
772
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
773
+ MAX_HYPOTHESIS_LEN: MAX_HYPOTHESIS_LEN,
774
+ MAX_VARIANTS: MAX_VARIANTS,
775
+ MAX_WEIGHT: MAX_WEIGHT,
776
+ ALLOWED_STATUSES: ALLOWED_STATUSES.slice(),
777
+ ALLOWED_EVENT_KINDS: ALLOWED_EVENT_KINDS.slice(),
778
+ TRANSITIONS: TRANSITIONS,
779
+
780
+ defineTest: defineTest,
781
+ getTest: getTest,
782
+ listTests: listTests,
783
+ getVariantForSession: getVariantForSession,
784
+ recordImpression: recordImpression,
785
+ recordClick: recordClick,
786
+ recordConversion: recordConversion,
787
+ metricsForTest: metricsForTest,
788
+ pauseTest: pauseTest,
789
+ resumeTest: resumeTest,
790
+ concludeTest: concludeTest,
791
+ archiveTest: archiveTest,
792
+ eventsForTest: eventsForTest,
793
+ };
794
+ }
795
+
796
+ module.exports = {
797
+ create: create,
798
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
799
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
800
+ MAX_HYPOTHESIS_LEN: MAX_HYPOTHESIS_LEN,
801
+ MAX_VARIANTS: MAX_VARIANTS,
802
+ MAX_WEIGHT: MAX_WEIGHT,
803
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
804
+ ALLOWED_EVENT_KINDS: ALLOWED_EVENT_KINDS,
805
+ TRANSITIONS: TRANSITIONS,
806
+ };