@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,918 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.emailABTests
4
+ * @title Email A/B tests — subject + body experiments across templates
5
+ *
6
+ * @intro
7
+ * `emailTemplates` lets operators author transactional bodies;
8
+ * `emailABTests` lets them experiment on those bodies. An operator
9
+ * defines a test against a single template slug + a list of
10
+ * variants (each variant overrides the template's subject /
11
+ * body_html / body_text for the subset of recipients it owns).
12
+ * Every variant carries a positive-integer `weight`; assignment
13
+ * is deterministic per (test_id, recipient_id) — a SHA3-512
14
+ * namespace-hash projects the pair into a 100,000-bucket range
15
+ * that maps onto the cumulative-weight buckets, so the same
16
+ * recipient always gets the same variant across the lifetime of
17
+ * the test and the realised split tracks the declared weights
18
+ * without rounding bias.
19
+ *
20
+ * Composition:
21
+ *
22
+ * var ab = bShop.emailABTests.create({ query: q, emailTemplates: et });
23
+ *
24
+ * var test = await ab.defineTest({
25
+ * slug: "welcome-subject-v1",
26
+ * title: "Welcome email — short vs. long subject",
27
+ * template_slug: "welcome-default",
28
+ * variants: [
29
+ * { id: "short", label: "Short", weight: 1, subject: "Welcome!" },
30
+ * { id: "long", label: "Long", weight: 1, subject: "Welcome to the shop — let's get you set up" },
31
+ * ],
32
+ * });
33
+ *
34
+ * await ab.startTest(test.slug);
35
+ *
36
+ * // For every outgoing welcome email — derive the assignment,
37
+ * // render the variant overrides on top of the template, record
38
+ * // the send.
39
+ * var assignment = await ab.getVariantForRecipient({
40
+ * test_slug: "welcome-subject-v1",
41
+ * recipient_id: customer.id,
42
+ * });
43
+ * // assignment.variant.subject overrides templates.renderTemplate(...).subject
44
+ * await ab.recordEmailSent({
45
+ * test_slug: "welcome-subject-v1",
46
+ * recipient_id: customer.id,
47
+ * variant_id: assignment.variant.id,
48
+ * });
49
+ *
50
+ * // Webhook side — every open / click pixel routes through here.
51
+ * await ab.recordOpen({ test_slug: "...", recipient_id: "...", variant_id: "..." });
52
+ * await ab.recordClick({ test_slug: "...", recipient_id: "...", variant_id: "..." });
53
+ *
54
+ * // Operator reads the live metrics with Wilson 95% CI on
55
+ * // both open- and click-through rates.
56
+ * var metrics = await ab.metricsForTest("welcome-subject-v1");
57
+ *
58
+ * // When the operator picks a winner, freeze the test.
59
+ * await ab.concludeTest("welcome-subject-v1", { winner_variant_id: "long" });
60
+ *
61
+ * Deterministic assignment:
62
+ *
63
+ * The hash input is `<test_id>|<recipient_id>` namespace-hashed
64
+ * under `email-ab-test-assignment`; the SHA3-512 output's first
65
+ * 8 hex bytes (32 bits) project into [0, 100000) via modulus.
66
+ * Cumulative-weight thresholds are computed in the same 100000-
67
+ * bucket basis so weight 1:1 splits at 50000, weight 3:1 splits
68
+ * at 75000, weight 1:2:1 splits at 25000 / 75000. The recipient_id
69
+ * is the operator-chosen stable handle — usually a customer UUID,
70
+ * but any non-empty string under 256 chars is accepted so an
71
+ * operator can A/B test newsletter sends keyed by an email-hash
72
+ * when no logged-in customer exists.
73
+ *
74
+ * FSM:
75
+ *
76
+ * draft -> running (startTest)
77
+ * running -> paused (pauseTest)
78
+ * paused -> running (resumeTest)
79
+ * running -> concluded (concludeTest)
80
+ * paused -> concluded (concludeTest)
81
+ * * -> archived (archiveTest; terminal)
82
+ *
83
+ * `pauseTest` does NOT invalidate existing per-recipient
84
+ * assignments — a paused test still serves the same variant to
85
+ * a returning recipient (so a paused experiment doesn't flip
86
+ * mid-flight); `recordEmailSent` refuses new assignments while
87
+ * paused. `archiveTest` is terminal and `recordEmailSent` /
88
+ * `recordOpen` / `recordClick` refuse on archived tests.
89
+ *
90
+ * Wilson 95% CI:
91
+ *
92
+ * `metricsForTest` returns per-variant `open_rate` + `click_rate`
93
+ * each as `{ rate, lower, upper, n, k }` where `lower` / `upper`
94
+ * are the Wilson score-interval bounds at z=1.96. The Wilson
95
+ * interval is well-defined for small n + extreme proportions
96
+ * (where the normal approximation gives nonsense bounds), so an
97
+ * operator who looks at a test with 30 sends + 1 open still gets
98
+ * a useful interval.
99
+ *
100
+ * Storage: `migrations-d1/0169_email_ab_tests.sql` —
101
+ * `email_ab_tests` + `email_ab_test_events` (FK CASCADE).
102
+ *
103
+ * @primitive emailABTests
104
+ * @related shop.emailTemplates, b.crypto.namespaceHash, b.uuid.v7
105
+ */
106
+
107
+ var bShop;
108
+ function _b() {
109
+ if (!bShop) bShop = require("./index");
110
+ return bShop.framework;
111
+ }
112
+
113
+ // ---- constants ----------------------------------------------------------
114
+
115
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?$/;
116
+ var VARIANT_ID_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
117
+
118
+ var STATUSES = Object.freeze(["draft", "running", "paused", "concluded", "archived"]);
119
+ var EVENT_KINDS = Object.freeze(["sent", "opened", "clicked"]);
120
+
121
+ var ASSIGNMENT_NAMESPACE = "email-ab-test-assignment";
122
+ var ASSIGNMENT_BUCKETS = 100000;
123
+
124
+ var MIN_VARIANTS = 2;
125
+ var MAX_VARIANTS = 16;
126
+ var MAX_TITLE_LEN = 200;
127
+ var MAX_LABEL_LEN = 200;
128
+ var MAX_SUBJECT_LEN = 200;
129
+ var MAX_BODY_LEN = 256 * 1024; // matches email-templates body cap
130
+ var MAX_RECIPIENT_ID_LEN = 256;
131
+ var MAX_TOTAL_WEIGHT = 1000000;
132
+ var MAX_LIST_LIMIT = 500;
133
+ var DEFAULT_LIST_LIMIT = 50;
134
+
135
+ // Wilson 95% z-score — kept as a constant so a future move to
136
+ // 99% is one literal swap. z=1.959964 is the more precise value
137
+ // but 1.96 is the de-facto convention for shipped dashboards.
138
+ var WILSON_Z = 1.96;
139
+
140
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
141
+
142
+ // ---- monotonic clock ----------------------------------------------------
143
+ //
144
+ // Operators record sent / open / click events in tight loops while a
145
+ // campaign drains; same-millisecond ties are routine. Bumping by 1ms
146
+ // on a tie keeps the audit timeline strictly increasing so an
147
+ // `ORDER BY occurred_at` read returns events in issue order without
148
+ // an additional tiebreaker column.
149
+
150
+ var _lastTs = 0;
151
+ function _now() {
152
+ var t = Date.now();
153
+ if (t <= _lastTs) { t = _lastTs + 1; }
154
+ _lastTs = t;
155
+ return t;
156
+ }
157
+
158
+ // ---- validators ---------------------------------------------------------
159
+
160
+ function _slug(s, label) {
161
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
162
+ throw new TypeError("emailABTests: " + (label || "slug") +
163
+ " must be lowercase alnum + . _ -, no leading/trailing punct, 1..100 chars");
164
+ }
165
+ return s;
166
+ }
167
+
168
+ function _title(s) {
169
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
170
+ throw new TypeError("emailABTests: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
171
+ }
172
+ if (CONTROL_BYTE_RE.test(s)) {
173
+ throw new TypeError("emailABTests: title must not contain control bytes");
174
+ }
175
+ return s;
176
+ }
177
+
178
+ function _variantId(s, label) {
179
+ if (typeof s !== "string" || !VARIANT_ID_RE.test(s)) {
180
+ throw new TypeError("emailABTests: " + (label || "variant id") +
181
+ " must be lowercase alnum + _ -, 1..64 chars");
182
+ }
183
+ return s;
184
+ }
185
+
186
+ function _label(s, idx) {
187
+ if (typeof s !== "string" || !s.length || s.length > MAX_LABEL_LEN) {
188
+ throw new TypeError("emailABTests: variants[" + idx + "].label must be a non-empty string <= " +
189
+ MAX_LABEL_LEN + " chars");
190
+ }
191
+ if (CONTROL_BYTE_RE.test(s)) {
192
+ throw new TypeError("emailABTests: variants[" + idx + "].label must not contain control bytes");
193
+ }
194
+ return s;
195
+ }
196
+
197
+ function _weight(n, idx) {
198
+ if (!Number.isInteger(n) || n <= 0) {
199
+ throw new TypeError("emailABTests: variants[" + idx + "].weight must be a positive integer");
200
+ }
201
+ return n;
202
+ }
203
+
204
+ function _subjectOverride(s, idx) {
205
+ if (s == null) return null;
206
+ if (typeof s !== "string" || !s.length || s.length > MAX_SUBJECT_LEN) {
207
+ throw new TypeError("emailABTests: variants[" + idx + "].subject must be a non-empty string <= " +
208
+ MAX_SUBJECT_LEN + " chars when provided");
209
+ }
210
+ if (/[\r\n\0]/.test(s)) {
211
+ throw new TypeError("emailABTests: variants[" + idx + "].subject must not contain CR / LF / NUL");
212
+ }
213
+ return s;
214
+ }
215
+
216
+ function _bodyOverride(s, idx, field) {
217
+ if (s == null) return null;
218
+ if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
219
+ throw new TypeError("emailABTests: variants[" + idx + "]." + field + " must be a non-empty string <= " +
220
+ MAX_BODY_LEN + " bytes when provided");
221
+ }
222
+ return s;
223
+ }
224
+
225
+ function _recipientId(s) {
226
+ if (typeof s !== "string" || !s.length || s.length > MAX_RECIPIENT_ID_LEN) {
227
+ throw new TypeError("emailABTests: recipient_id must be a non-empty string <= " +
228
+ MAX_RECIPIENT_ID_LEN + " chars");
229
+ }
230
+ if (CONTROL_BYTE_RE.test(s)) {
231
+ throw new TypeError("emailABTests: recipient_id must not contain control bytes");
232
+ }
233
+ return s;
234
+ }
235
+
236
+ function _limit(n) {
237
+ if (n == null) return DEFAULT_LIST_LIMIT;
238
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
239
+ throw new TypeError("emailABTests: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
240
+ }
241
+ return n;
242
+ }
243
+
244
+ function _epochOpt(n, label) {
245
+ if (n == null) return null;
246
+ if (!Number.isInteger(n) || n < 0) {
247
+ throw new TypeError("emailABTests: " + label + " must be a non-negative integer (ms epoch) or null");
248
+ }
249
+ return n;
250
+ }
251
+
252
+ // Canonicalise the operator-authored variants[] array. Returns a
253
+ // deep-cloned + key-ordered array so the stored JSON is independent
254
+ // of caller mutation.
255
+ function _variants(arr) {
256
+ if (!Array.isArray(arr)) {
257
+ throw new TypeError("emailABTests: variants must be an array");
258
+ }
259
+ if (arr.length < MIN_VARIANTS) {
260
+ throw new TypeError("emailABTests: variants must contain at least " + MIN_VARIANTS + " entries");
261
+ }
262
+ if (arr.length > MAX_VARIANTS) {
263
+ throw new TypeError("emailABTests: variants must contain <= " + MAX_VARIANTS + " entries");
264
+ }
265
+ var seen = Object.create(null);
266
+ var out = [];
267
+ var totalWeight = 0;
268
+ var sawOverride = false;
269
+ for (var i = 0; i < arr.length; i += 1) {
270
+ var v = arr[i];
271
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
272
+ throw new TypeError("emailABTests: variants[" + i + "] must be an object");
273
+ }
274
+ var id = _variantId(v.id, "variants[" + i + "].id");
275
+ if (seen[id]) {
276
+ throw new TypeError("emailABTests: variants[" + i + "].id duplicates a previous entry");
277
+ }
278
+ seen[id] = true;
279
+ var label = _label(v.label, i);
280
+ var weight = _weight(v.weight, i);
281
+ totalWeight += weight;
282
+ if (totalWeight > MAX_TOTAL_WEIGHT) {
283
+ throw new TypeError("emailABTests: total variant weight must be <= " + MAX_TOTAL_WEIGHT);
284
+ }
285
+ var subject = _subjectOverride(v.subject == null ? null : v.subject, i);
286
+ var bodyHtml = _bodyOverride(v.body_html == null ? null : v.body_html, i, "body_html");
287
+ var bodyText = _bodyOverride(v.body_text == null ? null : v.body_text, i, "body_text");
288
+ if (subject || bodyHtml || bodyText) sawOverride = true;
289
+ out.push({
290
+ id: id,
291
+ label: label,
292
+ weight: weight,
293
+ subject: subject,
294
+ body_html: bodyHtml,
295
+ body_text: bodyText,
296
+ });
297
+ }
298
+ if (!sawOverride) {
299
+ // A test with no overrides at all would assign recipients to
300
+ // visually-identical variants — surface this at definition time
301
+ // rather than letting the operator wonder why their metrics
302
+ // look identical.
303
+ throw new TypeError(
304
+ "emailABTests: at least one variant must override subject / body_html / body_text " +
305
+ "— otherwise the test has nothing to measure"
306
+ );
307
+ }
308
+ return out;
309
+ }
310
+
311
+ // ---- deterministic assignment ------------------------------------------
312
+ //
313
+ // SHA3-512 of `<test_id>|<recipient_id>` under the assignment
314
+ // namespace; the first 32 bits of the hex digest project into
315
+ // [0, ASSIGNMENT_BUCKETS) via modulus. The bucket is then matched
316
+ // against the cumulative-weight thresholds to pick the variant.
317
+
318
+ function _bucketForPair(testId, recipientId) {
319
+ var digest = _b().crypto.namespaceHash(ASSIGNMENT_NAMESPACE, testId + "|" + recipientId);
320
+ // Take the first 8 hex chars -> 32-bit unsigned integer. The full
321
+ // SHA3-512 digest is 128 hex chars; 32 bits is plenty of entropy
322
+ // for a 100,000-bucket projection and keeps the modulus bias
323
+ // negligible (2^32 / 100000 ~= 42949 — well above the modulo-bias
324
+ // floor where the trailing buckets would shrink).
325
+ var n = parseInt(digest.slice(0, 8), 16);
326
+ if (!Number.isFinite(n) || n < 0) n = 0;
327
+ return n % ASSIGNMENT_BUCKETS;
328
+ }
329
+
330
+ function _pickVariant(variants, bucket) {
331
+ // Cumulative-weight bucketing. The thresholds are computed in
332
+ // the same 100000-bucket basis so the last variant's upper bound
333
+ // is always exactly ASSIGNMENT_BUCKETS — no remainder bucket
334
+ // drifts because of integer division.
335
+ var totalWeight = 0;
336
+ for (var i = 0; i < variants.length; i += 1) totalWeight += variants[i].weight;
337
+ var accum = 0;
338
+ for (var j = 0; j < variants.length; j += 1) {
339
+ accum += variants[j].weight;
340
+ var threshold = Math.floor((accum * ASSIGNMENT_BUCKETS) / totalWeight);
341
+ if (j === variants.length - 1) threshold = ASSIGNMENT_BUCKETS;
342
+ if (bucket < threshold) return variants[j];
343
+ }
344
+ return variants[variants.length - 1];
345
+ }
346
+
347
+ // ---- Wilson score interval ---------------------------------------------
348
+ //
349
+ // 95% CI for a binomial proportion. For n=0 the interval collapses
350
+ // to {0, 0, 0}; for k=0 the lower bound is 0 and the upper bound is
351
+ // the Wilson upper for k=0 (still > 0); for k=n the upper bound is
352
+ // 1. The math:
353
+ //
354
+ // p_hat = k/n
355
+ // denom = 1 + z^2/n
356
+ // centre = (p_hat + z^2/(2n)) / denom
357
+ // margin = z * sqrt((p_hat*(1-p_hat) + z^2/(4n)) / n) / denom
358
+ // lower = centre - margin
359
+ // upper = centre + margin
360
+ //
361
+ // `_round4` rounds to 4 decimal places so the operator dashboard
362
+ // shows 0.1234 / 12.34% without trailing-precision noise.
363
+
364
+ function _round4(x) {
365
+ return Math.round(x * 10000) / 10000;
366
+ }
367
+
368
+ function _wilson(k, n) {
369
+ if (n === 0) return { rate: 0, lower: 0, upper: 0, n: 0, k: 0 };
370
+ var phat = k / n;
371
+ var z = WILSON_Z;
372
+ var z2 = z * z;
373
+ var denom = 1 + z2 / n;
374
+ var centre = (phat + z2 / (2 * n)) / denom;
375
+ var margin = (z * Math.sqrt((phat * (1 - phat) + z2 / (4 * n)) / n)) / denom;
376
+ var lower = centre - margin;
377
+ var upper = centre + margin;
378
+ if (lower < 0) lower = 0;
379
+ if (upper > 1) upper = 1;
380
+ return {
381
+ rate: _round4(phat),
382
+ lower: _round4(lower),
383
+ upper: _round4(upper),
384
+ n: n,
385
+ k: k,
386
+ };
387
+ }
388
+
389
+ // ---- row decoders ------------------------------------------------------
390
+
391
+ function _decodeTest(row) {
392
+ if (!row) return null;
393
+ var variants;
394
+ try { variants = JSON.parse(row.variants_json); }
395
+ catch (_e) { variants = []; }
396
+ return {
397
+ id: row.id,
398
+ slug: row.slug,
399
+ title: row.title,
400
+ template_slug: row.template_slug,
401
+ variants: variants,
402
+ status: row.status,
403
+ winner_variant_id: row.winner_variant_id,
404
+ started_at: row.started_at != null ? Number(row.started_at) : null,
405
+ paused_at: row.paused_at != null ? Number(row.paused_at) : null,
406
+ concluded_at: row.concluded_at != null ? Number(row.concluded_at) : null,
407
+ archived_at: row.archived_at != null ? Number(row.archived_at) : null,
408
+ created_at: Number(row.created_at),
409
+ updated_at: Number(row.updated_at),
410
+ };
411
+ }
412
+
413
+ // ---- factory -----------------------------------------------------------
414
+
415
+ function create(opts) {
416
+ opts = opts || {};
417
+ var query = opts.query;
418
+ if (!query) {
419
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
420
+ }
421
+ // Optional emailTemplates handle — when wired, defineTest validates
422
+ // that `template_slug` references a defined (non-archived) template.
423
+ // Absent, the test ships against an arbitrary slug (operator
424
+ // vouches via their own dispatch wiring).
425
+ var templates = opts.emailTemplates || null;
426
+
427
+ // ---- internal helpers ------------------------------------------------
428
+
429
+ async function _bySlug(slug) {
430
+ var r = await query("SELECT * FROM email_ab_tests WHERE slug = ?1", [slug]);
431
+ return r.rows[0] || null;
432
+ }
433
+ async function _byId(id) {
434
+ var r = await query("SELECT * FROM email_ab_tests WHERE id = ?1", [id]);
435
+ return r.rows[0] || null;
436
+ }
437
+
438
+ function _requireRow(row, label, slug) {
439
+ if (!row) {
440
+ throw new TypeError("emailABTests." + label + ": test '" + slug + "' not found");
441
+ }
442
+ return row;
443
+ }
444
+
445
+ async function _existingAssignment(testId, recipientId) {
446
+ var r = await query(
447
+ "SELECT variant_id FROM email_ab_test_events " +
448
+ "WHERE test_id = ?1 AND recipient_id = ?2 AND kind = 'sent' LIMIT 1",
449
+ [testId, recipientId],
450
+ );
451
+ return r.rows[0] ? r.rows[0].variant_id : null;
452
+ }
453
+
454
+ // ---- defineTest ------------------------------------------------------
455
+
456
+ async function defineTest(input) {
457
+ if (!input || typeof input !== "object") {
458
+ throw new TypeError("emailABTests.defineTest: input object required");
459
+ }
460
+ var slug = _slug(input.slug, "slug");
461
+ var title = _title(input.title);
462
+ var templateSlug = _slug(input.template_slug, "template_slug");
463
+ var variants = _variants(input.variants);
464
+
465
+ // Validate the template handle if one was wired into the factory.
466
+ if (templates && typeof templates.getTemplate === "function") {
467
+ // Use `_getTemplateRow`-style probe via the public API; an
468
+ // archived or missing template surfaces as a clean refusal at
469
+ // definition time so an operator can't ship an experiment
470
+ // pointed at a template the renderer would refuse.
471
+ var tplProbe = null;
472
+ try {
473
+ // `getTemplate` returns null for missing/archived; we don't
474
+ // require a published version (an operator may be staging
475
+ // both the template + the test together — the test only
476
+ // needs the slug to be live, not yet published).
477
+ // Read the raw row via a SELECT through the same query.
478
+ var rawProbe = await query(
479
+ "SELECT slug, archived_at FROM email_templates WHERE slug = ?1",
480
+ [templateSlug],
481
+ );
482
+ tplProbe = rawProbe.rows[0] || null;
483
+ } catch (_e) {
484
+ // Drop-silent — the email_templates table may not exist in a
485
+ // narrowly-scoped layer-1 fixture. The wired-handle path is
486
+ // an opt-in safety net, not a hard prerequisite.
487
+ tplProbe = null;
488
+ }
489
+ if (tplProbe) {
490
+ if (tplProbe.archived_at != null) {
491
+ throw new TypeError(
492
+ "emailABTests.defineTest: template_slug '" + templateSlug + "' is archived"
493
+ );
494
+ }
495
+ } else {
496
+ throw new TypeError(
497
+ "emailABTests.defineTest: template_slug '" + templateSlug + "' not found"
498
+ );
499
+ }
500
+ }
501
+
502
+ var existing = await _bySlug(slug);
503
+ if (existing) {
504
+ throw new TypeError(
505
+ "emailABTests.defineTest: test '" + slug + "' already exists — pick a fresh slug"
506
+ );
507
+ }
508
+
509
+ var id = _b().uuid.v7();
510
+ var ts = _now();
511
+ await query(
512
+ "INSERT INTO email_ab_tests " +
513
+ "(id, slug, title, template_slug, variants_json, status, winner_variant_id, " +
514
+ " started_at, paused_at, concluded_at, archived_at, created_at, updated_at) " +
515
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'draft', NULL, NULL, NULL, NULL, NULL, ?6, ?6)",
516
+ [id, slug, title, templateSlug, JSON.stringify(variants), ts],
517
+ );
518
+ return _decodeTest(await _byId(id));
519
+ }
520
+
521
+ // ---- getTest --------------------------------------------------------
522
+
523
+ async function getTest(slug) {
524
+ _slug(slug, "slug");
525
+ return _decodeTest(await _bySlug(slug));
526
+ }
527
+
528
+ // ---- listTests ------------------------------------------------------
529
+
530
+ async function listTests(listOpts) {
531
+ listOpts = listOpts || {};
532
+ var status = null;
533
+ if (listOpts.status != null) {
534
+ if (STATUSES.indexOf(listOpts.status) < 0) {
535
+ throw new TypeError("emailABTests.listTests: status must be one of " + STATUSES.join(", "));
536
+ }
537
+ status = listOpts.status;
538
+ }
539
+ var limit = _limit(listOpts.limit);
540
+ var sql = "SELECT * FROM email_ab_tests";
541
+ var params = [];
542
+ if (status) {
543
+ sql += " WHERE status = ?1";
544
+ params.push(status);
545
+ }
546
+ sql += " ORDER BY created_at DESC, id DESC LIMIT ?" + (params.length + 1);
547
+ params.push(limit);
548
+ var rows = (await query(sql, params)).rows;
549
+ var out = [];
550
+ for (var i = 0; i < rows.length; i += 1) out.push(_decodeTest(rows[i]));
551
+ return out;
552
+ }
553
+
554
+ // ---- FSM transitions ------------------------------------------------
555
+
556
+ async function startTest(slug) {
557
+ _slug(slug, "slug");
558
+ var row = _requireRow(await _bySlug(slug), "startTest", slug);
559
+ if (row.status !== "draft") {
560
+ var bad = new Error("emailABTests.startTest: test status is " + row.status);
561
+ bad.code = "EMAIL_AB_TEST_NOT_DRAFT";
562
+ throw bad;
563
+ }
564
+ var ts = _now();
565
+ await query(
566
+ "UPDATE email_ab_tests SET status = 'running', started_at = ?1, updated_at = ?1 " +
567
+ "WHERE id = ?2 AND status = 'draft'",
568
+ [ts, row.id],
569
+ );
570
+ return _decodeTest(await _byId(row.id));
571
+ }
572
+
573
+ async function pauseTest(slug) {
574
+ _slug(slug, "slug");
575
+ var row = _requireRow(await _bySlug(slug), "pauseTest", slug);
576
+ if (row.status !== "running") {
577
+ var bad = new Error("emailABTests.pauseTest: test status is " + row.status);
578
+ bad.code = "EMAIL_AB_TEST_NOT_RUNNING";
579
+ throw bad;
580
+ }
581
+ var ts = _now();
582
+ await query(
583
+ "UPDATE email_ab_tests SET status = 'paused', paused_at = ?1, updated_at = ?1 " +
584
+ "WHERE id = ?2 AND status = 'running'",
585
+ [ts, row.id],
586
+ );
587
+ return _decodeTest(await _byId(row.id));
588
+ }
589
+
590
+ async function resumeTest(slug) {
591
+ _slug(slug, "slug");
592
+ var row = _requireRow(await _bySlug(slug), "resumeTest", slug);
593
+ if (row.status !== "paused") {
594
+ var bad = new Error("emailABTests.resumeTest: test status is " + row.status);
595
+ bad.code = "EMAIL_AB_TEST_NOT_PAUSED";
596
+ throw bad;
597
+ }
598
+ var ts = _now();
599
+ await query(
600
+ "UPDATE email_ab_tests SET status = 'running', paused_at = NULL, updated_at = ?1 " +
601
+ "WHERE id = ?2 AND status = 'paused'",
602
+ [ts, row.id],
603
+ );
604
+ return _decodeTest(await _byId(row.id));
605
+ }
606
+
607
+ async function concludeTest(slug, concludeOpts) {
608
+ _slug(slug, "slug");
609
+ concludeOpts = concludeOpts || {};
610
+ var row = _requireRow(await _bySlug(slug), "concludeTest", slug);
611
+ if (row.status !== "running" && row.status !== "paused") {
612
+ var bad = new Error("emailABTests.concludeTest: test status is " + row.status);
613
+ bad.code = "EMAIL_AB_TEST_NOT_ACTIVE";
614
+ throw bad;
615
+ }
616
+ var variants = JSON.parse(row.variants_json);
617
+ var winnerId = null;
618
+ if (concludeOpts.winner_variant_id != null) {
619
+ winnerId = _variantId(concludeOpts.winner_variant_id, "winner_variant_id");
620
+ var match = false;
621
+ for (var i = 0; i < variants.length; i += 1) {
622
+ if (variants[i].id === winnerId) { match = true; break; }
623
+ }
624
+ if (!match) {
625
+ throw new TypeError(
626
+ "emailABTests.concludeTest: winner_variant_id '" + winnerId +
627
+ "' is not one of the test's variants"
628
+ );
629
+ }
630
+ }
631
+ var ts = _now();
632
+ await query(
633
+ "UPDATE email_ab_tests SET status = 'concluded', concluded_at = ?1, " +
634
+ "winner_variant_id = ?2, updated_at = ?1 " +
635
+ "WHERE id = ?3 AND status IN ('running', 'paused')",
636
+ [ts, winnerId, row.id],
637
+ );
638
+ return _decodeTest(await _byId(row.id));
639
+ }
640
+
641
+ async function archiveTest(slug) {
642
+ _slug(slug, "slug");
643
+ var row = await _bySlug(slug);
644
+ if (!row) return null;
645
+ if (row.status === "archived") {
646
+ return _decodeTest(row);
647
+ }
648
+ var ts = _now();
649
+ await query(
650
+ "UPDATE email_ab_tests SET status = 'archived', archived_at = ?1, updated_at = ?1 " +
651
+ "WHERE id = ?2 AND status <> 'archived'",
652
+ [ts, row.id],
653
+ );
654
+ return _decodeTest(await _byId(row.id));
655
+ }
656
+
657
+ // ---- getVariantForRecipient -----------------------------------------
658
+
659
+ async function getVariantForRecipient(input) {
660
+ if (!input || typeof input !== "object") {
661
+ throw new TypeError("emailABTests.getVariantForRecipient: input object required");
662
+ }
663
+ var slug = _slug(input.test_slug, "test_slug");
664
+ var recipientId = _recipientId(input.recipient_id);
665
+ var row = _requireRow(await _bySlug(slug), "getVariantForRecipient", slug);
666
+ if (row.status === "archived") {
667
+ var arch = new Error("emailABTests.getVariantForRecipient: test '" + slug + "' is archived");
668
+ arch.code = "EMAIL_AB_TEST_ARCHIVED";
669
+ throw arch;
670
+ }
671
+ var variants = JSON.parse(row.variants_json);
672
+
673
+ // Sticky assignment: if the recipient already has a 'sent' row
674
+ // recorded for this test, return that variant. The cumulative-
675
+ // weight bucket math is deterministic given (test_id,
676
+ // recipient_id) so re-computing returns the same answer; the
677
+ // ledger check is a belt-and-braces guard against an operator
678
+ // changing the variant weights mid-flight (which would silently
679
+ // re-bucket every returning recipient otherwise).
680
+ var sticky = await _existingAssignment(row.id, recipientId);
681
+ var picked;
682
+ if (sticky) {
683
+ picked = null;
684
+ for (var i = 0; i < variants.length; i += 1) {
685
+ if (variants[i].id === sticky) { picked = variants[i]; break; }
686
+ }
687
+ if (!picked) {
688
+ // The recipient's previous variant id no longer appears in
689
+ // the variants[] list — surface the drift rather than
690
+ // silently re-bucketing. Operators who really want to
691
+ // rebucket archive the test + define a fresh one.
692
+ var gone = new Error(
693
+ "emailABTests.getVariantForRecipient: previous variant '" + sticky +
694
+ "' for recipient is no longer in the test's variants"
695
+ );
696
+ gone.code = "EMAIL_AB_TEST_VARIANT_REMOVED";
697
+ throw gone;
698
+ }
699
+ } else {
700
+ var bucket = _bucketForPair(row.id, recipientId);
701
+ picked = _pickVariant(variants, bucket);
702
+ }
703
+ return {
704
+ test_id: row.id,
705
+ test_slug: row.slug,
706
+ recipient_id: recipientId,
707
+ sticky: !!sticky,
708
+ variant: {
709
+ id: picked.id,
710
+ label: picked.label,
711
+ weight: picked.weight,
712
+ subject: picked.subject,
713
+ body_html: picked.body_html,
714
+ body_text: picked.body_text,
715
+ },
716
+ };
717
+ }
718
+
719
+ // ---- record events --------------------------------------------------
720
+
721
+ async function _recordEvent(kind, input) {
722
+ if (!input || typeof input !== "object") {
723
+ throw new TypeError("emailABTests.record" + kind + ": input object required");
724
+ }
725
+ var slug = _slug(input.test_slug, "test_slug");
726
+ var recipientId = _recipientId(input.recipient_id);
727
+ var variantId = _variantId(input.variant_id, "variant_id");
728
+ var occurredOpt = _epochOpt(input.occurred_at, "occurred_at");
729
+
730
+ var row = _requireRow(await _bySlug(slug), "record" + kind, slug);
731
+ if (row.status === "archived") {
732
+ var arch = new Error("emailABTests.record" + kind + ": test '" + slug + "' is archived");
733
+ arch.code = "EMAIL_AB_TEST_ARCHIVED";
734
+ throw arch;
735
+ }
736
+ if (kind === "EmailSent" && row.status !== "running") {
737
+ // sent events anchor the assignment — only running tests accept
738
+ // new assignments. opened / clicked events can still land while
739
+ // the test is paused / concluded so a late-firing pixel from a
740
+ // previously-sent email doesn't drop on the floor.
741
+ var notRun = new Error("emailABTests.recordEmailSent: test '" + slug + "' status is " + row.status);
742
+ notRun.code = "EMAIL_AB_TEST_NOT_RUNNING";
743
+ throw notRun;
744
+ }
745
+
746
+ // For opened / clicked: verify the (recipient, variant) pair was
747
+ // actually assigned a `sent` event first. A click on a recipient
748
+ // who was never sent the email is a webhook anomaly we refuse
749
+ // outright so spammy upstream pixels can't inflate metrics.
750
+ if (kind === "Open" || kind === "Click") {
751
+ var sent = await query(
752
+ "SELECT 1 FROM email_ab_test_events " +
753
+ "WHERE test_id = ?1 AND recipient_id = ?2 AND variant_id = ?3 AND kind = 'sent' LIMIT 1",
754
+ [row.id, recipientId, variantId],
755
+ );
756
+ if (!sent.rows.length) {
757
+ var missing = new Error(
758
+ "emailABTests.record" + kind + ": no matching 'sent' event for " +
759
+ "(recipient, variant) — refusing to count an event for an unassigned pair"
760
+ );
761
+ missing.code = "EMAIL_AB_TEST_NOT_SENT";
762
+ throw missing;
763
+ }
764
+ }
765
+
766
+ // The (test_id, recipient_id, variant_id, kind) UNIQUE index makes
767
+ // the event idempotent — replaying the same open / click pixel
768
+ // lands as a no-op, never a double-count. We attempt the insert
769
+ // first; if it conflicts, we read the existing row's id and
770
+ // return it as the canonical event.
771
+ var id = _b().uuid.v7();
772
+ var ts = _now();
773
+ var occurredAt = occurredOpt != null ? occurredOpt : ts;
774
+ var eventKind = kind === "EmailSent" ? "sent"
775
+ : kind === "Open" ? "opened"
776
+ : "clicked";
777
+ var inserted = false;
778
+ try {
779
+ await query(
780
+ "INSERT INTO email_ab_test_events (id, test_id, recipient_id, variant_id, kind, occurred_at) " +
781
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
782
+ [id, row.id, recipientId, variantId, eventKind, occurredAt],
783
+ );
784
+ inserted = true;
785
+ } catch (e) {
786
+ // UNIQUE conflict — re-read the existing row's id + occurred_at
787
+ // and return it. Any other error (FK violation, schema drift)
788
+ // bubbles up.
789
+ var msg = String(e && e.message || e);
790
+ if (!/UNIQUE|constraint/i.test(msg)) throw e;
791
+ var existing = await query(
792
+ "SELECT id, occurred_at FROM email_ab_test_events " +
793
+ "WHERE test_id = ?1 AND recipient_id = ?2 AND variant_id = ?3 AND kind = ?4 LIMIT 1",
794
+ [row.id, recipientId, variantId, eventKind],
795
+ );
796
+ if (existing.rows[0]) {
797
+ id = existing.rows[0].id;
798
+ occurredAt = Number(existing.rows[0].occurred_at);
799
+ }
800
+ }
801
+
802
+ return {
803
+ event_id: id,
804
+ test_id: row.id,
805
+ test_slug: row.slug,
806
+ recipient_id: recipientId,
807
+ variant_id: variantId,
808
+ kind: eventKind,
809
+ occurred_at: occurredAt,
810
+ duplicate: !inserted,
811
+ };
812
+ }
813
+
814
+ async function recordEmailSent(input) { return _recordEvent("EmailSent", input); }
815
+ async function recordOpen(input) { return _recordEvent("Open", input); }
816
+ async function recordClick(input) { return _recordEvent("Click", input); }
817
+
818
+ // ---- metricsForTest -------------------------------------------------
819
+
820
+ async function metricsForTest(slug) {
821
+ _slug(slug, "slug");
822
+ var row = await _bySlug(slug);
823
+ if (!row) return null;
824
+ var test = _decodeTest(row);
825
+
826
+ var counts = (await query(
827
+ "SELECT variant_id, kind, COUNT(*) AS c FROM email_ab_test_events " +
828
+ "WHERE test_id = ?1 GROUP BY variant_id, kind",
829
+ [row.id],
830
+ )).rows;
831
+
832
+ var byVariant = Object.create(null);
833
+ for (var i = 0; i < test.variants.length; i += 1) {
834
+ var v = test.variants[i];
835
+ byVariant[v.id] = {
836
+ variant_id: v.id,
837
+ label: v.label,
838
+ weight: v.weight,
839
+ sent: 0,
840
+ opened: 0,
841
+ clicked: 0,
842
+ };
843
+ }
844
+ for (var j = 0; j < counts.length; j += 1) {
845
+ var row2 = counts[j];
846
+ var entry = byVariant[row2.variant_id];
847
+ if (!entry) continue; // historical variant id removed from the test
848
+ var n = Number(row2.c);
849
+ if (row2.kind === "sent") entry.sent = n;
850
+ else if (row2.kind === "opened") entry.opened = n;
851
+ else if (row2.kind === "clicked") entry.clicked = n;
852
+ }
853
+
854
+ var perVariant = [];
855
+ var totalSent = 0;
856
+ var totalOpen = 0;
857
+ var totalClick = 0;
858
+ for (var k = 0; k < test.variants.length; k += 1) {
859
+ var ent = byVariant[test.variants[k].id];
860
+ ent.open_rate = _wilson(ent.opened, ent.sent);
861
+ ent.click_rate = _wilson(ent.clicked, ent.sent);
862
+ perVariant.push(ent);
863
+ totalSent += ent.sent;
864
+ totalOpen += ent.opened;
865
+ totalClick += ent.clicked;
866
+ }
867
+
868
+ return {
869
+ test_id: test.id,
870
+ test_slug: test.slug,
871
+ status: test.status,
872
+ winner_variant_id: test.winner_variant_id,
873
+ per_variant: perVariant,
874
+ totals: {
875
+ sent: totalSent,
876
+ opened: totalOpen,
877
+ clicked: totalClick,
878
+ open_rate: _wilson(totalOpen, totalSent),
879
+ click_rate: _wilson(totalClick, totalSent),
880
+ },
881
+ };
882
+ }
883
+
884
+ return {
885
+ STATUSES: STATUSES.slice(),
886
+ EVENT_KINDS: EVENT_KINDS.slice(),
887
+ ASSIGNMENT_NAMESPACE: ASSIGNMENT_NAMESPACE,
888
+ ASSIGNMENT_BUCKETS: ASSIGNMENT_BUCKETS,
889
+ MIN_VARIANTS: MIN_VARIANTS,
890
+ MAX_VARIANTS: MAX_VARIANTS,
891
+ WILSON_Z: WILSON_Z,
892
+
893
+ defineTest: defineTest,
894
+ getTest: getTest,
895
+ listTests: listTests,
896
+ startTest: startTest,
897
+ pauseTest: pauseTest,
898
+ resumeTest: resumeTest,
899
+ concludeTest: concludeTest,
900
+ archiveTest: archiveTest,
901
+ getVariantForRecipient: getVariantForRecipient,
902
+ recordEmailSent: recordEmailSent,
903
+ recordOpen: recordOpen,
904
+ recordClick: recordClick,
905
+ metricsForTest: metricsForTest,
906
+ };
907
+ }
908
+
909
+ module.exports = {
910
+ create: create,
911
+ STATUSES: STATUSES,
912
+ EVENT_KINDS: EVENT_KINDS,
913
+ ASSIGNMENT_NAMESPACE: ASSIGNMENT_NAMESPACE,
914
+ ASSIGNMENT_BUCKETS: ASSIGNMENT_BUCKETS,
915
+ MIN_VARIANTS: MIN_VARIANTS,
916
+ MAX_VARIANTS: MAX_VARIANTS,
917
+ WILSON_Z: WILSON_Z,
918
+ };