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

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 (336) hide show
  1. package/README.md +202 -26
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +50 -21
  4. package/dist/admin/TestsNavLink.d.ts +3 -1
  5. package/dist/admin/TestsNavLink.js +2 -2
  6. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  7. package/dist/admin/actions/ConfirmDialog.js +37 -82
  8. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  9. package/dist/admin/actions/DecideDialog.js +30 -13
  10. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  11. package/dist/admin/actions/MakeContentResult.js +115 -16
  12. package/dist/admin/actions/actions.model.d.ts +2 -4
  13. package/dist/admin/actions/actions.model.js +2 -3
  14. package/dist/admin/allocation.model.js +3 -5
  15. package/dist/admin/api.d.ts +10 -4
  16. package/dist/admin/api.js +11 -7
  17. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  18. package/dist/admin/arms/arms.model.d.ts +1 -21
  19. package/dist/admin/arms/arms.model.js +1 -153
  20. package/dist/admin/copy/actions.d.ts +39 -3
  21. package/dist/admin/copy/actions.js +40 -4
  22. package/dist/admin/copy/armSelect.js +5 -5
  23. package/dist/admin/copy/arms.d.ts +1 -6
  24. package/dist/admin/copy/arms.js +1 -16
  25. package/dist/admin/copy/audience.d.ts +2 -1
  26. package/dist/admin/copy/audience.js +3 -2
  27. package/dist/admin/copy/common.d.ts +13 -6
  28. package/dist/admin/copy/common.js +14 -8
  29. package/dist/admin/copy/goals.d.ts +21 -6
  30. package/dist/admin/copy/goals.js +27 -6
  31. package/dist/admin/copy/readiness.d.ts +22 -0
  32. package/dist/admin/copy/readiness.js +21 -0
  33. package/dist/admin/copy/recommendation.d.ts +0 -3
  34. package/dist/admin/copy/recommendation.js +0 -3
  35. package/dist/admin/copy/results.d.ts +43 -2
  36. package/dist/admin/copy/results.js +44 -3
  37. package/dist/admin/copy/review.d.ts +2 -2
  38. package/dist/admin/copy/review.js +2 -2
  39. package/dist/admin/copy/setting.d.ts +2 -0
  40. package/dist/admin/copy/setting.js +2 -0
  41. package/dist/admin/copy/surface.d.ts +7 -5
  42. package/dist/admin/copy/surface.js +9 -5
  43. package/dist/admin/copy/testPage.d.ts +10 -0
  44. package/dist/admin/copy/testPage.js +10 -0
  45. package/dist/admin/format/date.d.ts +2 -0
  46. package/dist/admin/format/date.js +10 -0
  47. package/dist/admin/paths.d.ts +2 -1
  48. package/dist/admin/paths.js +2 -1
  49. package/dist/admin/previewLinks.model.d.ts +4 -2
  50. package/dist/admin/previewLinks.model.js +8 -7
  51. package/dist/admin/results/CiChart.js +11 -21
  52. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  53. package/dist/admin/results/DataQualityPanel.js +30 -17
  54. package/dist/admin/results/DataTable.d.ts +16 -0
  55. package/dist/admin/results/DataTable.js +152 -0
  56. package/dist/admin/results/StatTile.d.ts +7 -0
  57. package/dist/admin/results/StatTile.js +22 -0
  58. package/dist/admin/results/VerdictHero.d.ts +13 -0
  59. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  60. package/dist/admin/results/legacy.d.ts +16 -0
  61. package/dist/admin/results/legacy.js +27 -0
  62. package/dist/admin/results/releasePages.model.d.ts +17 -0
  63. package/dist/admin/results/releasePages.model.js +10 -0
  64. package/dist/admin/results/results.css +64 -0
  65. package/dist/admin/results/results.model.d.ts +11 -7
  66. package/dist/admin/results/results.model.js +24 -7
  67. package/dist/admin/results/snapshot.d.ts +14 -0
  68. package/dist/admin/results/snapshot.js +3 -0
  69. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  70. package/dist/admin/surface/ResultsRoute.js +59 -34
  71. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  72. package/dist/admin/surface/SurfaceShell.js +49 -14
  73. package/dist/admin/surface/TestsHome.d.ts +6 -1
  74. package/dist/admin/surface/TestsHome.js +182 -114
  75. package/dist/admin/surface/TestsSurface.js +56 -16
  76. package/dist/admin/surface/goals/GoalForm.js +341 -308
  77. package/dist/admin/surface/goals/GoalsList.js +123 -89
  78. package/dist/admin/surface/goals/data.d.ts +8 -2
  79. package/dist/admin/surface/goals/data.js +24 -16
  80. package/dist/admin/surface/goals/goals.css +10 -13
  81. package/dist/admin/surface/home.model.d.ts +8 -8
  82. package/dist/admin/surface/home.model.js +23 -12
  83. package/dist/admin/surface/newTest/NewTest.js +9 -9
  84. package/dist/admin/surface/pageBlock/parts.js +31 -26
  85. package/dist/admin/surface/readiness/ActionDialogs.js +47 -19
  86. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  87. package/dist/admin/surface/readiness/Banner.js +58 -33
  88. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  89. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  90. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  91. package/dist/admin/surface/readiness/readiness.model.js +25 -9
  92. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  93. package/dist/admin/surface/readiness/useTransition.js +2 -1
  94. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  95. package/dist/admin/surface/results/ReleasePages.js +116 -0
  96. package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
  97. package/dist/admin/surface/results/ResultsBody.js +277 -105
  98. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  99. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  100. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  101. package/dist/admin/surface/results/recommendation.model.js +12 -9
  102. package/dist/admin/surface/results/useResults.d.ts +21 -3
  103. package/dist/admin/surface/results/useResults.js +2 -1
  104. package/dist/admin/surface/setting/parts.js +75 -88
  105. package/dist/admin/surface/setting/setting.css +3 -15
  106. package/dist/admin/surface/surface.css +61 -47
  107. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  108. package/dist/admin/surface/test/GoalSection.js +46 -47
  109. package/dist/admin/surface/test/LengthSection.js +46 -34
  110. package/dist/admin/surface/test/Rail.d.ts +4 -0
  111. package/dist/admin/surface/test/Rail.js +83 -0
  112. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  113. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  114. package/dist/admin/surface/test/Section.d.ts +1 -1
  115. package/dist/admin/surface/test/Section.js +36 -16
  116. package/dist/admin/surface/test/TargetingSection.js +168 -142
  117. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  118. package/dist/admin/surface/test/TestPage.js +146 -68
  119. package/dist/admin/surface/test/VersionsSection.js +151 -120
  120. package/dist/admin/surface/test/fields.d.ts +11 -4
  121. package/dist/admin/surface/test/fields.js +84 -85
  122. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  123. package/dist/admin/surface/test/jumpToSection.js +10 -0
  124. package/dist/admin/surface/test/pageParts.js +9 -3
  125. package/dist/admin/surface/test/testPage.css +81 -14
  126. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  127. package/dist/admin/surface/test/testPage.model.js +1 -21
  128. package/dist/admin/surface/useTestDocument.js +12 -5
  129. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  130. package/dist/admin/targeting/targeting.model.js +3 -8
  131. package/dist/admin/theme.css +115 -36
  132. package/dist/admin/theme.d.ts +38 -0
  133. package/dist/admin/theme.js +38 -1
  134. package/dist/admin/useApi.d.ts +17 -2
  135. package/dist/admin/useLayerUsage.js +14 -23
  136. package/dist/admin/views/ExperimentsRootView.js +12 -2
  137. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  138. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  139. package/dist/arms/copy.d.ts +6 -0
  140. package/dist/arms/copy.js +16 -0
  141. package/dist/arms/model.d.ts +23 -0
  142. package/dist/arms/model.js +173 -0
  143. package/dist/collections/experiments/config.js +25 -0
  144. package/dist/collections/experiments/lockHook.js +3 -1
  145. package/dist/collections/identities.js +6 -1
  146. package/dist/collections/metrics.js +7 -0
  147. package/dist/collections/metricsGuards.d.ts +9 -0
  148. package/dist/collections/metricsGuards.js +33 -0
  149. package/dist/contentDone.d.ts +4 -0
  150. package/dist/contentDone.js +5 -0
  151. package/dist/db/appends.d.ts +6 -2
  152. package/dist/db/appends.js +10 -4
  153. package/dist/db/identities.d.ts +8 -7
  154. package/dist/db/identities.js +9 -6
  155. package/dist/db/locks.d.ts +6 -0
  156. package/dist/db/locks.js +13 -1
  157. package/dist/db/metricUsage.d.ts +19 -0
  158. package/dist/db/metricUsage.js +54 -0
  159. package/dist/db/names.d.ts +5 -0
  160. package/dist/db/names.js +5 -0
  161. package/dist/db/pageOccupancy.d.ts +12 -0
  162. package/dist/db/pageOccupancy.js +45 -0
  163. package/dist/db/publishedContent.d.ts +10 -0
  164. package/dist/db/publishedContent.js +43 -0
  165. package/dist/db/results.d.ts +2 -0
  166. package/dist/db/results.js +6 -2
  167. package/dist/db/snapshots.d.ts +18 -6
  168. package/dist/db/snapshots.js +25 -8
  169. package/dist/db/sweep.js +11 -6
  170. package/dist/endpoints/index.js +19 -1
  171. package/dist/endpoints/results.js +5 -0
  172. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  173. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  174. package/dist/endpoints/summary.js +20 -2
  175. package/dist/endpoints/transitions.d.ts +15 -0
  176. package/dist/endpoints/transitions.js +36 -0
  177. package/dist/errors.d.ts +8 -0
  178. package/dist/errors.js +28 -0
  179. package/dist/exports/adminRsc.d.ts +1 -0
  180. package/dist/exports/adminRsc.js +1 -0
  181. package/dist/exports/index.d.ts +2 -1
  182. package/dist/exports/index.js +1 -0
  183. package/dist/exports/puck.d.ts +2 -1
  184. package/dist/exports/puck.js +1 -1
  185. package/dist/exports/puckEditor.d.ts +4 -1
  186. package/dist/exports/puckEditor.js +3 -1
  187. package/dist/exports/rsc.d.ts +6 -1
  188. package/dist/exports/rsc.js +2 -0
  189. package/dist/hooks/pageGuard.d.ts +5 -0
  190. package/dist/hooks/pageGuard.js +139 -0
  191. package/dist/lib/fetchAllDocs.d.ts +16 -0
  192. package/dist/lib/fetchAllDocs.js +56 -0
  193. package/dist/log.js +6 -3
  194. package/dist/manifest/compile.d.ts +5 -0
  195. package/dist/manifest/compile.js +5 -1
  196. package/dist/options.js +16 -0
  197. package/dist/plugin.js +30 -2
  198. package/dist/puck/ExperimentPicker.js +47 -16
  199. package/dist/puck/ExperimentSlot.d.ts +51 -1
  200. package/dist/puck/ExperimentSlot.js +23 -12
  201. package/dist/puck/copy.d.ts +6 -5
  202. package/dist/puck/copy.js +6 -5
  203. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  204. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  205. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  206. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  207. package/dist/puck/editor/ResultsReadout.js +99 -0
  208. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  209. package/dist/puck/editor/VersionBuilder.js +317 -0
  210. package/dist/puck/editor/armWrites.d.ts +7 -0
  211. package/dist/puck/editor/armWrites.js +71 -0
  212. package/dist/puck/editor/armsCache.d.ts +14 -0
  213. package/dist/puck/editor/armsCache.js +78 -0
  214. package/dist/puck/editor/copy.d.ts +33 -1
  215. package/dist/puck/editor/copy.js +45 -8
  216. package/dist/puck/editor/createTest.d.ts +1 -1
  217. package/dist/puck/editor/createTest.js +25 -14
  218. package/dist/puck/editor/editor.chrome.css +121 -0
  219. package/dist/puck/editor/editor.css +288 -204
  220. package/dist/puck/editor/editor.model.d.ts +4 -1
  221. package/dist/puck/editor/editor.model.js +10 -7
  222. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  223. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  224. package/dist/puck/experimentSlotField.d.ts +2 -0
  225. package/dist/puck/experimentSlotField.js +11 -3
  226. package/dist/puck/hostContext.d.ts +10 -0
  227. package/dist/puck/hostContext.js +12 -0
  228. package/dist/puck/routes.d.ts +12 -0
  229. package/dist/puck/routes.js +11 -0
  230. package/dist/puck/slotConfig.d.ts +1 -1
  231. package/dist/puck/slotConfig.js +2 -2
  232. package/dist/results/aggregate.js +4 -3
  233. package/dist/results/analyse.d.ts +4 -1
  234. package/dist/results/analyse.js +27 -11
  235. package/dist/results/cuped.js +3 -2
  236. package/dist/results/identityCredit.d.ts +15 -7
  237. package/dist/results/identityCredit.js +32 -33
  238. package/dist/results/rolloutUnits.js +2 -1
  239. package/dist/results/snapshot.js +8 -2
  240. package/dist/results/srm.js +2 -1
  241. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  242. package/dist/sanitizeReadinessDetail.js +63 -0
  243. package/dist/server/linkIdentity.d.ts +10 -1
  244. package/dist/server/linkIdentity.js +17 -15
  245. package/dist/server/metadata.d.ts +5 -0
  246. package/dist/server/metadata.js +18 -3
  247. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  248. package/dist/server/normaliseContentRelationships.js +62 -0
  249. package/dist/server/recordContentChange.d.ts +1 -1
  250. package/dist/server/recordContentChange.js +17 -8
  251. package/dist/server/runResults.d.ts +3 -2
  252. package/dist/server/runResults.js +157 -35
  253. package/dist/targeting/values.d.ts +4 -0
  254. package/dist/targeting/values.js +10 -0
  255. package/dist/transitions/addMetric.js +8 -0
  256. package/dist/transitions/archive.js +2 -3
  257. package/dist/transitions/checkStart.d.ts +10 -3
  258. package/dist/transitions/checkStart.js +25 -0
  259. package/dist/transitions/contentFields.d.ts +7 -0
  260. package/dist/transitions/contentFields.js +20 -0
  261. package/dist/transitions/decide.js +22 -2
  262. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  263. package/dist/transitions/dismissContentEntry.js +45 -0
  264. package/dist/transitions/makeContent.d.ts +5 -2
  265. package/dist/transitions/makeContent.js +217 -118
  266. package/dist/transitions/phaseRecord.d.ts +8 -2
  267. package/dist/transitions/placements.d.ts +3 -6
  268. package/dist/transitions/placements.js +12 -1
  269. package/dist/transitions/preflightData.d.ts +9 -1
  270. package/dist/transitions/preflightData.js +138 -20
  271. package/dist/transitions/releasePages.d.ts +8 -0
  272. package/dist/transitions/releasePages.js +26 -0
  273. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  274. package/dist/transitions/restoreContentEntry.js +47 -0
  275. package/dist/transitions/start.js +62 -7
  276. package/dist/types/contracts.d.ts +24 -4
  277. package/dist/types/endpoints.d.ts +34 -3
  278. package/dist/types/index.d.ts +1 -0
  279. package/dist/types/index.js +1 -0
  280. package/dist/types/reasons.d.ts +3 -0
  281. package/dist/types/reasons.js +1 -0
  282. package/dist/types/snapshot.d.ts +6 -1
  283. package/dist/ui/Banner.d.ts +2 -0
  284. package/dist/ui/Banner.js +2 -0
  285. package/dist/ui/Breadcrumb.d.ts +7 -0
  286. package/dist/ui/Breadcrumb.js +25 -0
  287. package/dist/ui/Button.d.ts +15 -0
  288. package/dist/ui/Button.js +32 -0
  289. package/dist/ui/Card.d.ts +12 -0
  290. package/dist/ui/Card.js +40 -0
  291. package/dist/ui/Checkbox.d.ts +2 -0
  292. package/dist/ui/Checkbox.js +2 -0
  293. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  294. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  295. package/dist/ui/Dialog.d.ts +13 -0
  296. package/dist/ui/Dialog.js +85 -0
  297. package/dist/ui/EmptyState.d.ts +2 -0
  298. package/dist/ui/EmptyState.js +2 -0
  299. package/dist/ui/Feedback.d.ts +24 -0
  300. package/dist/ui/Feedback.js +40 -0
  301. package/dist/ui/Inputs.d.ts +34 -0
  302. package/dist/ui/Inputs.js +225 -0
  303. package/dist/ui/NumberInput.d.ts +2 -0
  304. package/dist/ui/NumberInput.js +2 -0
  305. package/dist/ui/Range.d.ts +2 -0
  306. package/dist/ui/Range.js +2 -0
  307. package/dist/ui/SearchInput.d.ts +2 -0
  308. package/dist/ui/SearchInput.js +2 -0
  309. package/dist/ui/SectionCard.d.ts +2 -0
  310. package/dist/ui/SectionCard.js +2 -0
  311. package/dist/ui/Select.d.ts +2 -0
  312. package/dist/ui/Select.js +2 -0
  313. package/dist/ui/StatusChip.d.ts +2 -0
  314. package/dist/ui/StatusChip.js +2 -0
  315. package/dist/ui/Tabs.d.ts +16 -0
  316. package/dist/ui/Tabs.js +61 -0
  317. package/dist/ui/TextInput.d.ts +2 -0
  318. package/dist/ui/TextInput.js +2 -0
  319. package/dist/ui/Textarea.d.ts +2 -0
  320. package/dist/ui/Textarea.js +2 -0
  321. package/dist/ui/Toggle.d.ts +2 -0
  322. package/dist/ui/Toggle.js +2 -0
  323. package/dist/ui/accent.d.ts +40 -0
  324. package/dist/ui/accent.js +139 -0
  325. package/dist/ui/index.d.ts +9 -0
  326. package/dist/ui/index.js +9 -0
  327. package/dist/ui/ui.css +92 -0
  328. package/package.json +5 -2
  329. package/dist/admin/components/chipsInput.css +0 -11
  330. package/dist/admin/results/ArmTable.d.ts +0 -8
  331. package/dist/admin/results/ArmTable.js +0 -113
  332. package/dist/admin/results/VerdictCard.d.ts +0 -9
  333. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  334. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  335. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  336. package/dist/admin/surface/test/TestHeader.js +0 -70
@@ -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,18 +1,33 @@
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) {
7
15
  const controlPath = live.arms.find((a)=>a.kind === 'control')?.pageTarget;
8
- return controlPath && normalisePath(path) !== normalisePath(controlPath) ? {
16
+ if (!controlPath) return {};
17
+ // The Original's URL may be served with a variant's route: its canonical must stay on the Original,
18
+ // never the (noindexed) variant route the host would otherwise derive.
19
+ return normalisePath(path) !== normalisePath(controlPath) ? {
9
20
  robots: {
10
21
  index: false
11
22
  },
12
23
  alternates: {
13
24
  canonical: controlPath
14
25
  }
15
- } : {};
26
+ } : {
27
+ alternates: {
28
+ canonical: controlPath
29
+ }
30
+ };
16
31
  }
17
32
  const archivedPath = manifest ? findNormalisedPathKey(manifest.archivedPages, path) : undefined;
18
33
  const archived = archivedPath === undefined ? undefined : manifest?.archivedPages[archivedPath];
@@ -0,0 +1,7 @@
1
+ import type { Block, Field } from 'payload';
2
+ /** Undo depth expansion only where the collection schema declares relationships.
3
+ * Hidden fields and field-level afterRead hooks are unaffected by depth, so
4
+ * relationship expansion is the only depth-dependent transformation to undo.
5
+ * Other objects (especially JSON content with its own ids) must stay intact.
6
+ */
7
+ export declare function normaliseContentRelationships(data: unknown, fields: Field[], blocks?: Block[], locales?: readonly string[]): unknown;
@@ -0,0 +1,62 @@
1
+ import { contentBlocks } from '../transitions/contentFields.js';
2
+ const object = (value)=>value !== null && typeof value === 'object' && !Array.isArray(value);
3
+ const id = (value)=>object(value) && 'id' in value ? value.id : value;
4
+ /** Undo depth expansion only where the collection schema declares relationships.
5
+ * Hidden fields and field-level afterRead hooks are unaffected by depth, so
6
+ * relationship expansion is the only depth-dependent transformation to undo.
7
+ * Other objects (especially JSON content with its own ids) must stay intact.
8
+ */ export function normaliseContentRelationships(data, fields, blocks = [], locales = []) {
9
+ // With locale 'all', a localized field's value is { [locale]: value }: normalise each locale's value.
10
+ const localeMap = (value)=>locales.length > 0 && object(value) && Object.keys(value).length > 0 && Object.keys(value).every((key)=>locales.includes(key));
11
+ function lexical(value) {
12
+ if (!object(value)) return value;
13
+ const result = {
14
+ ...value
15
+ };
16
+ if (value.type === 'upload' || value.type === 'relationship') result.value = id(value.value);
17
+ if (Array.isArray(value.children)) result.children = value.children.map(lexical);
18
+ if (object(value.root)) result.root = lexical(value.root);
19
+ return result;
20
+ }
21
+ function fieldValue(value, field) {
22
+ if (field.type === 'relationship' || field.type === 'upload') {
23
+ const one = (entry)=>Array.isArray(field.relationTo) && object(entry) ? {
24
+ ...entry,
25
+ value: id(entry.value)
26
+ } : id(entry);
27
+ return field.hasMany && Array.isArray(value) ? value.map(one) : one(value);
28
+ }
29
+ if (field.type === 'richText') return lexical(value);
30
+ if (field.type === 'array' && Array.isArray(value)) return value.map((row)=>walk(row, field.fields));
31
+ if (field.type === 'blocks' && Array.isArray(value)) return value.map((row)=>{
32
+ const block = object(row) && contentBlocks(field, blocks).find((block)=>block.slug === row.blockType);
33
+ return block ? walk(row, block.fields) : row;
34
+ });
35
+ return 'fields' in field ? walk(value, field.fields) : value;
36
+ }
37
+ function walk(value, schema) {
38
+ if (!object(value)) return value;
39
+ let result = {
40
+ ...value
41
+ };
42
+ for (const field of schema){
43
+ if (field.type === 'tabs') {
44
+ for (const tab of field.tabs){
45
+ if ('name' in tab && tab.name) {
46
+ if (tab.name in result) result[tab.name] = walk(result[tab.name], tab.fields);
47
+ } else result = walk(result, tab.fields);
48
+ }
49
+ } else if ('name' in field && field.name) {
50
+ if (field.name in result) {
51
+ const value = result[field.name];
52
+ result[field.name] = 'localized' in field && field.localized && localeMap(value) ? Object.fromEntries(Object.entries(value).map(([locale, entry])=>[
53
+ locale,
54
+ fieldValue(entry, field)
55
+ ])) : fieldValue(value, field);
56
+ }
57
+ } else if ('fields' in field) result = walk(result, field.fields);
58
+ }
59
+ return result;
60
+ }
61
+ return walk(data, fields);
62
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CollectionAfterChangeHook, CollectionBeforeChangeHook } from 'payload';
2
- /** Paired beforeChange capture: previousDoc may be an autosaved draft. */
2
+ /** Paired raw beforeChange capture: previousDoc may be an autosaved draft. */
3
3
  export declare function capturePublishedContent({ req, collection, originalDoc, operation }: Parameters<CollectionBeforeChangeHook>[0]): Promise<void>;
4
4
  type ContentChangeArgs = Pick<Parameters<CollectionAfterChangeHook>[0], 'doc' | 'previousDoc' | 'req' | 'collection'>;
5
5
  /** Publish-only host helper. Audit failures are logged, never thrown into saves. */
@@ -1,9 +1,10 @@
1
1
  import { safeError } from '../log.js';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
- import { appendContentChange, contentChangeSavepoint, previousPublishedContent, runningContentExperiment } from '../db/appends.js';
3
+ import { appendContentChange, contentChangeSavepoint, previousPublishedContent, runningContentExperiment, storedContent } from '../db/appends.js';
4
4
  import { txExecutor } from '../db/executor.js';
5
5
  import { puckCollections } from '../options.js';
6
6
  import { withTransition } from '../transitions/kernel.js';
7
+ import { normaliseContentRelationships } from './normaliseContentRelationships.js';
7
8
  const slots = [
8
9
  'control',
9
10
  'variantA',
@@ -54,8 +55,7 @@ function bindings(data, puck) {
54
55
  return found;
55
56
  }
56
57
  const baselineKey = (collection, id)=>`${collection}:${id}`;
57
- /** Paired beforeChange capture: previousDoc may be an autosaved draft. */ export async function capturePublishedContent({ req, collection, originalDoc, operation }) {
58
- if (!collection.versions || !collection.versions.drafts) return;
58
+ /** Paired raw beforeChange capture: previousDoc may be an autosaved draft. */ export async function capturePublishedContent({ req, collection, originalDoc, operation }) {
59
59
  if (await req.transactionID == null) {
60
60
  req.payload.logger?.debug('[payload-experiments] contentChanges baseline skipped without a transaction');
61
61
  return;
@@ -64,7 +64,7 @@ const baselineKey = (collection, id)=>`${collection}:${id}`;
64
64
  const baselines = req.context.xpPublishedContent ??= {};
65
65
  const key = baselineKey(collection.slug, id);
66
66
  delete baselines[key];
67
- const audit = await contentChangeSavepoint(txExecutor(req), async ()=>operation === 'create' ? undefined : previousPublishedContent(req, collection.slug, id));
67
+ const audit = await contentChangeSavepoint(txExecutor(req), async ()=>operation === 'create' ? undefined : collection.versions && collection.versions.drafts ? previousPublishedContent(req, collection.slug, id) : storedContent(req, collection.slug, id));
68
68
  if (!audit.ok) throw audit.error;
69
69
  baselines[key] = {
70
70
  collection: collection.slug,
@@ -97,8 +97,8 @@ async function auditContentChange({ doc, previousDoc, req, collection }) {
97
97
  }
98
98
  const options = req.payload.config.custom?.xp;
99
99
  const registration = puckCollections(options ?? {}).find((entry)=>entry.slug === collection.slug);
100
- const after = bindings(registration ? doc[registration.field] : doc, Boolean(registration));
101
- const changes = (previous)=>{
100
+ const changes = (previous, current)=>{
101
+ const after = bindings(registration && object(current) ? current[registration.field] : current, Boolean(registration));
102
102
  const before = bindings(registration && object(previous) ? previous[registration.field] : previous, Boolean(registration));
103
103
  return [
104
104
  ...new Set([
@@ -107,12 +107,21 @@ async function auditContentChange({ doc, previousDoc, req, collection }) {
107
107
  ])
108
108
  ].filter((key)=>!isDeepStrictEqual(before.get(key)?.contents, after.get(key)?.contents)).map((key)=>after.get(key) ?? before.get(key));
109
109
  };
110
- if (!drafts && !changes(previousDoc).length) return;
111
110
  // Include discovery reads in the savepoint: a SQL read error also aborts Postgres.
112
111
  // An explicit host call must supply its afterChange request's transaction.
113
112
  const executor = txExecutor(req);
114
113
  const audit = await contentChangeSavepoint(executor, async ()=>{
115
- const changed = changes(drafts ? baseline?.doc : previousDoc);
114
+ // Compare like with like. A captured baseline is adapter-raw, so it meets the stored row after the
115
+ // write. Without one, previousDoc and doc are both afterRead documents (same locale projection,
116
+ // hidden fields and field hooks) that differ only in depth, so only relationships are normalised.
117
+ const locales = req.payload.config.localization ? req.payload.config.localization.localeCodes : [];
118
+ const normalise = (value)=>normaliseContentRelationships(value, collection.fields, req.payload.config.blocks, locales);
119
+ let changed;
120
+ if (baseline) {
121
+ const current = await storedContent(req, collection.slug, doc.id);
122
+ if (!current) throw new Error('The saved content row was not found.');
123
+ changed = changes(baseline.doc, current);
124
+ } else changed = changes(normalise(previousDoc), normalise(doc));
116
125
  const experiments = new Map();
117
126
  for (const binding of changed){
118
127
  const experiment = await runningContentExperiment(executor, binding);
@@ -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;
@@ -1,4 +1,6 @@
1
+ import { XpAnalysisError } from '../errors.js';
1
2
  import { safeError } from '../log.js';
3
+ import { createLocalReq } from 'payload';
2
4
  import { resultsCandidates, resultsExecutor, writeSnapshots } from '../db/snapshots.js';
3
5
  import { sweepEvents } from '../db/sweep.js';
4
6
  import { armMismatchCount, identityRows, metricEventRows, phaseExposureRows, rolloutExposureRows } from '../db/results.js';
@@ -6,8 +8,8 @@ import { raceDeadline, ResultsBudgetError, RESULTS_TIMEOUT_MARGIN_MS } from '../
6
8
  import { analysePhase } from '../results/analyse.js';
7
9
  import { firstExposures, perUnitValues, timestampMs } from '../results/aggregate.js';
8
10
  import { rolloutAnalysisUnits } from '../results/rolloutUnits.js';
9
- import { creditUserEvents } from '../results/identityCredit.js';
10
- async function analyseCandidate(exec, candidate, options, now, deadline, unavailableMetrics) {
11
+ import { creditUserEvents, eligibleIdentityLinks } from '../results/identityCredit.js';
12
+ async function analyseCandidate(exec, candidate, options, now, deadline, unavailableMetrics, logger) {
11
13
  const { phase } = candidate;
12
14
  // Rollout assignment preserves the original test ranges, but its SRM compares
13
15
  // only the shipped group against holdout with weights {100-h, h}.
@@ -59,23 +61,54 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
59
61
  ...new Set(identities.map((identity)=>identity.userId))
60
62
  ];
61
63
  const linked = new Set(userIds);
64
+ const identityConflicts = new Set();
65
+ const userByVisitor = new Map(identities.map((identity)=>[
66
+ identity.anonymousId,
67
+ identity.userId
68
+ ]));
69
+ const linksByUser = new Map();
70
+ for (const identity of identities){
71
+ const links = linksByUser.get(identity.userId) ?? [];
72
+ links.push(identity);
73
+ linksByUser.set(identity.userId, links);
74
+ }
75
+ // Last arm credited per user: enough to detect that a user's credited events span arms (a conflict).
76
+ const creditedArms = new Map();
77
+ const noteCredit = (userId, unitId)=>{
78
+ const arm = units.get(unitId)?.arm;
79
+ if (arm === undefined) return;
80
+ const previous = creditedArms.get(userId);
81
+ if (previous !== undefined && previous !== arm) identityConflicts.add(userId);
82
+ creditedArms.set(userId, arm);
83
+ };
62
84
  const credit = (events, userKeyed)=>{
63
85
  if (candidate.unitType === 'user') return events.map((event)=>({
64
86
  ...event,
65
87
  unitId: event.userId ?? event.unitId
66
88
  }));
67
- const userEvents = events.filter((event)=>userKeyed || event.userId != null && linked.has(event.userId)).map((event)=>({
89
+ // §8.3's earliest exposed linked visitor applies only when the event has no
90
+ // exposed unit of its own. A visitor-keyed event stays on its own exposed
91
+ // browser even if an earlier linked browser saw another arm or is ineligible.
92
+ const direct = userKeyed ? [] : events.filter((event)=>units.has(event.unitId) || event.userId == null || !linked.has(event.userId));
93
+ const userEvents = events.filter((event)=>userKeyed || !units.has(event.unitId) && event.userId != null && linked.has(event.userId)).map((event)=>({
68
94
  userId: String(event.userId ?? event.unitId),
69
95
  value: event.value,
70
96
  ts: event.ts
71
97
  }));
72
- const credited = creditUserEvents(units, identities, userEvents).events;
73
- return userKeyed ? credited : [
74
- ...events.filter((event)=>event.userId == null || !linked.has(event.userId)),
98
+ const credited = creditUserEvents(units, identities, userEvents, (userId)=>identityConflicts.add(userId)).events;
99
+ for (const event of direct)if (event.userId != null && linked.has(event.userId)) {
100
+ noteCredit(event.userId, event.unitId);
101
+ // Keep the eligible-link diagnostic when direct credit bypasses the helper.
102
+ const arms = new Set(eligibleIdentityLinks(linksByUser.get(event.userId), event.ts).map((link)=>units.get(link.anonymousId).arm));
103
+ arms.add(units.get(event.unitId).arm);
104
+ if (arms.size > 1) identityConflicts.add(event.userId);
105
+ }
106
+ for (const event of credited)noteCredit(userByVisitor.get(event.unitId), event.unitId);
107
+ return [
108
+ ...direct,
75
109
  ...credited
76
110
  ];
77
111
  };
78
- const diagnostics = creditUserEvents(units, identities, []);
79
112
  const metricData = [];
80
113
  for (const definition of phase.metricDefinitions){
81
114
  try {
@@ -89,7 +122,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
89
122
  let events, covariates;
90
123
  if (definition.source.startsWith('events:')) {
91
124
  const eventName = definition.source.slice(7);
92
- if (!options.eventSources?.some((source)=>source.name === eventName)) throw new Error('Metric source unavailable');
125
+ if (!options.eventSources?.some((source)=>source.name === eventName)) throw new XpAnalysisError('source-unavailable');
93
126
  const read = async (start)=>{
94
127
  if (candidate.unitType === 'user') return metricEventRows(exec, {
95
128
  eventName,
@@ -109,9 +142,10 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
109
142
  from: start,
110
143
  to
111
144
  });
145
+ // The two lookups overlap when a linked user's own browser was exposed.
112
146
  return [
113
147
  ...direct,
114
- ...signedIn
148
+ ...signedIn.filter((event)=>!units.has(event.unitId))
115
149
  ];
116
150
  };
117
151
  events = credit(await read(phase.analysis.cuped === 'auto' ? preFrom : from), false);
@@ -132,9 +166,9 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
132
166
  }
133
167
  } else {
134
168
  const source = options.metricSources?.find((source)=>source.id === definition.source);
135
- if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new Error('Metric source unavailable');
169
+ if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new XpAnalysisError('source-unavailable');
136
170
  const userKeyed = source.keyedBy === 'user' && candidate.unitType === 'visitor';
137
- if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new Error('Metric unit type unavailable');
171
+ if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new XpAnalysisError('unit-type-unavailable');
138
172
  const sourceIds = userKeyed ? userIds : ids;
139
173
  events = credit(await raceDeadline(deadline, ()=>source.query({
140
174
  from,
@@ -142,35 +176,51 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
142
176
  unitIds: sourceIds
143
177
  })), userKeyed);
144
178
  if (phase.analysis.cuped === 'auto' && source.covariate) {
145
- const sourceUnits = userKeyed ? creditUserEvents(units, identities, userIds.map((userId)=>({
146
- userId,
147
- ts: now,
148
- value: 0
149
- }))).events.map((event)=>({
150
- unitId: identities.find((identity)=>identity.anonymousId === event.unitId).userId,
151
- firstExposureTs: units.get(event.unitId).firstTs
179
+ const userUnits = new Map();
180
+ if (userKeyed) for (const link of identities){
181
+ const unit = units.get(link.anonymousId);
182
+ if (!unit) continue;
183
+ // Hosts aggregate pre-period values in SQL; per-event rows over 14 days would break the one-row-per-unit contract and multiply host traffic.
184
+ // CUPED only reduces variance: mis-credited pre-exposure values add noise, but cannot bias the effect because they are independent of assignment.
185
+ if (!eligibleIdentityLinks([
186
+ link
187
+ ], unit.firstTs).length) continue;
188
+ const previous = userUnits.get(link.userId);
189
+ if (!previous || +unit.firstTs < +previous.firstExposureTs || +unit.firstTs === +previous.firstExposureTs && link.anonymousId < previous.unitId) {
190
+ userUnits.set(link.userId, {
191
+ unitId: link.anonymousId,
192
+ firstExposureTs: unit.firstTs
193
+ });
194
+ }
195
+ }
196
+ const sourceUnits = userKeyed ? [
197
+ ...userUnits
198
+ ].map(([unitId, unit])=>({
199
+ unitId,
200
+ firstExposureTs: unit.firstExposureTs
152
201
  })) : [
153
202
  ...units
154
203
  ].map(([unitId, unit])=>({
155
204
  unitId,
156
- firstExposureTs: unit.firstTs
205
+ firstExposureTs: new Date(timestampMs(unit.firstTs))
157
206
  }));
158
207
  const pre = await raceDeadline(deadline, ()=>source.covariate({
159
208
  units: sourceUnits,
160
209
  lookbackDays: 14
161
210
  }));
162
- if (pre.some((row)=>!Number.isFinite(row.value))) throw new Error('Covariates must be finite');
163
- const credited = credit(pre.map((row)=>({
164
- ...row,
165
- ts: now
166
- })), userKeyed);
167
- covariates = new Map(credited.map((row)=>[
168
- row.unitId,
169
- row.value
170
- ]));
211
+ if (pre.some((row)=>!Number.isFinite(row.value))) throw new XpAnalysisError('non-finite-covariates');
212
+ covariates = new Map(pre.flatMap((row)=>{
213
+ const unitId = userKeyed ? userUnits.get(row.unitId)?.unitId : row.unitId;
214
+ return unitId === undefined ? [] : [
215
+ [
216
+ unitId,
217
+ row.value
218
+ ]
219
+ ];
220
+ }));
171
221
  }
172
222
  }
173
- if (events.some((event)=>!Number.isFinite(event.value))) throw new Error('Metric values must be finite');
223
+ if (events.some((event)=>!Number.isFinite(event.value))) throw new XpAnalysisError('non-finite-values');
174
224
  metricData.push({
175
225
  metricId: definition.metricId,
176
226
  values: perUnitValues(units, events, {
@@ -183,7 +233,22 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
183
233
  } : {}
184
234
  });
185
235
  } catch (error) {
186
- const reason = error instanceof Error ? error.message : 'Metric source unavailable';
236
+ let reason = error instanceof ResultsBudgetError ? 'budget' : 'source-failed';
237
+ if (error instanceof XpAnalysisError) {
238
+ switch(error.code){
239
+ case 'source-unavailable':
240
+ case 'unit-type-unavailable':
241
+ case 'non-finite-values':
242
+ case 'non-finite-covariates':
243
+ reason = error.code;
244
+ break;
245
+ }
246
+ }
247
+ logger.error({
248
+ err: safeError(error),
249
+ experimentId: phase.experiment,
250
+ metricId: definition.metricId
251
+ }, 'Experiment metric source failed');
187
252
  metricData.push({
188
253
  metricId: definition.metricId,
189
254
  values: new Map(),
@@ -205,9 +270,14 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
205
270
  units,
206
271
  metricData,
207
272
  status,
273
+ onMetricError: (error, metricId)=>logger.error({
274
+ err: safeError(error),
275
+ experimentId: phase.experiment,
276
+ metricId
277
+ }, 'Experiment metric analysis failed'),
208
278
  excludedMultiArm: exposed.excludedMultiArm,
209
279
  armMismatch: await armMismatchCount(exec, query),
210
- identityConflictShare: userIds.length ? diagnostics.conflicts / userIds.length : 0,
280
+ identityConflictShare: userIds.length ? identityConflicts.size / userIds.length : 0,
211
281
  ...phase.phaseKind === 'rollout' ? {
212
282
  winnerArm: phase.arms.find((arm)=>arm.kind !== 'holdout').key
213
283
  } : {}
@@ -252,8 +322,8 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
252
322
  experimentId
253
323
  }, 'Experiment scheduling failed');
254
324
  }
255
- // Query order is oldest phase snapshot first; grouping keeps each experiment's
256
- // phases together while preserving that scheduling priority.
325
+ // Analysis precedes health-only refreshes, then oldest phase snapshots first;
326
+ // grouping keeps each experiment's phases together with that priority.
257
327
  const experiments = new Map();
258
328
  for (const candidate of candidates){
259
329
  const id = candidate.phase.experiment;
@@ -272,10 +342,62 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
272
342
  const snapshots = [];
273
343
  for (const phase of phases){
274
344
  if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
275
- snapshots.push(await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics));
345
+ // Health refreshes must not reopen inference after the late-beacon grace.
346
+ snapshots.push(phase.frozenSnapshot ? {
347
+ ...phase.frozenSnapshot,
348
+ computedAt: now.toISOString()
349
+ } : await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics, payload.logger));
350
+ }
351
+ if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
352
+ const current = phases.find((candidate)=>candidate.kind === 'page' && candidate.phase.phase === candidate.currentPhase && (candidate.status === 'running' || candidate.status === 'decided'));
353
+ if (current) {
354
+ const req = await raceDeadline(analysisDeadline, ()=>createLocalReq({}, payload));
355
+ const health = [];
356
+ // Decided tests serve only the winner and Original, including rollouts.
357
+ const arms = current.phase.phaseKind === 'rollout' ? [
358
+ ...current.phase.arms.filter((arm)=>arm.kind !== 'holdout'),
359
+ ...current.testArms.filter((arm)=>arm.kind === 'control')
360
+ ] : current.testArms.filter((arm)=>arm.kind !== 'holdout' && (current.status !== 'decided' || arm.kind === 'control' || arm.key === current.winnerArm));
361
+ const targets = new Set(arms.flatMap((arm)=>arm.pageTarget ? [
362
+ arm.pageTarget
363
+ ] : []));
364
+ for (const path of targets){
365
+ let published = false, found = false, unchecked = false;
366
+ for (const collection of options.pages?.collections ?? []){
367
+ try {
368
+ const page = await raceDeadline(analysisDeadline, ()=>collection.findByPath(path, req));
369
+ found ||= page != null;
370
+ published ||= page?.published === true;
371
+ } catch (error) {
372
+ if (error instanceof ResultsBudgetError) throw error;
373
+ payload.logger.error({
374
+ err: safeError(error),
375
+ experimentId: id
376
+ }, 'Experiment version page lookup failed');
377
+ unchecked = true;
378
+ }
379
+ }
380
+ if (!published) health.push({
381
+ code: !found && (unchecked || !options.pages?.collections.length) ? 'version-page-unchecked' : 'version-page-unavailable',
382
+ path
383
+ });
384
+ }
385
+ for (const snapshot of snapshots)snapshot.health = health;
276
386
  }
277
387
  if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
278
- await writeSnapshots(exec, snapshots);
388
+ // Order and optional empty health are semantically identical. Frozen
389
+ // inference gets a new row only when its page availability changes.
390
+ const canonicalHealth = (snapshot)=>JSON.stringify((snapshot.health ?? []).map(({ code, path })=>[
391
+ code,
392
+ path
393
+ ]).map((entry)=>JSON.stringify(entry)).sort((a, b)=>a < b ? -1 : a > b ? 1 : 0));
394
+ const changed = snapshots.filter((snapshot, index)=>!phases[index].frozenSnapshot || canonicalHealth(snapshot) !== canonicalHealth(phases[index].frozenSnapshot));
395
+ // Unchanged health is a successful refresh with nothing to write.
396
+ if (!changed.length) {
397
+ summary.ran++;
398
+ continue;
399
+ }
400
+ await writeSnapshots(exec, changed);
279
401
  summary.ran++;
280
402
  if (summary.processedIds.length < 100) summary.processedIds.push(id);
281
403
  } catch (error) {
@@ -0,0 +1,4 @@
1
+ /** Trim and deduplicate list inputs while preserving commas inside array items. */
2
+ export declare function coerceListValue(raw: unknown): string[];
3
+ /** Only the read boundary accepts legacy comma-separated document values. */
4
+ export declare function readListValue(raw: unknown): string[];
@@ -0,0 +1,10 @@
1
+ /** Trim and deduplicate list inputs while preserving commas inside array items. */ export function coerceListValue(raw) {
2
+ return [
3
+ ...new Set((Array.isArray(raw) ? raw.map(String) : [
4
+ String(raw ?? '')
5
+ ]).map((value)=>value.trim()).filter(Boolean))
6
+ ];
7
+ }
8
+ /** Only the read boundary accepts legacy comma-separated document values. */ export function readListValue(raw) {
9
+ return coerceListValue(typeof raw === 'string' ? raw.split(',') : raw);
10
+ }
@@ -1,3 +1,4 @@
1
+ import { lockMetricsForFreeze } from '../db/metricUsage.js';
1
2
  import { readState } from '../db/state.js';
2
3
  import { XpRefusal } from '../errors.js';
3
4
  import { relationshipId } from './checkStart.js';
@@ -44,7 +45,14 @@ export async function addMetric(req, { experimentId, metric, role, options }) {
44
45
  if (ids.some((id)=>id !== undefined && String(id) === String(metric))) refuse('duplicate-metric', {
45
46
  message: 'This metric is already part of the experiment.'
46
47
  });
48
+ const lockedMetrics = await lockMetricsForFreeze(exec, caps.definitions.map((metric)=>metric.id));
49
+ if (!lockedMetrics) {
50
+ throw new XpRefusal('stale-draft', 'This goal was deleted. Choose another goal.', undefined, 409);
51
+ }
47
52
  const definition = caps.definitions[0];
53
+ if (lockedMetrics[0].slug !== definition.slug || lockedMetrics[0].source !== definition.source) {
54
+ throw new XpRefusal('stale-draft', 'This goal changed. Review it again before adding it.', undefined, 409);
55
+ }
48
56
  if (current.unitType === 'user' && definition.keyedBy === 'visitor' && !definition.source.startsWith('events:')) refuse('unit-source-mismatch');
49
57
  // §7.8: phase lookup serves the access-authorised transition write.
50
58
  const phases = await req.payload.find({
@@ -1,3 +1,4 @@
1
+ import { contentDone } from '../contentDone.js';
1
2
  import { relationshipId } from './checkStart.js';
2
3
  import { expectedConfirm, matchesConfirm, storedWinnerLabel } from './confirmPhrases.js';
3
4
  import { withTransition } from './kernel.js';
@@ -14,9 +15,7 @@ export async function archive(req, { experimentId, mode, confirmation }) {
14
15
  if (mode === 'stopped' ? current.status !== 'stopped' : current.status !== 'decided') refuse('invalid-status');
15
16
  if (mode === 'hard-coded' && current.kind !== 'code') refuse('invalid-status');
16
17
  if (mode === 'after-content') {
17
- const report = decision?.contentReport;
18
- const docs = Array.isArray(report) ? report : report?.docs;
19
- if (!Array.isArray(docs) || !docs.every((doc)=>doc?.ok === true)) refuse('invalid-status');
18
+ if (!contentDone(decision?.contentReport)) refuse('invalid-status');
20
19
  }
21
20
  if (mode === 'discard' && !matchesConfirm(expectedConfirm('discard', ''), confirmation)) refuse('confirmation-required');
22
21
  if (mode === 'hard-coded') {