@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.
- package/CHANGELOG.md +2 -0
- package/lib/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-portal.js +359 -0
- package/lib/experiments.js +697 -0
- package/lib/index.js +14 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|