@delmaredigital/payload-experiments 0.2.0-rc.7 → 0.3.0-rc.1

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 (267) hide show
  1. package/README.md +129 -11
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +24 -16
  4. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  5. package/dist/admin/actions/ConfirmDialog.js +37 -82
  6. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  7. package/dist/admin/actions/DecideDialog.js +30 -13
  8. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  9. package/dist/admin/actions/MakeContentResult.js +115 -16
  10. package/dist/admin/actions/actions.model.d.ts +2 -4
  11. package/dist/admin/actions/actions.model.js +2 -3
  12. package/dist/admin/allocation.model.js +3 -5
  13. package/dist/admin/api.d.ts +10 -4
  14. package/dist/admin/api.js +11 -7
  15. package/dist/admin/copy/actions.d.ts +39 -3
  16. package/dist/admin/copy/actions.js +40 -4
  17. package/dist/admin/copy/armSelect.js +5 -5
  18. package/dist/admin/copy/audience.d.ts +2 -1
  19. package/dist/admin/copy/audience.js +3 -2
  20. package/dist/admin/copy/common.d.ts +13 -6
  21. package/dist/admin/copy/common.js +14 -8
  22. package/dist/admin/copy/goals.d.ts +21 -6
  23. package/dist/admin/copy/goals.js +27 -6
  24. package/dist/admin/copy/readiness.d.ts +19 -0
  25. package/dist/admin/copy/readiness.js +19 -0
  26. package/dist/admin/copy/recommendation.d.ts +0 -3
  27. package/dist/admin/copy/recommendation.js +0 -3
  28. package/dist/admin/copy/results.d.ts +41 -2
  29. package/dist/admin/copy/results.js +42 -3
  30. package/dist/admin/copy/review.d.ts +1 -1
  31. package/dist/admin/copy/review.js +1 -1
  32. package/dist/admin/copy/setting.d.ts +2 -0
  33. package/dist/admin/copy/setting.js +2 -0
  34. package/dist/admin/copy/surface.d.ts +7 -4
  35. package/dist/admin/copy/surface.js +9 -4
  36. package/dist/admin/copy/testPage.d.ts +10 -0
  37. package/dist/admin/copy/testPage.js +10 -0
  38. package/dist/admin/format/date.d.ts +2 -0
  39. package/dist/admin/format/date.js +10 -0
  40. package/dist/admin/previewLinks.model.d.ts +2 -2
  41. package/dist/admin/previewLinks.model.js +5 -7
  42. package/dist/admin/results/CiChart.js +11 -21
  43. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  44. package/dist/admin/results/DataQualityPanel.js +30 -17
  45. package/dist/admin/results/DataTable.d.ts +16 -0
  46. package/dist/admin/results/DataTable.js +152 -0
  47. package/dist/admin/results/StatTile.d.ts +7 -0
  48. package/dist/admin/results/StatTile.js +22 -0
  49. package/dist/admin/results/VerdictHero.d.ts +13 -0
  50. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  51. package/dist/admin/results/releasePages.model.d.ts +17 -0
  52. package/dist/admin/results/releasePages.model.js +10 -0
  53. package/dist/admin/results/results.css +64 -0
  54. package/dist/admin/results/results.model.d.ts +5 -1
  55. package/dist/admin/results/results.model.js +10 -2
  56. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  57. package/dist/admin/surface/ResultsRoute.js +59 -34
  58. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  59. package/dist/admin/surface/SurfaceShell.js +49 -14
  60. package/dist/admin/surface/TestsHome.d.ts +6 -1
  61. package/dist/admin/surface/TestsHome.js +183 -112
  62. package/dist/admin/surface/TestsSurface.js +56 -16
  63. package/dist/admin/surface/goals/GoalForm.js +341 -308
  64. package/dist/admin/surface/goals/GoalsList.js +123 -89
  65. package/dist/admin/surface/goals/data.d.ts +8 -2
  66. package/dist/admin/surface/goals/data.js +11 -3
  67. package/dist/admin/surface/goals/goals.css +10 -13
  68. package/dist/admin/surface/home.model.d.ts +7 -8
  69. package/dist/admin/surface/home.model.js +23 -12
  70. package/dist/admin/surface/newTest/NewTest.js +5 -5
  71. package/dist/admin/surface/pageBlock/parts.js +31 -26
  72. package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
  73. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  74. package/dist/admin/surface/readiness/Banner.js +58 -33
  75. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  76. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  77. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  78. package/dist/admin/surface/readiness/readiness.model.js +18 -9
  79. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  80. package/dist/admin/surface/readiness/useTransition.js +2 -1
  81. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  82. package/dist/admin/surface/results/ReleasePages.js +116 -0
  83. package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
  84. package/dist/admin/surface/results/ResultsBody.js +276 -105
  85. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  86. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  87. package/dist/admin/surface/results/recommendation.model.js +2 -6
  88. package/dist/admin/surface/results/useResults.d.ts +21 -3
  89. package/dist/admin/surface/results/useResults.js +2 -1
  90. package/dist/admin/surface/setting/parts.js +75 -88
  91. package/dist/admin/surface/setting/setting.css +3 -15
  92. package/dist/admin/surface/surface.css +62 -47
  93. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  94. package/dist/admin/surface/test/GoalSection.js +39 -32
  95. package/dist/admin/surface/test/LengthSection.js +46 -34
  96. package/dist/admin/surface/test/Rail.d.ts +4 -0
  97. package/dist/admin/surface/test/Rail.js +83 -0
  98. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  99. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  100. package/dist/admin/surface/test/Section.d.ts +1 -1
  101. package/dist/admin/surface/test/Section.js +36 -16
  102. package/dist/admin/surface/test/TargetingSection.js +168 -142
  103. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  104. package/dist/admin/surface/test/TestPage.js +146 -68
  105. package/dist/admin/surface/test/VersionsSection.js +151 -120
  106. package/dist/admin/surface/test/fields.d.ts +11 -4
  107. package/dist/admin/surface/test/fields.js +84 -85
  108. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  109. package/dist/admin/surface/test/jumpToSection.js +10 -0
  110. package/dist/admin/surface/test/pageParts.js +9 -3
  111. package/dist/admin/surface/test/testPage.css +81 -14
  112. package/dist/admin/surface/useTestDocument.js +10 -4
  113. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  114. package/dist/admin/targeting/targeting.model.js +3 -8
  115. package/dist/admin/theme.css +115 -36
  116. package/dist/admin/theme.d.ts +38 -0
  117. package/dist/admin/theme.js +38 -1
  118. package/dist/admin/useApi.d.ts +17 -2
  119. package/dist/admin/views/ExperimentsRootView.js +12 -2
  120. package/dist/collections/experiments/config.js +25 -0
  121. package/dist/collections/experiments/lockHook.js +3 -1
  122. package/dist/collections/identities.js +6 -1
  123. package/dist/collections/metrics.js +7 -0
  124. package/dist/collections/metricsGuards.d.ts +9 -0
  125. package/dist/collections/metricsGuards.js +33 -0
  126. package/dist/contentDone.d.ts +4 -0
  127. package/dist/contentDone.js +5 -0
  128. package/dist/db/identities.d.ts +8 -7
  129. package/dist/db/identities.js +9 -6
  130. package/dist/db/metricUsage.d.ts +19 -0
  131. package/dist/db/metricUsage.js +54 -0
  132. package/dist/db/names.d.ts +5 -0
  133. package/dist/db/names.js +5 -0
  134. package/dist/db/publishedContent.d.ts +10 -0
  135. package/dist/db/publishedContent.js +43 -0
  136. package/dist/db/results.d.ts +2 -0
  137. package/dist/db/results.js +6 -2
  138. package/dist/db/snapshots.d.ts +18 -6
  139. package/dist/db/snapshots.js +23 -7
  140. package/dist/db/sweep.js +11 -6
  141. package/dist/endpoints/index.js +19 -1
  142. package/dist/endpoints/results.js +5 -0
  143. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  144. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  145. package/dist/endpoints/summary.js +20 -2
  146. package/dist/endpoints/transitions.d.ts +15 -0
  147. package/dist/endpoints/transitions.js +36 -0
  148. package/dist/errors.d.ts +8 -0
  149. package/dist/errors.js +28 -0
  150. package/dist/exports/index.d.ts +2 -1
  151. package/dist/exports/index.js +1 -0
  152. package/dist/exports/puckEditor.d.ts +1 -0
  153. package/dist/exports/puckEditor.js +1 -0
  154. package/dist/exports/rsc.d.ts +5 -1
  155. package/dist/exports/rsc.js +2 -0
  156. package/dist/hooks/pageGuard.d.ts +5 -0
  157. package/dist/hooks/pageGuard.js +121 -0
  158. package/dist/log.js +6 -3
  159. package/dist/manifest/compile.d.ts +5 -0
  160. package/dist/manifest/compile.js +5 -1
  161. package/dist/options.js +23 -0
  162. package/dist/plugin.js +26 -2
  163. package/dist/puck/copy.d.ts +1 -1
  164. package/dist/puck/copy.js +1 -1
  165. package/dist/puck/editor/editor.chrome.css +235 -0
  166. package/dist/puck/editor/editor.css +241 -43
  167. package/dist/results/aggregate.js +4 -3
  168. package/dist/results/analyse.d.ts +4 -1
  169. package/dist/results/analyse.js +27 -11
  170. package/dist/results/cuped.js +3 -2
  171. package/dist/results/identityCredit.d.ts +15 -7
  172. package/dist/results/identityCredit.js +32 -33
  173. package/dist/results/rolloutUnits.js +2 -1
  174. package/dist/results/snapshot.js +8 -2
  175. package/dist/results/srm.js +2 -1
  176. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  177. package/dist/sanitizeReadinessDetail.js +54 -0
  178. package/dist/server/linkIdentity.d.ts +10 -1
  179. package/dist/server/linkIdentity.js +17 -15
  180. package/dist/server/metadata.d.ts +5 -0
  181. package/dist/server/metadata.js +9 -1
  182. package/dist/server/runResults.d.ts +3 -2
  183. package/dist/server/runResults.js +121 -31
  184. package/dist/targeting/values.d.ts +4 -0
  185. package/dist/targeting/values.js +10 -0
  186. package/dist/transitions/addMetric.js +8 -0
  187. package/dist/transitions/archive.js +2 -3
  188. package/dist/transitions/checkStart.d.ts +6 -3
  189. package/dist/transitions/checkStart.js +15 -0
  190. package/dist/transitions/contentFields.d.ts +7 -0
  191. package/dist/transitions/contentFields.js +20 -0
  192. package/dist/transitions/decide.js +6 -1
  193. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  194. package/dist/transitions/dismissContentEntry.js +45 -0
  195. package/dist/transitions/makeContent.d.ts +5 -2
  196. package/dist/transitions/makeContent.js +217 -118
  197. package/dist/transitions/phaseRecord.d.ts +8 -2
  198. package/dist/transitions/placements.d.ts +1 -0
  199. package/dist/transitions/placements.js +4 -0
  200. package/dist/transitions/preflightData.d.ts +1 -1
  201. package/dist/transitions/preflightData.js +42 -4
  202. package/dist/transitions/releasePages.d.ts +8 -0
  203. package/dist/transitions/releasePages.js +26 -0
  204. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  205. package/dist/transitions/restoreContentEntry.js +47 -0
  206. package/dist/transitions/start.js +30 -5
  207. package/dist/types/contracts.d.ts +14 -0
  208. package/dist/types/endpoints.d.ts +34 -3
  209. package/dist/types/index.d.ts +1 -0
  210. package/dist/types/index.js +1 -0
  211. package/dist/types/reasons.d.ts +3 -0
  212. package/dist/types/reasons.js +1 -0
  213. package/dist/types/snapshot.d.ts +6 -1
  214. package/dist/ui/Banner.d.ts +2 -0
  215. package/dist/ui/Banner.js +2 -0
  216. package/dist/ui/Breadcrumb.d.ts +7 -0
  217. package/dist/ui/Breadcrumb.js +25 -0
  218. package/dist/ui/Button.d.ts +15 -0
  219. package/dist/ui/Button.js +32 -0
  220. package/dist/ui/Card.d.ts +12 -0
  221. package/dist/ui/Card.js +40 -0
  222. package/dist/ui/Checkbox.d.ts +2 -0
  223. package/dist/ui/Checkbox.js +2 -0
  224. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  225. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  226. package/dist/ui/Dialog.d.ts +13 -0
  227. package/dist/ui/Dialog.js +85 -0
  228. package/dist/ui/EmptyState.d.ts +2 -0
  229. package/dist/ui/EmptyState.js +2 -0
  230. package/dist/ui/Feedback.d.ts +24 -0
  231. package/dist/ui/Feedback.js +40 -0
  232. package/dist/ui/Inputs.d.ts +34 -0
  233. package/dist/ui/Inputs.js +225 -0
  234. package/dist/ui/NumberInput.d.ts +2 -0
  235. package/dist/ui/NumberInput.js +2 -0
  236. package/dist/ui/Range.d.ts +2 -0
  237. package/dist/ui/Range.js +2 -0
  238. package/dist/ui/SearchInput.d.ts +2 -0
  239. package/dist/ui/SearchInput.js +2 -0
  240. package/dist/ui/SectionCard.d.ts +2 -0
  241. package/dist/ui/SectionCard.js +2 -0
  242. package/dist/ui/Select.d.ts +2 -0
  243. package/dist/ui/Select.js +2 -0
  244. package/dist/ui/StatusChip.d.ts +2 -0
  245. package/dist/ui/StatusChip.js +2 -0
  246. package/dist/ui/Tabs.d.ts +14 -0
  247. package/dist/ui/Tabs.js +54 -0
  248. package/dist/ui/TextInput.d.ts +2 -0
  249. package/dist/ui/TextInput.js +2 -0
  250. package/dist/ui/Textarea.d.ts +2 -0
  251. package/dist/ui/Textarea.js +2 -0
  252. package/dist/ui/Toggle.d.ts +2 -0
  253. package/dist/ui/Toggle.js +2 -0
  254. package/dist/ui/accent.d.ts +21 -0
  255. package/dist/ui/accent.js +81 -0
  256. package/dist/ui/index.d.ts +9 -0
  257. package/dist/ui/index.js +9 -0
  258. package/dist/ui/ui.css +92 -0
  259. package/package.json +1 -1
  260. package/dist/admin/components/chipsInput.css +0 -11
  261. package/dist/admin/results/ArmTable.d.ts +0 -8
  262. package/dist/admin/results/ArmTable.js +0 -113
  263. package/dist/admin/results/VerdictCard.d.ts +0 -9
  264. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  265. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  266. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  267. package/dist/admin/surface/test/TestHeader.js +0 -70
@@ -1,3 +1,4 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { DEFAULT_GAUSSIAN_PRIOR, bonferroniAlpha, capValues, createThetaAdjustedStatistics, effectBayesianABTest, sequentialOneSidedTreatmentGreaterTTest, sequentialOneSidedTreatmentLesserTTest, sequentialTwoSidedTTest, statisticMean, statisticVariance } from '@delmaredigital/experiments-stats';
2
3
  import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
3
4
  import { timestampMs } from './aggregate.js';
@@ -18,8 +19,8 @@ const severity = {
18
19
  */ export function analysePhase(input) {
19
20
  const { phase, units } = input;
20
21
  const { analysis } = phase;
21
- // Start freezes this optional collection field as-is. Match the stats engine's
22
- // default and record the effective value in every snapshot, including n = 0.
22
+ // Compatibility for phases created before Start froze the effective tuning
23
+ // value. Record that effective value in every snapshot, including n = 0.
23
24
  const sequentialTuningParameter = analysis.sequentialTuningParameter ?? DEFAULT_SEQUENTIAL_TUNING_PARAMETER;
24
25
  const alpha = analysis.alpha ?? 0.05;
25
26
  const prior = analysis.prior ?? DEFAULT_GAUSSIAN_PRIOR;
@@ -30,7 +31,7 @@ const severity = {
30
31
  const rollout = phase.phaseKind === 'rollout';
31
32
  const baselineArm = phase.arms.find((arm)=>arm.kind === (rollout ? 'holdout' : 'control'));
32
33
  const winner = rollout ? phase.arms.find((arm)=>arm.key === input.winnerArm && arm.kind !== 'holdout') : undefined;
33
- if (!baselineArm || rollout && (!winner || phase.arms.length !== 2)) throw new RangeError('Phase comparison baseline or shipped arm is missing');
34
+ if (!baselineArm || rollout && (!winner || phase.arms.length !== 2)) throw new XpAnalysisError('no-baseline');
34
35
  const compared = phase.arms.filter((arm)=>rollout ? arm.key === winner.key : arm.kind === 'variant');
35
36
  const k = compared.length;
36
37
  const alphaAdjusted = analysis.method === 'sequential' ? bonferroniAlpha(alpha, k) : alpha;
@@ -42,22 +43,22 @@ const severity = {
42
43
  }));
43
44
  const primaryDefinition = definitions.find((metric)=>metric.role === 'primary');
44
45
  if (!primaryDefinition || phase.metricDefinitions.filter((metric)=>metric.role === 'primary').length !== 1) {
45
- throw new RangeError('Phase must have exactly one primary metric');
46
+ throw new XpAnalysisError('primary-metric-count');
46
47
  }
47
48
  if (new Set(phase.metricDefinitions.map((metric)=>String(metric.metricId))).size !== phase.metricDefinitions.length || new Set(input.metricData.map((metric)=>String(metric.metricId))).size !== input.metricData.length) {
48
- throw new RangeError('Duplicate metric id');
49
+ throw new XpAnalysisError('duplicate-metric');
49
50
  }
50
51
  const from = timestampMs(phase.startedAt);
51
52
  const now = timestampMs(input.now);
52
53
  const to = Math.min(now, phase.endedAt == null ? now : timestampMs(phase.endedAt));
53
- if (to < from) throw new RangeError('Analysis window ends before the phase starts');
54
+ if (to < from) throw new XpAnalysisError('window-before-start');
54
55
  const byArm = new Map(phase.arms.map((arm)=>[
55
56
  arm.key,
56
57
  []
57
58
  ]));
58
59
  for (const [id, unit] of units){
59
60
  const ids = byArm.get(unit.arm);
60
- if (!ids) throw new RangeError('Exposed unit has an unknown arm');
61
+ if (!ids) throw new XpAnalysisError('unknown-arm');
61
62
  ids.push(id);
62
63
  }
63
64
  const perArm = Object.fromEntries([
@@ -120,17 +121,19 @@ const severity = {
120
121
  let worstArmLabel = compared[0]?.label ?? '';
121
122
  let worstArm = compared[0]?.key ?? '';
122
123
  try {
123
- if (!data || data.unavailable !== undefined) throw new Error(data?.unavailable ?? 'Metric data was not supplied');
124
+ if (!data || data.unavailable !== undefined) {
125
+ throw new XpAnalysisError('metric-not-supplied');
126
+ }
124
127
  const stats = new Map();
125
128
  const cap = kind === 'revenue' ? definition.resolvedCap ?? null : null;
126
129
  const useCovariates = analysis.cuped === 'auto' && data.covariates !== undefined && data.covariates.size > 0;
127
130
  for (const arm of phase.arms){
128
131
  const ids = byArm.get(arm.key);
129
132
  const values = ids.map((id)=>data.values.get(id) ?? 0);
130
- if (!values.every(Number.isFinite)) throw new RangeError('Metric values must be finite');
133
+ if (!values.every(Number.isFinite)) throw new XpAnalysisError('non-finite-values');
131
134
  const capped = capValues(values, cap);
132
135
  const rawPre = useCovariates ? ids.map((id)=>data.covariates.get(id) ?? 0) : undefined;
133
- if (rawPre && !rawPre.every(Number.isFinite)) throw new RangeError('Covariates must be finite');
136
+ if (rawPre && !rawPre.every(Number.isFinite)) throw new XpAnalysisError('non-finite-covariates');
134
137
  const pre = rawPre && capValues(rawPre, cap).values;
135
138
  const built = outcomeStatistic(kind, capped.values, pre);
136
139
  stats.set(arm.key, built.statistic);
@@ -257,7 +260,20 @@ const severity = {
257
260
  }
258
261
  }
259
262
  } catch (error) {
260
- metric.unavailable = error instanceof Error ? error.message : 'Metric could not be analysed';
263
+ input.onMetricError?.(error, metricId);
264
+ let reason = 'analysis-failed';
265
+ if (error instanceof XpAnalysisError) {
266
+ switch(error.code){
267
+ case 'metric-not-supplied':
268
+ reason = data?.unavailable ?? 'not-supplied';
269
+ break;
270
+ case 'non-finite-values':
271
+ case 'non-finite-covariates':
272
+ reason = error.code;
273
+ break;
274
+ }
275
+ }
276
+ metric.unavailable = reason;
261
277
  metric.perArm = [];
262
278
  metric.comparisons = [];
263
279
  metric.cupedApplied = false;
@@ -1,13 +1,14 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { buildRegressionAdjustedStatistic, buildStatistic, summarizeUnits, summarizeUnitsWithCovariate } from '@delmaredigital/experiments-stats';
2
3
  /** Build candidate CUPED statistics; the stats engine pools each pair, infers theta,
3
4
  * and falls back to the post statistic when pre-period variance/covariance is zero.
4
5
  * Missing units in a supplied pre-period map are zero-filled by the caller.
5
6
  */ export function outcomeStatistic(kind, values, covariates) {
6
7
  if (!values.every(Number.isFinite) || covariates && !covariates.every(Number.isFinite)) {
7
- throw new RangeError('Metric values and covariates must be finite');
8
+ throw new XpAnalysisError('cuped-non-finite');
8
9
  }
9
10
  if (kind === 'binomial' && (values.some((value)=>value !== 0 && value !== 1) || covariates?.some((value)=>value !== 0 && value !== 1))) {
10
- throw new RangeError('Binomial values and covariates must be 0 or 1');
11
+ throw new XpAnalysisError('cuped-binomial');
11
12
  }
12
13
  const moments = summarizeUnits(values);
13
14
  if (covariates === undefined) return {
@@ -1,16 +1,24 @@
1
1
  import type { ExposedUnit, MetricEvent, Timestamp } from './aggregate.js';
2
- /**
3
- * Spec §8.3: credit once to the earliest exposed linked visitor; conflicts are diagnostic only.
4
- * Callers must pass user ids through String() for both identities and user-keyed source events.
5
- */
6
- export declare function creditUserEvents(units: ReadonlyMap<string, ExposedUnit>, identities: readonly {
2
+ export interface IdentityLink {
7
3
  anonymousId: string;
8
4
  userId: string;
9
- }[], userEvents: readonly {
5
+ verifiedAt: Timestamp | null;
6
+ /** Earliest verification across all the user's browsers, exposed or not. */
7
+ ownedAt: Timestamp | null;
8
+ }
9
+ /** Unverified links credit only events before anyone proves account ownership.
10
+ * Never-verified accounts keep crediting without a time limit.
11
+ */
12
+ export declare function eligibleIdentityLinks<T extends IdentityLink>(links: readonly T[], ts: Timestamp): T[];
13
+ /** Credit each event to the earliest exposed eligible visitor, with a stable ID
14
+ * tie-break. A user counts once if eligible links span arms for a credited event.
15
+ * Callers must normalize user IDs with String().
16
+ */
17
+ export declare function creditUserEvents(units: ReadonlyMap<string, ExposedUnit>, identities: readonly IdentityLink[], userEvents: readonly {
10
18
  userId: string;
11
19
  value: number;
12
20
  ts: Timestamp;
13
- }[]): {
21
+ }[], onConflict?: (userId: string) => void): {
14
22
  events: MetricEvent[];
15
23
  conflicts: number;
16
24
  };
@@ -1,46 +1,45 @@
1
1
  import { timestampMs } from './aggregate.js';
2
- /**
3
- * Spec §8.3: credit once to the earliest exposed linked visitor; conflicts are diagnostic only.
4
- * Callers must pass user ids through String() for both identities and user-keyed source events.
5
- */ export function creditUserEvents(units, identities, userEvents) {
2
+ /** Unverified links credit only events before anyone proves account ownership.
3
+ * Never-verified accounts keep crediting without a time limit.
4
+ */ export function eligibleIdentityLinks(links, ts) {
5
+ const t = timestampMs(ts);
6
+ return links.filter((link)=>link.verifiedAt !== null || link.ownedAt === null || t < timestampMs(link.ownedAt));
7
+ }
8
+ /** Credit each event to the earliest exposed eligible visitor, with a stable ID
9
+ * tie-break. A user counts once if eligible links span arms for a credited event.
10
+ * Callers must normalize user IDs with String().
11
+ */ export function creditUserEvents(units, identities, userEvents, onConflict) {
12
+ const starts = new Map([
13
+ ...units
14
+ ].map(([id, unit])=>[
15
+ id,
16
+ timestampMs(unit.firstTs)
17
+ ]));
6
18
  const users = new Map();
7
- const starts = new Map();
8
- for (const [unitId, unit] of units)starts.set(unitId, timestampMs(unit.firstTs));
9
- for (const { anonymousId, userId } of identities){
10
- const unit = units.get(anonymousId);
11
- if (!unit) continue;
12
- const firstTs = starts.get(anonymousId);
13
- const user = users.get(userId);
14
- if (!user) users.set(userId, {
15
- unitId: anonymousId,
16
- firstTs,
17
- arms: new Set([
18
- unit.arm
19
- ])
20
- });
21
- else {
22
- user.arms.add(unit.arm);
23
- // Stable tie-break for simultaneous exposures, independent of identity row order.
24
- if (firstTs < user.firstTs || firstTs === user.firstTs && anonymousId < user.unitId) {
25
- user.unitId = anonymousId;
26
- user.firstTs = firstTs;
27
- }
28
- }
19
+ for (const link of identities){
20
+ if (!units.has(link.anonymousId)) continue;
21
+ const links = users.get(link.userId) ?? [];
22
+ links.push(link);
23
+ users.set(link.userId, links);
29
24
  }
30
- let conflicts = 0;
31
- for (const user of users.values())if (user.arms.size > 1) conflicts++;
25
+ for (const links of users.values())links.sort((a, b)=>starts.get(a.anonymousId) - starts.get(b.anonymousId) || (a.anonymousId < b.anonymousId ? -1 : a.anonymousId > b.anonymousId ? 1 : 0));
26
+ const conflicts = new Set();
32
27
  const events = [];
33
28
  for (const event of userEvents){
34
- timestampMs(event.ts);
35
- const user = users.get(event.userId);
36
- if (user) events.push({
37
- unitId: user.unitId,
29
+ const eligible = eligibleIdentityLinks(users.get(event.userId) ?? [], event.ts);
30
+ if (!eligible.length) continue;
31
+ events.push({
32
+ unitId: eligible[0].anonymousId,
38
33
  value: event.value,
39
34
  ts: event.ts
40
35
  });
36
+ if (new Set(eligible.map((link)=>units.get(link.anonymousId).arm)).size > 1) {
37
+ conflicts.add(event.userId);
38
+ onConflict?.(event.userId);
39
+ }
41
40
  }
42
41
  return {
43
42
  events,
44
- conflicts
43
+ conflicts: conflicts.size
45
44
  };
46
45
  }
@@ -1,7 +1,8 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { hash, layerSlice, rolloutBucketRanges } from '@delmaredigital/experiments-stats';
2
3
  import { timestampMs } from './aggregate.js';
3
4
  /** Spec §7.6/§8.1.5 (X1): first-ever rollout exposures inside assignment's exact ranges. */ export function rolloutAnalysisUnits({ exposuresAllPhases, rolloutPhase, seed, testWeights, coverage, layer }) {
4
- if (!seed.endsWith('|')) throw new RangeError('Assignment seed must end in |');
5
+ if (!seed.endsWith('|')) throw new XpAnalysisError('seed-format');
5
6
  const first = new Map();
6
7
  for (const row of exposuresAllPhases){
7
8
  const ts = timestampMs(row.ts);
@@ -1,3 +1,4 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  /** JSON cannot represent unbounded confidence limits. */ export function jsonInterval(ci) {
2
3
  return ci.map((bound)=>bound !== null && Number.isFinite(bound) ? bound : null);
3
4
  }
@@ -138,14 +139,19 @@ const shape = object({
138
139
  }),
139
140
  by: nullable(id)
140
141
  }))
142
+ }, {
143
+ health: list(object({
144
+ code: oneOf('version-page-unavailable', 'version-page-unchecked'),
145
+ path: string
146
+ }))
141
147
  });
142
148
  /** Validate the frozen JSON shape and headline/rollout invariants before storage.
143
149
  * This also accepts the shared CORE-13/admin fixtures without any I/O.
144
150
  */ export function validateSnapshot(value) {
145
- if (!shape(value)) throw new TypeError('Invalid Snapshot shape (all numbers must be finite)');
151
+ if (!shape(value)) throw new XpAnalysisError('snapshot-shape');
146
152
  const snapshot = value;
147
153
  if (snapshot.phaseKind === 'test' && snapshot.winnerArm !== null || snapshot.phaseKind === 'rollout' && (snapshot.winnerArm === null || !(snapshot.winnerArm in snapshot.exposures.perArm)) || snapshot.verdict.headline !== snapshot.verdict.finding + (snapshot.verdict.action ? ` ${snapshot.verdict.action}` : '') || new Date(snapshot.window.to).getTime() < new Date(snapshot.window.from).getTime()) {
148
- throw new TypeError('Invalid Snapshot invariants');
154
+ throw new XpAnalysisError('snapshot-invariants');
149
155
  }
150
156
  }
151
157
  /** Assemble the already-computed snapshot at the pure/I/O boundary. */ export function assembleSnapshot(snapshot) {
@@ -1,3 +1,4 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { checkSrm } from '@delmaredigital/experiments-stats';
2
3
  /** SRM uses exposed units and renormalised frozen weights, never allocation or
3
4
  * assignment counts. Rollout callers pass only shipped/holdout weights (100-h,h).
@@ -5,7 +6,7 @@ import { checkSrm } from '@delmaredigital/experiments-stats';
5
6
  */ export function exposureSrm(arms, perArm) {
6
7
  const active = arms.filter((arm)=>arm.weight > 0);
7
8
  const weight = active.reduce((sum, arm)=>sum + arm.weight, 0);
8
- if (weight <= 0) throw new RangeError('SRM requires positive weights');
9
+ if (weight <= 0) throw new XpAnalysisError('srm-weights');
9
10
  const expected = Object.fromEntries(arms.map((arm)=>[
10
11
  arm.key,
11
12
  arm.weight / weight
@@ -0,0 +1,7 @@
1
+ import type { ReadinessCode } from './types/endpoints.js';
2
+ /** Summary details contain identifiers only. Project known shapes rather than
3
+ * recursively copying host objects: titles, messages and placement detail are text.
4
+ * Other checks use authored client fallbacks and need no detail (including slot
5
+ * counts and layer capacity, which are not identifiers).
6
+ */
7
+ export declare function sanitizeReadinessDetail(code: ReadinessCode, detail: unknown): unknown;
@@ -0,0 +1,54 @@
1
+ const object = (value)=>value !== null && typeof value === 'object' && !Array.isArray(value);
2
+ // The runtime's slug/key alphabet, bounded even for host-provided parameter keys.
3
+ const key = (value)=>typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value);
4
+ const arm = (value)=>typeof value === 'string' && [
5
+ 'control',
6
+ 'variantA',
7
+ 'variantB',
8
+ 'variantC',
9
+ 'holdout'
10
+ ].includes(value);
11
+ const failure = (value)=>value === 'placements-failed' || value === 'forbidden' || value === 'not-found';
12
+ /** Summary details contain identifiers only. Project known shapes rather than
13
+ * recursively copying host objects: titles, messages and placement detail are text.
14
+ * Other checks use authored client fallbacks and need no detail (including slot
15
+ * counts and layer capacity, which are not identifiers).
16
+ */ export function sanitizeReadinessDetail(code, detail) {
17
+ switch(code){
18
+ case 'block-path-rule':
19
+ return object(detail) && Array.isArray(detail.rules) && detail.rules.length && detail.rules.every((index)=>Number.isSafeInteger(index) && index >= 0) ? {
20
+ rules: detail.rules
21
+ } : undefined;
22
+ case 'no-placements':
23
+ return failure(detail) ? detail : undefined;
24
+ case 'page-target-unpublished':
25
+ // Paths only: no query, fragment, whitespace, URL authority or encoded text.
26
+ return typeof detail === 'string' && detail.length <= 128 && detail.startsWith('/') && !detail.includes('//') && !/[^A-Za-z0-9_/-]/.test(detail) ? detail : undefined;
27
+ case 'flag-params-invalid':
28
+ {
29
+ if (!Array.isArray(detail) || !detail.length || !detail.every((item)=>object(item) && arm(item.arm) && key(item.field))) return undefined;
30
+ return detail.map((item)=>({
31
+ arm: item.arm,
32
+ field: item.field
33
+ }));
34
+ }
35
+ case 'flag-placement-missing':
36
+ {
37
+ if (failure(detail)) return detail;
38
+ if (!object(detail) || !Array.isArray(detail.missing) || !detail.missing.every(arm) || !Array.isArray(detail.placements) || !detail.placements.every((item)=>object(item) && arm(item.arm))) return undefined;
39
+ return {
40
+ missing: detail.missing,
41
+ placements: detail.placements.map((item)=>({
42
+ arm: item.arm
43
+ }))
44
+ };
45
+ }
46
+ case 'undeclared-targeting-key':
47
+ case 'invalid-targeting-rule':
48
+ return object(detail) && Array.isArray(detail.attributes) && detail.attributes.length && detail.attributes.every(key) ? {
49
+ attributes: detail.attributes
50
+ } : undefined;
51
+ default:
52
+ return undefined;
53
+ }
54
+ }
@@ -1,4 +1,12 @@
1
1
  import type { ExperimentsServerRuntime } from '../types/runtime.js';
2
+ import { type IdentityWriteOutcome } from '../db/identities.js';
3
+ export type LinkIdentityOutcome = IdentityWriteOutcome | 'failed';
4
+ /**
5
+ * verified: true means this request proves, in this browser, that the person owns
6
+ * the account: a magic-link or one-time-code callback, password sign-in, or OAuth
7
+ * callback. It does not mean "a session exists" or "the email is verified".
8
+ * Never throws into registration or sign-in.
9
+ */
2
10
  export declare function linkIdentity<TUser extends {
3
11
  id: string | number;
4
12
  } = {
@@ -6,4 +14,5 @@ export declare function linkIdentity<TUser extends {
6
14
  }>(serverRuntime: ExperimentsServerRuntime<TUser>, opts: {
7
15
  headers: Headers;
8
16
  userId: string;
9
- }): Promise<void>;
17
+ verified: boolean;
18
+ }): Promise<LinkIdentityOutcome>;
@@ -2,33 +2,35 @@ import { safeError } from '../log.js';
2
2
  import { NextRequest } from 'next/server.js';
3
3
  import { insertIdentityOnOwnConnection } from '../db/identities.js';
4
4
  import { readUnitCookie } from '../runtime/cookie.js';
5
- export async function linkIdentity(serverRuntime, opts) {
5
+ /**
6
+ * verified: true means this request proves, in this browser, that the person owns
7
+ * the account: a magic-link or one-time-code callback, password sign-in, or OAuth
8
+ * callback. It does not mean "a session exists" or "the email is verified".
9
+ * Never throws into registration or sign-in.
10
+ */ export async function linkIdentity(serverRuntime, opts) {
6
11
  let payload;
7
12
  try {
8
13
  const runtime = serverRuntime.proxyRuntime;
9
14
  // Consent sees cookies and headers only; the URL is a placeholder.
10
- let consent = true;
11
- try {
12
- consent = runtime.cookie.consent?.(new NextRequest('http://consent.invalid/', {
13
- headers: opts.headers
14
- })) !== false;
15
- } catch {
16
- console.error('[payload-experiments] consent failed; treating consent as false');
17
- consent = false;
18
- }
19
- if (!consent) return;
15
+ const consent = runtime.cookie.consent?.(new NextRequest('http://consent.invalid/', {
16
+ headers: opts.headers
17
+ })) !== false;
18
+ if (!consent) return 'skipped';
20
19
  const anonymousId = readUnitCookie(opts.headers, runtime);
21
- if (!anonymousId) return;
20
+ if (!anonymousId) return 'skipped';
22
21
  payload = await serverRuntime.getPayload();
23
- await insertIdentityOnOwnConnection(payload, {
22
+ return await insertIdentityOnOwnConnection(payload, {
24
23
  anonymousId,
25
- userId: opts.userId
24
+ userId: opts.userId,
25
+ verified: opts.verified
26
26
  });
27
27
  } catch (error) {
28
28
  try {
29
- payload?.logger.error({
29
+ if (payload) payload.logger.error({
30
30
  err: safeError(error)
31
31
  }, 'Experiments: linkIdentity failed');
32
+ else console.error('[payload-experiments] linkIdentity failed', safeError(error));
32
33
  } catch {}
34
+ return 'failed';
33
35
  }
34
36
  }
@@ -1,5 +1,10 @@
1
1
  import type { Metadata } from 'next';
2
2
  import type { ExperimentsServerRuntime } from '../types/runtime.js';
3
+ /**
4
+ * Reads the signed request context via `headers()`, so it decides from the URL the visitor requested, not the
5
+ * rewritten route. Call it only from a dynamic `generateMetadata`, never inside `'use cache'`. `path` is the
6
+ * fallback when the proxy did not run.
7
+ */
3
8
  export declare function experimentMetadata<TUser extends {
4
9
  id: string | number;
5
10
  } = {
@@ -1,6 +1,14 @@
1
1
  import { findNormalisedPathKey, normalisePath } from '../runtime/normalisePath.js';
2
+ import { headers } from 'next/headers.js';
3
+ import { serverRequest } from './request.js';
2
4
  import { serverManifest } from './context.js';
3
- export async function experimentMetadata(serverRuntime, path) {
5
+ /**
6
+ * Reads the signed request context via `headers()`, so it decides from the URL the visitor requested, not the
7
+ * rewritten route. Call it only from a dynamic `generateMetadata`, never inside `'use cache'`. `path` is the
8
+ * fallback when the proxy did not run.
9
+ */ export async function experimentMetadata(serverRuntime, path) {
10
+ const { request, hasContext } = serverRequest(new Headers(await headers()), serverRuntime.proxyRuntime.secrets);
11
+ if (hasContext) path = request.nextUrl.pathname;
4
12
  const manifest = await serverManifest(serverRuntime.proxyRuntime);
5
13
  const live = manifest?.experiments.find((e)=>e.kind === 'page' && e.arms.some((a)=>a.pageTarget !== undefined && normalisePath(a.pageTarget) === normalisePath(path)));
6
14
  if (live) {
@@ -1,10 +1,11 @@
1
- import type { Payload } from 'payload';
1
+ import { type Payload } from 'payload';
2
+ import type { MetricUnavailableReason } from '../types/reasons.js';
2
3
  import type { DocId } from '../types/contracts.js';
3
4
  type UnavailableMetric = {
4
5
  experimentId: DocId;
5
6
  phase: number;
6
7
  metricId: DocId;
7
- reason: string;
8
+ reason: MetricUnavailableReason;
8
9
  };
9
10
  export declare function runResults(payload: Payload, { budgetMs, experimentId, sweep }?: {
10
11
  budgetMs?: number;