@blamejs/blamejs-shop 0.0.59 → 0.0.60

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.
@@ -0,0 +1,697 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.experiments
4
+ * @title Experiments — A/B testing framework for the storefront
5
+ *
6
+ * @intro
7
+ * Operators define experiments with N variants + per-variant
8
+ * traffic weights. Each visitor's session id deterministically
9
+ * maps to one variant for the experiment's life — re-visits in the
10
+ * same session always see the same variant, so the visitor never
11
+ * experiences a within-session UI flip mid-funnel. Conversion
12
+ * events feed a per-variant success counter, and the operator
13
+ * dashboard reads back per-variant counts + Wilson 95% confidence
14
+ * intervals for the conversion rate.
15
+ *
16
+ * Surface:
17
+ * - defineExperiment({ slug, title, hypothesis, variants,
18
+ * primary_metric, status, starts_at,
19
+ * ends_at? })
20
+ * Create the experiment. Variants are an ordered array of
21
+ * { slug, weight } records — weights are positive integers,
22
+ * the cumulative total is the modulus for assignment.
23
+ * Variants are write-once: changing them after defineExperiment
24
+ * would corrupt assignments for sessions already seen.
25
+ *
26
+ * - getVariant({ experiment_slug, session_id })
27
+ * Returns the assigned variant for the session, or null if
28
+ * the experiment is not currently reading traffic (status
29
+ * not "running", or `now` is outside [starts_at, ends_at]).
30
+ * The session id is namespace-hashed (`experiments-session`)
31
+ * before any storage / hashing-for-assignment work. The
32
+ * assignment is deterministic: same session id + same
33
+ * experiment slug → same variant 100% of the time, for the
34
+ * lifetime of the experiment.
35
+ *
36
+ * - recordConversion({ experiment_slug, variant_slug,
37
+ * session_id, metric, value? })
38
+ * Append a conversion event. Drop-silent on
39
+ * unknown / archived experiment (this is a hot-path
40
+ * observability sink; throwing here would crash the request
41
+ * that observed the conversion).
42
+ *
43
+ * - metricsForExperiment({ experiment_slug, until? })
44
+ * Returns per-variant counts + Wilson 95% CI for the
45
+ * conversion rate. `until` defaults to `now`. The denominator
46
+ * is the number of sessions that were assigned to the
47
+ * variant via `getVariant` — but assignment is implicit
48
+ * (never persisted), so the dashboard approximates the
49
+ * denominator using the cumulative-weight share of the
50
+ * total assigned-session population, which the operator
51
+ * supplies as `assigned_sessions`. When `assigned_sessions`
52
+ * is omitted, the report returns raw counts only without a
53
+ * rate / CI.
54
+ *
55
+ * - listExperiments({ status? })
56
+ * Enumerate experiments, optionally filtered by status.
57
+ *
58
+ * - pauseExperiment(slug) / resumeExperiment(slug) /
59
+ * archiveExperiment(slug)
60
+ * FSM transitions. Refuse invalid edges (e.g. resuming an
61
+ * archived experiment, archiving twice).
62
+ *
63
+ * - update(slug, patch)
64
+ * Mutate metadata fields (title / hypothesis /
65
+ * primary_metric / ends_at). The variants_json column is
66
+ * read-only — operators archive + redefine to change the
67
+ * variant catalogue.
68
+ *
69
+ * Composition:
70
+ * - b.crypto.namespaceHash — session id is hashed with
71
+ * namespace "experiments-session" before any storage or
72
+ * assignment-hashing work. Assignment uses
73
+ * `namespaceHash("experiments-assign", slug + ":" +
74
+ * sessionHash)` to derive a 64-bit integer modulo the
75
+ * cumulative weight.
76
+ * - b.uuid.v7 — every experiment_events row carries a v7 id so
77
+ * rows sort lexicographically by insertion time.
78
+ *
79
+ * Storage:
80
+ * - experiments + experiment_events
81
+ * (migration 0071_experiments.sql).
82
+ *
83
+ * @primitive experiments
84
+ * @related b.crypto.namespaceHash, b.uuid.v7
85
+ */
86
+
87
+ var MAX_SLUG_LEN = 80;
88
+ var MAX_TITLE_LEN = 200;
89
+ var MAX_HYPOTHESIS_LEN = 2000;
90
+ var MAX_METRIC_LEN = 80;
91
+ var MAX_VARIANTS = 32;
92
+ var MAX_WEIGHT = 1000000;
93
+
94
+ var SESSION_NAMESPACE = "experiments-session";
95
+ var ASSIGN_NAMESPACE = "experiments-assign";
96
+
97
+ var ALLOWED_STATUSES = Object.freeze([
98
+ "draft",
99
+ "running",
100
+ "paused",
101
+ "archived",
102
+ ]);
103
+
104
+ // FSM transition graph. Mirrors the migration header comment.
105
+ // Archived is terminal — no outbound edges.
106
+ var TRANSITIONS = Object.freeze({
107
+ draft: { pause: null, resume: "running", archive: "archived" },
108
+ running: { pause: "paused", resume: null, archive: "archived" },
109
+ paused: { pause: null, resume: "running", archive: "archived" },
110
+ archived: { pause: null, resume: null, archive: null },
111
+ });
112
+
113
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
114
+
115
+ // Refuse C0 control bytes + DEL in operator-authored strings. The
116
+ // title + hypothesis fields land into the operator dashboard, not the
117
+ // storefront, but the discipline is the same — strings reach the
118
+ // operator UI as inert text, never as live markup.
119
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
120
+ var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
121
+
122
+ // Zero-width / direction-override family — mirrors the promo-banners
123
+ // + gift-options catalogues. Spelled with \u-escapes so ESLint's
124
+ // no-irregular-whitespace stays happy.
125
+ var ZERO_WIDTH_RE = new RegExp(
126
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
127
+ );
128
+
129
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
130
+ "title",
131
+ "hypothesis",
132
+ "primary_metric",
133
+ "ends_at",
134
+ ]);
135
+
136
+ var bShop;
137
+ function _b() {
138
+ if (!bShop) bShop = require("./index");
139
+ return bShop.framework;
140
+ }
141
+
142
+ // ---- validators ---------------------------------------------------------
143
+
144
+ function _slug(s, label) {
145
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
146
+ throw new TypeError("experiments: " + (label || "slug") +
147
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SLUG_LEN + " chars)");
148
+ }
149
+ return s;
150
+ }
151
+
152
+ function _line(s, label, maxLen) {
153
+ if (typeof s !== "string" || !s.length || s.length > maxLen) {
154
+ throw new TypeError("experiments: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
155
+ }
156
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
157
+ throw new TypeError("experiments: " + label + " contains control bytes (incl. CR/LF)");
158
+ }
159
+ if (ZERO_WIDTH_RE.test(s)) {
160
+ throw new TypeError("experiments: " + label + " contains zero-width / direction-override characters");
161
+ }
162
+ return s;
163
+ }
164
+
165
+ function _block(s, label, maxLen) {
166
+ if (typeof s !== "string" || !s.length || s.length > maxLen) {
167
+ throw new TypeError("experiments: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
168
+ }
169
+ if (CONTROL_BYTE_BLOCK_RE.test(s)) {
170
+ throw new TypeError("experiments: " + label + " contains control bytes");
171
+ }
172
+ if (ZERO_WIDTH_RE.test(s)) {
173
+ throw new TypeError("experiments: " + label + " contains zero-width / direction-override characters");
174
+ }
175
+ return s;
176
+ }
177
+
178
+ function _status(s) {
179
+ if (typeof s !== "string" || ALLOWED_STATUSES.indexOf(s) === -1) {
180
+ throw new TypeError("experiments: status must be one of " + JSON.stringify(ALLOWED_STATUSES));
181
+ }
182
+ return s;
183
+ }
184
+
185
+ function _epochMs(n, label) {
186
+ if (!Number.isInteger(n) || n < 0) {
187
+ throw new TypeError("experiments: " + label + " must be a non-negative integer (epoch ms)");
188
+ }
189
+ return n;
190
+ }
191
+
192
+ function _nonNegInt(n, label) {
193
+ if (!Number.isInteger(n) || n < 0) {
194
+ throw new TypeError("experiments: " + label + " must be a non-negative integer");
195
+ }
196
+ return n;
197
+ }
198
+
199
+ function _variants(arr) {
200
+ if (!Array.isArray(arr) || arr.length < 2) {
201
+ throw new TypeError("experiments: variants must be an array of at least 2 { slug, weight } records");
202
+ }
203
+ if (arr.length > MAX_VARIANTS) {
204
+ throw new TypeError("experiments: variants must be ≤ " + MAX_VARIANTS + " records");
205
+ }
206
+ var seen = Object.create(null);
207
+ var out = [];
208
+ for (var i = 0; i < arr.length; i += 1) {
209
+ var v = arr[i];
210
+ if (!v || typeof v !== "object") {
211
+ throw new TypeError("experiments: variants[" + i + "] must be an object");
212
+ }
213
+ var vs = _slug(v.slug, "variants[" + i + "].slug");
214
+ if (seen[vs]) {
215
+ throw new TypeError("experiments: variants[" + i + "].slug duplicates an earlier variant slug");
216
+ }
217
+ seen[vs] = true;
218
+ if (!Number.isInteger(v.weight) || v.weight < 1 || v.weight > MAX_WEIGHT) {
219
+ throw new TypeError("experiments: variants[" + i + "].weight must be an integer in [1, " + MAX_WEIGHT + "]");
220
+ }
221
+ out.push({ slug: vs, weight: v.weight });
222
+ }
223
+ return out;
224
+ }
225
+
226
+ function _now() { return Date.now(); }
227
+
228
+ // ---- assignment math ----------------------------------------------------
229
+ //
230
+ // The 64-bit modulus is computed from the first 16 hex chars (64
231
+ // bits) of the SHA3-512 hex output. JavaScript numbers are 53-bit
232
+ // safe, so the modulus is taken in two 32-bit halves to stay inside
233
+ // integer-arithmetic territory. Mathematically this is identical to
234
+ // taking the modulus of the full 64-bit unsigned integer.
235
+
236
+ function _modCumulativeWeight(sessionHashHex, cumulativeWeight) {
237
+ // Take the first 8 hex bytes (32 bits) as the high half, next 8 as
238
+ // the low half. Combine with: (high * 2^32 + low) mod CW =
239
+ // ((high mod CW) * (2^32 mod CW) + low) mod CW.
240
+ var high = parseInt(sessionHashHex.slice(0, 8), 16);
241
+ var low = parseInt(sessionHashHex.slice(8, 16), 16);
242
+ var cw = cumulativeWeight;
243
+ var twoToThe32ModCw = 4294967296 % cw;
244
+ return ((high % cw) * twoToThe32ModCw + low) % cw;
245
+ }
246
+
247
+ // ---- Wilson score interval ---------------------------------------------
248
+ //
249
+ // Two-sided 95% confidence interval for a Bernoulli proportion. The
250
+ // Wilson interval is well-behaved at the extremes (0% / 100%) and
251
+ // for small sample sizes — strictly preferable to the normal-
252
+ // approximation interval that newcomers reach for. Returns
253
+ // { lower, upper } with both bounds clamped into [0, 1].
254
+
255
+ var Z_95 = 1.959963984540054; // two-sided 95% z-score
256
+
257
+ function _wilsonCi(successes, trials) {
258
+ if (trials <= 0) return { lower: 0, upper: 0 };
259
+ var z = Z_95;
260
+ var n = trials;
261
+ var p = successes / n;
262
+ var z2 = z * z;
263
+ var denom = 1 + z2 / n;
264
+ var center = (p + z2 / (2 * n)) / denom;
265
+ var half = (z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n)) / denom;
266
+ var lower = center - half;
267
+ var upper = center + half;
268
+ if (lower < 0) lower = 0;
269
+ if (upper > 1) upper = 1;
270
+ return { lower: lower, upper: upper };
271
+ }
272
+
273
+ // ---- row hydration ------------------------------------------------------
274
+
275
+ function _hydrateRow(r) {
276
+ if (!r) return null;
277
+ var variants;
278
+ try {
279
+ variants = JSON.parse(r.variants_json);
280
+ } catch (_e) {
281
+ variants = [];
282
+ }
283
+ return {
284
+ slug: r.slug,
285
+ title: r.title,
286
+ hypothesis: r.hypothesis,
287
+ variants: variants,
288
+ primary_metric: r.primary_metric,
289
+ status: r.status,
290
+ starts_at: Number(r.starts_at),
291
+ ends_at: r.ends_at == null ? null : Number(r.ends_at),
292
+ paused_at: r.paused_at == null ? null : Number(r.paused_at),
293
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
294
+ created_at: Number(r.created_at),
295
+ updated_at: Number(r.updated_at),
296
+ };
297
+ }
298
+
299
+ // ---- factory ------------------------------------------------------------
300
+
301
+ function create(opts) {
302
+ opts = opts || {};
303
+ var query = opts.query;
304
+ if (!query) {
305
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
306
+ }
307
+
308
+ function _hashSession(sessionId) {
309
+ return _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
310
+ }
311
+
312
+ function _assignVariant(experimentSlug, sessionHash, variants) {
313
+ var cw = 0;
314
+ for (var i = 0; i < variants.length; i += 1) cw += variants[i].weight;
315
+ var keyHash = _b().crypto.namespaceHash(ASSIGN_NAMESPACE, experimentSlug + ":" + sessionHash);
316
+ var bucket = _modCumulativeWeight(keyHash, cw);
317
+ var acc = 0;
318
+ for (var j = 0; j < variants.length; j += 1) {
319
+ acc += variants[j].weight;
320
+ if (bucket < acc) return variants[j];
321
+ }
322
+ // Defensive fallback — should be unreachable since
323
+ // bucket < cw = sum(weights). The fallback prevents an off-by-
324
+ // one in floating-point summation (cumulative weights are ints,
325
+ // so summation is exact) from returning undefined and crashing
326
+ // the request.
327
+ return variants[variants.length - 1];
328
+ }
329
+
330
+ // -- defineExperiment ---------------------------------------------------
331
+
332
+ async function defineExperiment(input) {
333
+ if (!input || typeof input !== "object") {
334
+ throw new TypeError("experiments.defineExperiment: input object required");
335
+ }
336
+ var slug = _slug(input.slug);
337
+ var title = _line(input.title, "title", MAX_TITLE_LEN);
338
+ var hypothesis = _block(input.hypothesis, "hypothesis", MAX_HYPOTHESIS_LEN);
339
+ var variants = _variants(input.variants);
340
+ var primaryMetric = _line(input.primary_metric, "primary_metric", MAX_METRIC_LEN);
341
+ var status = _status(input.status);
342
+ if (status === "archived") {
343
+ throw new TypeError("experiments.defineExperiment: cannot define an experiment in 'archived' status");
344
+ }
345
+ var startsAt = _epochMs(input.starts_at, "starts_at");
346
+ var endsAt = null;
347
+ if (input.ends_at != null) {
348
+ endsAt = _epochMs(input.ends_at, "ends_at");
349
+ if (endsAt <= startsAt) {
350
+ throw new TypeError("experiments.defineExperiment: ends_at must be strictly greater than starts_at");
351
+ }
352
+ }
353
+
354
+ var ts = _now();
355
+ var pausedAt = status === "paused" ? ts : null;
356
+ await query(
357
+ "INSERT INTO experiments (slug, title, hypothesis, variants_json, primary_metric, " +
358
+ "status, starts_at, ends_at, paused_at, archived_at, created_at, updated_at) " +
359
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?10)",
360
+ [slug, title, hypothesis, JSON.stringify(variants), primaryMetric,
361
+ status, startsAt, endsAt, pausedAt, ts],
362
+ );
363
+ return await getExperiment(slug);
364
+ }
365
+
366
+ // -- getExperiment / listExperiments -----------------------------------
367
+
368
+ async function getExperiment(slug) {
369
+ _slug(slug);
370
+ var r = (await query(
371
+ "SELECT * FROM experiments WHERE slug = ?1 LIMIT 1",
372
+ [slug],
373
+ )).rows[0];
374
+ return _hydrateRow(r);
375
+ }
376
+
377
+ async function listExperiments(listOpts) {
378
+ listOpts = listOpts || {};
379
+ var sql, params;
380
+ if (listOpts.status != null) {
381
+ _status(listOpts.status);
382
+ sql = "SELECT * FROM experiments WHERE status = ?1 ORDER BY starts_at DESC, slug ASC";
383
+ params = [listOpts.status];
384
+ } else {
385
+ sql = "SELECT * FROM experiments ORDER BY starts_at DESC, slug ASC";
386
+ params = [];
387
+ }
388
+ var rows = (await query(sql, params)).rows;
389
+ return rows.map(_hydrateRow);
390
+ }
391
+
392
+ // -- getVariant --------------------------------------------------------
393
+
394
+ async function getVariant(input) {
395
+ if (!input || typeof input !== "object") {
396
+ throw new TypeError("experiments.getVariant: input object required");
397
+ }
398
+ _slug(input.experiment_slug, "experiment_slug");
399
+ if (typeof input.session_id !== "string" || !input.session_id.length) {
400
+ throw new TypeError("experiments.getVariant: session_id must be a non-empty string");
401
+ }
402
+ var sessionHash = _hashSession(input.session_id);
403
+
404
+ var exp = await getExperiment(input.experiment_slug);
405
+ if (!exp) return null;
406
+ if (exp.status !== "running") return null;
407
+
408
+ var nowTs = _now();
409
+ if (nowTs < exp.starts_at) return null;
410
+ if (exp.ends_at != null && nowTs >= exp.ends_at) return null;
411
+
412
+ var v = _assignVariant(exp.slug, sessionHash, exp.variants);
413
+ return {
414
+ experiment_slug: exp.slug,
415
+ variant_slug: v.slug,
416
+ session_id_hash: sessionHash,
417
+ };
418
+ }
419
+
420
+ // -- recordConversion --------------------------------------------------
421
+
422
+ // Drop-silent on unknown / archived experiment + unknown variant.
423
+ // This runs on the hot conversion path (checkout-started, add-to-
424
+ // cart, etc.); throwing here would crash the request that observed
425
+ // the conversion. The validation layer at defineExperiment has
426
+ // already verified that legitimate slugs exist; a request that
427
+ // arrives with a stale slug after the experiment was archived
428
+ // simply doesn't record, which is the correct observability
429
+ // behavior.
430
+ async function recordConversion(input) {
431
+ if (!input || typeof input !== "object") return { recorded: false };
432
+ if (typeof input.experiment_slug !== "string" || !SLUG_RE.test(input.experiment_slug)) {
433
+ return { recorded: false };
434
+ }
435
+ if (typeof input.variant_slug !== "string" || !SLUG_RE.test(input.variant_slug)) {
436
+ return { recorded: false };
437
+ }
438
+ if (typeof input.session_id !== "string" || !input.session_id.length) {
439
+ return { recorded: false };
440
+ }
441
+ if (typeof input.metric !== "string" || !input.metric.length || input.metric.length > MAX_METRIC_LEN) {
442
+ return { recorded: false };
443
+ }
444
+ if (CONTROL_BYTE_LINE_RE.test(input.metric) || ZERO_WIDTH_RE.test(input.metric)) {
445
+ return { recorded: false };
446
+ }
447
+ var value = 1;
448
+ if (input.value != null) {
449
+ if (!Number.isInteger(input.value) || input.value < 0) return { recorded: false };
450
+ value = input.value;
451
+ }
452
+ try {
453
+ var exp = await getExperiment(input.experiment_slug);
454
+ if (!exp) return { recorded: false };
455
+ if (exp.status === "archived") return { recorded: false };
456
+ // Variant must exist in the experiment's variant catalogue.
457
+ var ok = false;
458
+ for (var i = 0; i < exp.variants.length; i += 1) {
459
+ if (exp.variants[i].slug === input.variant_slug) { ok = true; break; }
460
+ }
461
+ if (!ok) return { recorded: false };
462
+
463
+ var sessionHash = _hashSession(input.session_id);
464
+ var ts = _now();
465
+ var id = _b().uuid.v7();
466
+ await query(
467
+ "INSERT INTO experiment_events (id, experiment_slug, variant_slug, session_id_hash, metric, value, occurred_at) " +
468
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
469
+ [id, input.experiment_slug, input.variant_slug, sessionHash, input.metric, value, ts],
470
+ );
471
+ return { recorded: true, id: id };
472
+ } catch (_e) {
473
+ return { recorded: false };
474
+ }
475
+ }
476
+
477
+ // -- metricsForExperiment ----------------------------------------------
478
+
479
+ async function metricsForExperiment(input) {
480
+ if (!input || typeof input !== "object") {
481
+ throw new TypeError("experiments.metricsForExperiment: input object required");
482
+ }
483
+ _slug(input.experiment_slug, "experiment_slug");
484
+ var until = _now();
485
+ if (input.until != null) until = _epochMs(input.until, "until");
486
+
487
+ var exp = await getExperiment(input.experiment_slug);
488
+ if (!exp) {
489
+ throw new TypeError("experiments.metricsForExperiment: experiment " +
490
+ JSON.stringify(input.experiment_slug) + " not found");
491
+ }
492
+
493
+ // Operator-supplied per-variant assigned-session counts for the
494
+ // Wilson CI denominator. Optional — when omitted, the report
495
+ // returns raw counts only. The shape is { variant_slug:
496
+ // assigned_count }.
497
+ var assignedSessions = null;
498
+ if (input.assigned_sessions != null) {
499
+ if (typeof input.assigned_sessions !== "object") {
500
+ throw new TypeError("experiments.metricsForExperiment: assigned_sessions must be a plain object");
501
+ }
502
+ assignedSessions = {};
503
+ var keys = Object.keys(input.assigned_sessions);
504
+ for (var i = 0; i < keys.length; i += 1) {
505
+ assignedSessions[keys[i]] = _nonNegInt(
506
+ input.assigned_sessions[keys[i]],
507
+ "assigned_sessions[" + JSON.stringify(keys[i]) + "]"
508
+ );
509
+ }
510
+ }
511
+
512
+ // Per-variant raw counts of the primary metric in [exp.starts_at,
513
+ // until]. The rollup is in SQL so the metrics report stays
514
+ // efficient as the events table grows.
515
+ var rows = (await query(
516
+ "SELECT variant_slug, COUNT(*) AS conversions, COALESCE(SUM(value), 0) AS value_sum " +
517
+ "FROM experiment_events " +
518
+ "WHERE experiment_slug = ?1 AND metric = ?2 AND occurred_at <= ?3 " +
519
+ "GROUP BY variant_slug",
520
+ [exp.slug, exp.primary_metric, until],
521
+ )).rows;
522
+ var byVariant = Object.create(null);
523
+ for (var r = 0; r < rows.length; r += 1) {
524
+ byVariant[rows[r].variant_slug] = {
525
+ conversions: Number(rows[r].conversions),
526
+ value_sum: Number(rows[r].value_sum),
527
+ };
528
+ }
529
+
530
+ var out = [];
531
+ for (var v = 0; v < exp.variants.length; v += 1) {
532
+ var variant = exp.variants[v];
533
+ var stats = byVariant[variant.slug] || { conversions: 0, value_sum: 0 };
534
+ var entry = {
535
+ variant_slug: variant.slug,
536
+ weight: variant.weight,
537
+ conversions: stats.conversions,
538
+ value_sum: stats.value_sum,
539
+ };
540
+ if (assignedSessions) {
541
+ var assigned = assignedSessions[variant.slug] || 0;
542
+ entry.assigned = assigned;
543
+ if (assigned > 0) {
544
+ entry.rate = stats.conversions / assigned;
545
+ var ci = _wilsonCi(stats.conversions, assigned);
546
+ entry.ci95_lower = ci.lower;
547
+ entry.ci95_upper = ci.upper;
548
+ } else {
549
+ entry.rate = 0;
550
+ entry.ci95_lower = 0;
551
+ entry.ci95_upper = 0;
552
+ }
553
+ }
554
+ out.push(entry);
555
+ }
556
+
557
+ return {
558
+ experiment_slug: exp.slug,
559
+ primary_metric: exp.primary_metric,
560
+ until: until,
561
+ variants: out,
562
+ };
563
+ }
564
+
565
+ // -- FSM transitions ---------------------------------------------------
566
+
567
+ async function _transition(slug, event) {
568
+ _slug(slug);
569
+ var existing = await getExperiment(slug);
570
+ if (!existing) {
571
+ throw new TypeError("experiments." + event + ": slug " + JSON.stringify(slug) + " not found");
572
+ }
573
+ var allowed = TRANSITIONS[existing.status];
574
+ var next = allowed && allowed[event];
575
+ if (!next) {
576
+ throw new TypeError("experiments." + event + ": cannot " + event +
577
+ " an experiment in status " + JSON.stringify(existing.status));
578
+ }
579
+ var ts = _now();
580
+ var sets = ["status = ?1", "updated_at = ?2"];
581
+ var params = [next, ts];
582
+ var idx = 3;
583
+ if (next === "paused") {
584
+ sets.push("paused_at = ?" + idx);
585
+ params.push(ts);
586
+ idx += 1;
587
+ } else if (next === "running") {
588
+ // Clear paused_at on resume so the column reflects the most
589
+ // recent pause window only.
590
+ sets.push("paused_at = NULL");
591
+ } else if (next === "archived") {
592
+ sets.push("archived_at = ?" + idx);
593
+ params.push(ts);
594
+ idx += 1;
595
+ }
596
+ params.push(slug);
597
+ await query(
598
+ "UPDATE experiments SET " + sets.join(", ") + " WHERE slug = ?" + idx,
599
+ params,
600
+ );
601
+ return await getExperiment(slug);
602
+ }
603
+
604
+ function pauseExperiment(slug) { return _transition(slug, "pause"); }
605
+ function resumeExperiment(slug) { return _transition(slug, "resume"); }
606
+ function archiveExperiment(slug) { return _transition(slug, "archive"); }
607
+
608
+ // -- update ------------------------------------------------------------
609
+
610
+ async function update(slug, patch) {
611
+ _slug(slug);
612
+ if (!patch || typeof patch !== "object") {
613
+ throw new TypeError("experiments.update: patch object required");
614
+ }
615
+ var keys = Object.keys(patch);
616
+ if (!keys.length) {
617
+ throw new TypeError("experiments.update: patch must include at least one column");
618
+ }
619
+ var existing = await getExperiment(slug);
620
+ if (!existing) {
621
+ throw new TypeError("experiments.update: slug " + JSON.stringify(slug) + " not found");
622
+ }
623
+ if (existing.status === "archived") {
624
+ throw new TypeError("experiments.update: cannot update an archived experiment");
625
+ }
626
+
627
+ var sets = [];
628
+ var params = [];
629
+ var idx = 1;
630
+ var postEndsAt = existing.ends_at;
631
+ for (var i = 0; i < keys.length; i += 1) {
632
+ var col = keys[i];
633
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
634
+ throw new TypeError("experiments.update: unsupported column " + JSON.stringify(col));
635
+ }
636
+ var v;
637
+ if (col === "title") { v = _line(patch[col], "title", MAX_TITLE_LEN); }
638
+ else if (col === "hypothesis") { v = _block(patch[col], "hypothesis", MAX_HYPOTHESIS_LEN); }
639
+ else if (col === "primary_metric") { v = _line(patch[col], "primary_metric", MAX_METRIC_LEN); }
640
+ else /* ends_at */ {
641
+ if (patch[col] == null) { v = null; postEndsAt = null; }
642
+ else { v = _epochMs(patch[col], "ends_at"); postEndsAt = v; }
643
+ }
644
+ sets.push(col + " = ?" + idx);
645
+ params.push(v);
646
+ idx += 1;
647
+ }
648
+ if (postEndsAt != null && postEndsAt <= existing.starts_at) {
649
+ throw new TypeError("experiments.update: ends_at must be strictly greater than starts_at");
650
+ }
651
+
652
+ sets.push("updated_at = ?" + idx);
653
+ params.push(_now());
654
+ idx += 1;
655
+ params.push(slug);
656
+
657
+ await query(
658
+ "UPDATE experiments SET " + sets.join(", ") + " WHERE slug = ?" + idx,
659
+ params,
660
+ );
661
+ return await getExperiment(slug);
662
+ }
663
+
664
+ return {
665
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
666
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
667
+ MAX_HYPOTHESIS_LEN: MAX_HYPOTHESIS_LEN,
668
+ MAX_METRIC_LEN: MAX_METRIC_LEN,
669
+ MAX_VARIANTS: MAX_VARIANTS,
670
+ MAX_WEIGHT: MAX_WEIGHT,
671
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
672
+ TRANSITIONS: TRANSITIONS,
673
+
674
+ defineExperiment: defineExperiment,
675
+ getExperiment: getExperiment,
676
+ listExperiments: listExperiments,
677
+ getVariant: getVariant,
678
+ recordConversion: recordConversion,
679
+ metricsForExperiment: metricsForExperiment,
680
+ pauseExperiment: pauseExperiment,
681
+ resumeExperiment: resumeExperiment,
682
+ archiveExperiment: archiveExperiment,
683
+ update: update,
684
+ };
685
+ }
686
+
687
+ module.exports = {
688
+ create: create,
689
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
690
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
691
+ MAX_HYPOTHESIS_LEN: MAX_HYPOTHESIS_LEN,
692
+ MAX_METRIC_LEN: MAX_METRIC_LEN,
693
+ MAX_VARIANTS: MAX_VARIANTS,
694
+ MAX_WEIGHT: MAX_WEIGHT,
695
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
696
+ TRANSITIONS: TRANSITIONS,
697
+ };