@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
@@ -0,0 +1,173 @@
1
+ import { addHoldout, checkArms } from '../collections/experiments/armInvariants.js';
2
+ import { ARM_LABELS as GROUP_LABELS } from '../labels/arms.js';
3
+ const VARIANTS = [
4
+ 'variantA',
5
+ 'variantB',
6
+ 'variantC'
7
+ ];
8
+ export function defaultArms(kind) {
9
+ return withSlotsFor([
10
+ {
11
+ key: 'control',
12
+ kind: 'control',
13
+ label: GROUP_LABELS.control,
14
+ weight: 50
15
+ },
16
+ {
17
+ key: 'variantA',
18
+ kind: 'variant',
19
+ label: GROUP_LABELS.variantA,
20
+ weight: 50
21
+ }
22
+ ], kind);
23
+ }
24
+ /** Keep content slots in step with the experiment kind, including an unset kind. */ export function withSlotsFor(arms, kind) {
25
+ const next = arms.map((arm)=>{
26
+ if (kind === 'block' && arm.kind !== 'holdout') return arm.slot === arm.key ? arm : {
27
+ ...arm,
28
+ slot: arm.key
29
+ };
30
+ // Postgres returns null for unset columns; null counts as "no slot" so a loaded doc is not rewritten.
31
+ if (arm.slot == null) return arm;
32
+ const { slot: _slot, ...rest } = arm;
33
+ return rest;
34
+ });
35
+ return next.every((arm, index)=>arm === arms[index]) ? arms : next;
36
+ }
37
+ const finiteWeight = (weight)=>Number.isFinite(weight) ? weight : 0;
38
+ export function totalWeight(arms) {
39
+ return arms.reduce((sum, arm)=>sum + finiteWeight(arm.weight), 0);
40
+ }
41
+ export function addVariant(arms, kind) {
42
+ if (!arms.some((arm)=>arm.kind === 'control')) return arms;
43
+ const key = VARIANTS.find((candidate)=>!arms.some((arm)=>arm.key === candidate));
44
+ if (!key || arms.filter((arm)=>arm.kind === 'variant').length >= 3) return arms;
45
+ const block = kind === 'block' || kind === undefined && arms.some((arm)=>arm.slot !== undefined);
46
+ const variant = {
47
+ key,
48
+ kind: 'variant',
49
+ label: GROUP_LABELS[key],
50
+ weight: 0,
51
+ ...block ? {
52
+ slot: key
53
+ } : {}
54
+ };
55
+ const index = arms.findIndex((arm)=>arm.kind === 'holdout');
56
+ const result = [
57
+ ...arms
58
+ ];
59
+ result.splice(index < 0 ? arms.length : index, 0, variant);
60
+ return result;
61
+ }
62
+ export function removeArm(arms, key) {
63
+ if (arms.some((arm)=>arm.key === key && arm.kind === 'control')) return arms;
64
+ if (key === 'holdout') return removeHoldout(arms);
65
+ return arms.filter((arm)=>arm.key !== key);
66
+ }
67
+ export function addHoldoutArm(arms) {
68
+ if (!arms.some((arm)=>arm.kind === 'control') || arms.some((arm)=>arm.kind === 'holdout' || arm.key === 'holdout')) return arms;
69
+ return addHoldout(arms.map((arm)=>Number.isFinite(arm.weight) ? arm : {
70
+ ...arm,
71
+ weight: 0
72
+ }));
73
+ }
74
+ // Largest remainder, with equal fractional remainders awarded to Original first.
75
+ function distribute(arms, total, proportional) {
76
+ if (!arms.length) return arms;
77
+ const weightTotal = totalWeight(arms);
78
+ const useWeights = proportional && weightTotal > 0;
79
+ const denominator = useWeights ? weightTotal : arms.length;
80
+ const numerators = arms.map((arm)=>(useWeights ? finiteWeight(arm.weight) : 1) * total);
81
+ const weights = numerators.map((numerator)=>Math.floor(numerator / denominator));
82
+ const order = arms.map((_, i)=>i).sort((a, b)=>numerators[b] % denominator - numerators[a] % denominator || Number(arms[b].kind === 'control') - Number(arms[a].kind === 'control') || a - b);
83
+ const remainder = total - weights.reduce((sum, weight)=>sum + weight, 0);
84
+ for(let i = 0; i < remainder; i++)weights[order[i % order.length]]++;
85
+ return arms.map((arm, i)=>({
86
+ ...arm,
87
+ weight: weights[i]
88
+ }));
89
+ }
90
+ export function removeHoldout(arms) {
91
+ if (!arms.some((arm)=>arm.kind === 'holdout')) return arms;
92
+ return distribute(arms.filter((arm)=>arm.kind !== 'holdout'), totalWeight(arms), true);
93
+ }
94
+ export function splitEvenly(arms) {
95
+ const holdout = arms.find((arm)=>arm.kind === 'holdout');
96
+ const weight = finiteWeight(holdout?.weight ?? 0);
97
+ return [
98
+ ...distribute(arms.filter((arm)=>arm.kind !== 'holdout'), 100 - weight, false),
99
+ ...holdout ? [
100
+ {
101
+ ...holdout,
102
+ weight
103
+ }
104
+ ] : []
105
+ ];
106
+ }
107
+ export function setWeight(arms, key, weight) {
108
+ return arms.map((arm)=>arm.key === key ? {
109
+ ...arm,
110
+ weight
111
+ } : arm);
112
+ }
113
+ export function issuesByRow(arms, kind) {
114
+ const rows = arms.map(()=>[]);
115
+ const form = [];
116
+ for (const issue of checkArms(arms, kind)){
117
+ const index = issue.armIndex ?? arms.findIndex((arm)=>arm.key === issue.armKey);
118
+ if (index >= 0 && index < arms.length) rows[index].push(issue);
119
+ else form.push(issue);
120
+ }
121
+ return {
122
+ rows,
123
+ form
124
+ };
125
+ }
126
+ export const ARM_FIELDS = [
127
+ 'key',
128
+ 'label',
129
+ 'weight',
130
+ 'kind',
131
+ 'slot',
132
+ 'params',
133
+ 'pageTarget'
134
+ ];
135
+ /** Payload arrays store each row's values at separate field paths. */ export function readArms(fields, path = 'arms') {
136
+ const indices = new Set();
137
+ for (const name of Object.keys(fields)){
138
+ if (!name.startsWith(`${path}.`)) continue;
139
+ const [index, field] = name.slice(path.length + 1).split('.');
140
+ if (/^\d+$/.test(index) && field === 'key') indices.add(Number(index));
141
+ }
142
+ return [
143
+ ...indices
144
+ ].sort((a, b)=>a - b).map((index)=>Object.fromEntries(ARM_FIELDS.flatMap((field)=>{
145
+ const value = fields[`${path}.${index}.${field}`]?.value;
146
+ return value === undefined ? [] : [
147
+ [
148
+ field,
149
+ value
150
+ ]
151
+ ];
152
+ })));
153
+ }
154
+ /** Keep the edited share exact and distribute the remaining whole percentages proportionally. */ export function balanceWeight(arms, key, weight) {
155
+ if (!arms.some((arm)=>arm.key === key) || !Number.isInteger(weight) || weight < 0 || weight > 100) return arms;
156
+ const others = arms.filter((arm)=>arm.key !== key);
157
+ if (!others.length) return arms;
158
+ const total = others.reduce((sum, arm)=>sum + Math.max(0, Number.isFinite(arm.weight) ? arm.weight : 0), 0);
159
+ const denominator = total || others.length;
160
+ const raw = others.map((arm)=>(total ? Math.max(0, Number.isFinite(arm.weight) ? arm.weight : 0) : 1) * (100 - weight) / denominator);
161
+ const whole = raw.map(Math.floor);
162
+ const order = raw.map((_, i)=>i).sort((a, b)=>raw[b] - whole[b] - (raw[a] - whole[a]) || a - b);
163
+ const remainder = 100 - weight - whole.reduce((sum, value)=>sum + value, 0);
164
+ for(let i = 0; i < remainder; i++)whole[order[i]]++;
165
+ const shares = new Map(others.map((arm, i)=>[
166
+ arm.key,
167
+ whole[i]
168
+ ]));
169
+ return setWeight(arms, key, weight).map((arm)=>shares.has(arm.key) ? {
170
+ ...arm,
171
+ weight: shares.get(arm.key)
172
+ } : arm);
173
+ }
@@ -2,12 +2,14 @@ import * as paths from '../../admin/paths.js';
2
2
  import { managedAccess } from '../access.js';
3
3
  import { serializeAttributeKeys, serializeFlagPoints } from '../serialize.js';
4
4
  import { TARGETING_OPS, isOpValid } from '../../targeting/ops.js';
5
+ import { readListValue } from '../../targeting/values.js';
5
6
  import { createDefaults } from './createDefaults.js';
6
7
  import { flagControlParams } from './flagControlParams.js';
7
8
  import { lockHook } from './lockHook.js';
8
9
  import { deleteGuard, deleteGuardAfter } from './deleteGuard.js';
9
10
  import { checkArms } from './armInvariants.js';
10
11
  import { XpNotImplemented, XpRefusal } from '../../errors.js';
12
+ const targetingValue = (rule)=>rule.op === 'in' || rule.op === 'nin' ? readListValue(rule.value) : rule.value;
11
13
  const positiveInteger = (value)=>value != null && Number.isInteger(value) && value > 0 || 'Enter a positive whole number.';
12
14
  export function experimentsCollection(options) {
13
15
  const flagPoints = serializeFlagPoints(options);
@@ -224,6 +226,21 @@ export function experimentsCollection(options) {
224
226
  {
225
227
  name: 'targeting',
226
228
  type: 'array',
229
+ validate: (value, { data, previousValue })=>{
230
+ // Payload 3.90.2 beforeChange/promise.js passes merged document data
231
+ // and this document's stored array as previousValue, including in bulk updates.
232
+ const status = data?.status ?? 'draft';
233
+ if (data?.kind === 'block' && (status === 'draft' || status === 'stopped')) {
234
+ for (const rule of value ?? []){
235
+ if (rule.attribute !== 'path') continue;
236
+ const previous = rule.id == null ? undefined : previousValue?.find((stored)=>stored.id != null && String(stored.id) === String(rule.id));
237
+ if (!previous || previous.attribute !== rule.attribute || previous.op !== rule.op || JSON.stringify(targetingValue(previous)) !== JSON.stringify(targetingValue(rule))) {
238
+ return 'Page block tests run on their page; remove the page-path rule.';
239
+ }
240
+ }
241
+ }
242
+ return true;
243
+ },
227
244
  fields: [
228
245
  {
229
246
  name: 'attribute',
@@ -477,6 +494,14 @@ export function experimentsCollection(options) {
477
494
  {
478
495
  name: 'holdoutReleasedAt',
479
496
  type: 'date'
497
+ },
498
+ {
499
+ name: 'pagesReleasedAt',
500
+ type: 'date'
501
+ },
502
+ {
503
+ name: 'pagesReleasedBy',
504
+ type: 'text'
480
505
  }
481
506
  ]
482
507
  }
@@ -12,6 +12,7 @@ const relationships = new Set([
12
12
  'metrics.secondary',
13
13
  'metrics.guardrails',
14
14
  'decision.decidedBy',
15
+ 'decision.pagesReleasedBy',
15
16
  'decision.snapshotId'
16
17
  ]);
17
18
  const dates = new Set([
@@ -20,7 +21,8 @@ const dates = new Set([
20
21
  'decidedAt',
21
22
  'archivedAt',
22
23
  'decision.discardedAt',
23
- 'decision.holdoutReleasedAt'
24
+ 'decision.holdoutReleasedAt',
25
+ 'decision.pagesReleasedAt'
24
26
  ]);
25
27
  /** Schema-aware: JSON retains its own ids and date-like strings. */ function normalize(value, path, json = false) {
26
28
  json ||= [
@@ -19,12 +19,17 @@ export function identitiesCollection(options) {
19
19
  {
20
20
  name: 'userId',
21
21
  type: 'text',
22
- required: true
22
+ required: true,
23
+ index: true
23
24
  },
24
25
  {
25
26
  name: 'linkedAt',
26
27
  type: 'date',
27
28
  required: true
29
+ },
30
+ {
31
+ name: 'verifiedAt',
32
+ type: 'date'
28
33
  }
29
34
  ]
30
35
  };
@@ -2,6 +2,7 @@ import { XP_METRIC_SOURCE_SELECT } from '../admin/paths.js';
2
2
  import { managedAccess } from './access.js';
3
3
  import { serializeMetricSources } from './serialize.js';
4
4
  import { validateCappingType, validateCappingValue } from './metricValidation.js';
5
+ import { metricChangeGuard, metricDeleteGuard } from './metricsGuards.js';
5
6
  export function metricsCollection(options) {
6
7
  const sources = serializeMetricSources(options);
7
8
  return {
@@ -20,6 +21,12 @@ export function metricsCollection(options) {
20
21
  },
21
22
  access: managedAccess(options),
22
23
  hooks: {
24
+ beforeDelete: [
25
+ metricDeleteGuard
26
+ ],
27
+ beforeChange: [
28
+ metricChangeGuard
29
+ ],
23
30
  beforeValidate: [
24
31
  ({ data, originalDoc })=>{
25
32
  if (!data) return data;
@@ -0,0 +1,9 @@
1
+ import type { CollectionBeforeChangeHook, CollectionBeforeDeleteHook } from 'payload';
2
+ /** Payload's REST formatter preserves public plugin errors and their 4xx status. */
3
+ export declare class XpGoalInUseError extends Error {
4
+ name: string;
5
+ readonly status = 400;
6
+ readonly isPublic = true;
7
+ }
8
+ export declare const metricDeleteGuard: CollectionBeforeDeleteHook;
9
+ export declare const metricChangeGuard: CollectionBeforeChangeHook;
@@ -0,0 +1,33 @@
1
+ import { pooledExecutor, txExecutor } from '../db/executor.js';
2
+ import { findMetricUsage, lockMetricRow } from '../db/metricUsage.js';
3
+ /** Payload's REST formatter preserves public plugin errors and their 4xx status. */ export class XpGoalInUseError extends Error {
4
+ name = 'XpGoalInUseError';
5
+ status = 400;
6
+ isPublic = true;
7
+ }
8
+ async function executor(req, id, mode) {
9
+ const transactionID = await req.transactionID;
10
+ if (transactionID != null) {
11
+ const exec = txExecutor(req);
12
+ await lockMetricRow(exec, id, mode);
13
+ return exec;
14
+ }
15
+ // With no transaction a pooled row lock would expire before the usage read;
16
+ // retain the existing best-effort usage check without taking a lock.
17
+ return pooledExecutor(req.payload);
18
+ }
19
+ export const metricDeleteGuard = async ({ req, id })=>{
20
+ const usage = await findMetricUsage(await executor(req, id, 'delete'), id);
21
+ if (usage) throw new XpGoalInUseError(`This goal is used by the test "${usage.name}". Remove it from the test first.`);
22
+ };
23
+ export const metricChangeGuard = async ({ req, data, originalDoc, operation })=>{
24
+ if (operation !== 'update') return data;
25
+ const changed = [
26
+ 'slug',
27
+ 'source'
28
+ ].some((field)=>field in data && data[field] !== originalDoc[field]);
29
+ if (changed && await findMetricUsage(await executor(req, originalDoc.id, 'change'), originalDoc.id)) {
30
+ throw new XpGoalInUseError("This goal is used by a test, so its short name and what it measures can't change. Create a new goal instead.");
31
+ }
32
+ return data;
33
+ };
@@ -0,0 +1,4 @@
1
+ import type { ContentReport } from './types/endpoints.js';
2
+ /** Every recorded content entry is updated or handled by the manager. A present but empty report is done:
3
+ * page tests and flag tests without a host callback record no entries. An absent report is not done. */
4
+ export declare function contentDone(report: ContentReport | null | undefined): boolean;
@@ -0,0 +1,5 @@
1
+ /** Every recorded content entry is updated or handled by the manager. A present but empty report is done:
2
+ * page tests and flag tests without a host callback record no entries. An absent report is not done. */ export function contentDone(report) {
3
+ const docs = Array.isArray(report) ? report : report?.docs;
4
+ return Array.isArray(docs) && docs.every((doc)=>doc?.ok === true || Boolean(doc?.dismissedAt));
5
+ }
@@ -19,9 +19,13 @@ type AppendArgs<T> = {
19
19
  phase: number;
20
20
  entry: T;
21
21
  };
22
+ /** Raw main-row read on the host transaction, without afterRead expansion.
23
+ * Adapter reads avoid Local API error handling killing the host transaction
24
+ * before the audit savepoint can recover.
25
+ */
26
+ export declare function storedContent(req: PayloadRequest, collection: string, id: DocId): Promise<Record<string, unknown> | undefined>;
22
27
  /** Read the main collection row BEFORE the host write. Draft version retention
23
- * and unpublishing cannot remove this baseline. Adapter reads avoid Local API
24
- * error handling killing the host transaction before savepoint recovery.
28
+ * and unpublishing cannot remove this baseline.
25
29
  */
26
30
  export declare function previousPublishedContent(req: PayloadRequest, collection: string, id: DocId): Promise<Record<string, unknown> | undefined>;
27
31
  /** Serialize flagDefaults read/modify/write before the manifest builder bumps. */
@@ -1,10 +1,10 @@
1
1
  import { COLUMNS, TABLES } from './names.js';
2
2
  import { XpRefusal } from '../errors.js';
3
3
  import { qualifiedTable, quoteIdentifier as quote } from './sql.js';
4
- /** Read the main collection row BEFORE the host write. Draft version retention
5
- * and unpublishing cannot remove this baseline. Adapter reads avoid Local API
6
- * error handling killing the host transaction before savepoint recovery.
7
- */ export async function previousPublishedContent(req, collection, id) {
4
+ /** Raw main-row read on the host transaction, without afterRead expansion.
5
+ * Adapter reads avoid Local API error handling killing the host transaction
6
+ * before the audit savepoint can recover.
7
+ */ export async function storedContent(req, collection, id) {
8
8
  const doc = await req.payload.db.findOne({
9
9
  collection,
10
10
  req,
@@ -16,6 +16,12 @@ import { qualifiedTable, quoteIdentifier as quote } from './sql.js';
16
16
  // Adapter reads have no access layer; document the audit's access posture.
17
17
  overrideAccess: false
18
18
  });
19
+ return doc ?? undefined;
20
+ }
21
+ /** Read the main collection row BEFORE the host write. Draft version retention
22
+ * and unpublishing cannot remove this baseline.
23
+ */ export async function previousPublishedContent(req, collection, id) {
24
+ const doc = await storedContent(req, collection, id);
19
25
  if (!doc) return undefined;
20
26
  // A draft created with content is already in the main table. Only the
21
27
  // existence of a published version matters here; never use its content as
@@ -1,11 +1,12 @@
1
1
  import type { Payload } from 'payload';
2
2
  import { type Executor } from './executor.js';
3
- export declare function insertIdentity(exec: Executor, { anonymousId, userId }: {
3
+ export type IdentityWriteOutcome = 'linked' | 'verified' | 'skipped';
4
+ type IdentityInput = {
4
5
  anonymousId: string;
5
6
  userId: string;
6
- }): Promise<void>;
7
- /** §5.6 / §7.8: privileged insert, isolated from the caller's sign-in transaction. */
8
- export declare function insertIdentityOnOwnConnection(payload: Payload, identity: {
9
- anonymousId: string;
10
- userId: string;
11
- }): Promise<void>;
7
+ verified: boolean;
8
+ };
9
+ export declare function insertIdentity(exec: Executor, { anonymousId, userId, verified }: IdentityInput): Promise<IdentityWriteOutcome>;
10
+ /** §5.6 / §7.8: privileged insert/upgrade, isolated from the caller's sign-in transaction. */
11
+ export declare function insertIdentityOnOwnConnection(payload: Payload, identity: IdentityInput): Promise<IdentityWriteOutcome>;
12
+ export {};
@@ -1,26 +1,29 @@
1
1
  import { ownConnection } from './executor.js';
2
2
  import { COLUMNS, TABLES } from './names.js';
3
3
  const quote = (name)=>`"${name.replaceAll('"', '""')}"`;
4
- export async function insertIdentity(exec, { anonymousId, userId }) {
4
+ export async function insertIdentity(exec, { anonymousId, userId, verified }) {
5
5
  const schema = exec.schemaName;
6
6
  const table = `${schema ? `${quote(schema)}.` : ''}${quote(TABLES.identities)}`;
7
7
  const c = COLUMNS.identities;
8
- await exec.query(`INSERT INTO ${table} (${[
8
+ const { rows } = await exec.query(`INSERT INTO ${table} AS identity (${[
9
9
  c.anonymousId,
10
10
  c.userId,
11
11
  c.linkedAt,
12
+ c.verifiedAt,
12
13
  c.createdAt,
13
14
  c.updatedAt
14
- ].map(quote).join(', ')}) VALUES ($1, $2, now(), now(), now()) ON CONFLICT (${quote(c.anonymousId)}) DO NOTHING`, [
15
+ ].map(quote).join(', ')}) VALUES ($1, $2, now(), CASE WHEN $3 THEN now() END, now(), now()) ON CONFLICT (${quote(c.anonymousId)}) DO UPDATE SET ${quote(c.verifiedAt)} = now(), ${quote(c.updatedAt)} = now() WHERE identity.${quote(c.userId)} = EXCLUDED.${quote(c.userId)} AND identity.${quote(c.verifiedAt)} IS NULL AND $3 RETURNING (xmax = 0) AS inserted`, [
15
16
  anonymousId,
16
- userId
17
+ userId,
18
+ verified
17
19
  ]);
20
+ return rows.length === 0 ? 'skipped' : rows[0].inserted ? 'linked' : 'verified';
18
21
  }
19
- /** §5.6 / §7.8: privileged insert, isolated from the caller's sign-in transaction. */ export async function insertIdentityOnOwnConnection(payload, identity) {
22
+ /** §5.6 / §7.8: privileged insert/upgrade, isolated from the caller's sign-in transaction. */ export async function insertIdentityOnOwnConnection(payload, identity) {
20
23
  const db = payload.db;
21
24
  const exec = await ownConnection(db.pool);
22
25
  try {
23
- await insertIdentity(Object.assign(exec, {
26
+ return await insertIdentity(Object.assign(exec, {
24
27
  schemaName: db.schemaName
25
28
  }), identity);
26
29
  } finally{
@@ -4,6 +4,7 @@ export declare const LOCK_NAMESPACES: {
4
4
  readonly layer: "xp:layer";
5
5
  readonly flagPoint: "xp:flagpoint";
6
6
  readonly experiment: "xp:experiment";
7
+ readonly pagePath: "xp:pagepath";
7
8
  };
8
9
  /** Transaction executor only. Call in layer → flag point → experiment order;
9
10
  * the manifest builder takes the state lock last, after transition writes.
@@ -13,3 +14,8 @@ export declare function lockTransitionScope(exec: Executor, scope: {
13
14
  layerId?: DocId | null;
14
15
  flagPointId?: string | null;
15
16
  }): Promise<void>;
17
+ /** After layer → flag point → experiment, before metric/state locks. Paths must
18
+ * already be normalised with the runtime's rules. Sorted keys avoid deadlocks
19
+ * when different tests share several paths in different arm orders.
20
+ */
21
+ export declare function lockPagePaths(exec: Executor, paths: readonly string[]): Promise<void>;
package/dist/db/locks.js CHANGED
@@ -1,11 +1,13 @@
1
1
  export const LOCK_NAMESPACES = {
2
2
  layer: 'xp:layer',
3
3
  flagPoint: 'xp:flagpoint',
4
- experiment: 'xp:experiment'
4
+ experiment: 'xp:experiment',
5
+ pagePath: 'xp:pagepath'
5
6
  };
6
7
  const LOCK_STATEMENTS = {
7
8
  layer: `SELECT pg_advisory_xact_lock(hashtext('${LOCK_NAMESPACES.layer}'), hashtext($1))`,
8
9
  flagPoint: `SELECT pg_advisory_xact_lock(hashtext('${LOCK_NAMESPACES.flagPoint}'), hashtext($1))`,
10
+ pagePath: `SELECT pg_advisory_xact_lock(hashtext('${LOCK_NAMESPACES.pagePath}'), hashtext($1))`,
9
11
  experiment: `SELECT pg_advisory_xact_lock(hashtext('${LOCK_NAMESPACES.experiment}'), hashtext($1))`
10
12
  };
11
13
  /** Transaction executor only. Call in layer → flag point → experiment order;
@@ -21,3 +23,13 @@ const LOCK_STATEMENTS = {
21
23
  String(scope.experimentId)
22
24
  ]);
23
25
  }
26
+ /** After layer → flag point → experiment, before metric/state locks. Paths must
27
+ * already be normalised with the runtime's rules. Sorted keys avoid deadlocks
28
+ * when different tests share several paths in different arm orders.
29
+ */ export async function lockPagePaths(exec, paths) {
30
+ for (const path of [
31
+ ...new Set(paths)
32
+ ].sort())await exec.query(LOCK_STATEMENTS.pagePath, [
33
+ path
34
+ ]);
35
+ }
@@ -0,0 +1,19 @@
1
+ import type { Executor } from './executor.js';
2
+ import type { DocId } from '../types/contracts.js';
3
+ /** Request transaction only: serialize the guard with FK checks and freezes. */
4
+ export declare function lockMetricRow(exec: Executor, id: DocId, mode: 'delete' | 'change'): Promise<void>;
5
+ /** Transition transaction only, after advisory locks. SQL orders by the native
6
+ * id type so overlapping freezes always acquire row locks in the same order.
7
+ * Return the locked identities so callers can reject a change that committed
8
+ * after preflight. Missing rows mean a goal was deleted; never freeze that copy.
9
+ * txExecutor throws if the request no longer has a transaction session.
10
+ */
11
+ export declare function lockMetricsForFreeze(exec: Executor, ids: DocId[]): Promise<Array<{
12
+ id: DocId;
13
+ slug: string;
14
+ source: string;
15
+ }> | null>;
16
+ /** Live selections cover drafts; frozen definitions keep historical goals in use. */
17
+ export declare function findMetricUsage(exec: Executor, id: DocId): Promise<{
18
+ name: string;
19
+ } | null>;
@@ -0,0 +1,54 @@
1
+ import { COLUMNS, TABLES } from './names.js';
2
+ import { qualifiedTable, quoteIdentifier as q } from './sql.js';
3
+ /** Request transaction only: serialize the guard with FK checks and freezes. */ export async function lockMetricRow(exec, id, mode) {
4
+ const lock = mode === 'delete' ? 'FOR UPDATE' : 'FOR NO KEY UPDATE';
5
+ await exec.query(`SELECT 1 FROM ${qualifiedTable(exec, TABLES.metrics)} WHERE ${q(COLUMNS.metrics.id)} = $1 ${lock}`, [
6
+ id
7
+ ]);
8
+ }
9
+ /** Transition transaction only, after advisory locks. SQL orders by the native
10
+ * id type so overlapping freezes always acquire row locks in the same order.
11
+ * Return the locked identities so callers can reject a change that committed
12
+ * after preflight. Missing rows mean a goal was deleted; never freeze that copy.
13
+ * txExecutor throws if the request no longer has a transaction session.
14
+ */ export async function lockMetricsForFreeze(exec, ids) {
15
+ // Deduplicate for the row-count check; SQL ORDER BY sets the lock order.
16
+ const sorted = [
17
+ ...new Map(ids.map((id)=>[
18
+ String(id),
19
+ id
20
+ ])).values()
21
+ ].sort((a, b)=>typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)));
22
+ const m = COLUMNS.metrics, id = q(m.id);
23
+ const result = await exec.query(`SELECT ${id}, ${q(m.slug)}, ${q(m.source)} FROM ${qualifiedTable(exec, TABLES.metrics)} WHERE ${id} = ANY($1) ORDER BY ${id} FOR SHARE`, [
24
+ sorted
25
+ ]);
26
+ return result.rows.length === sorted.length ? result.rows : null;
27
+ }
28
+ /** Live selections cover drafts; frozen definitions keep historical goals in use. */ export async function findMetricUsage(exec, id) {
29
+ const e = COLUMNS.experiments, p = COLUMNS.phases, r = COLUMNS.experimentsRels;
30
+ const result = await exec.query(`
31
+ SELECT COALESCE(usage.name, 'Deleted test') AS name FROM (
32
+ SELECT e.${q(e.name)} AS name, e.${q(e.status)} AS status
33
+ FROM ${qualifiedTable(exec, TABLES.phases)} p
34
+ LEFT JOIN ${qualifiedTable(exec, TABLES.experiments)} e ON e.${q(e.id)} = p.${q(p.experimentId)}
35
+ WHERE jsonb_typeof(p.${q(p.metricDefinitions)}) = 'array' AND EXISTS (
36
+ SELECT 1 FROM jsonb_array_elements(p.${q(p.metricDefinitions)}) definition
37
+ WHERE definition->>'metricId' = $1::text
38
+ )
39
+ UNION ALL
40
+ SELECT e.${q(e.name)} AS name, e.${q(e.status)} AS status
41
+ FROM ${qualifiedTable(exec, TABLES.experiments)} e
42
+ WHERE e.${q(e.metricsPrimaryId)}::text = $1::text OR EXISTS (
43
+ SELECT 1 FROM ${qualifiedTable(exec, TABLES.experimentsRels)} r
44
+ WHERE r.${q(r.parentId)} = e.${q(e.id)}
45
+ AND r.${q(r.path)} IN ('metrics.secondary', 'metrics.guardrails')
46
+ AND r.${q(r.xpMetricsId)}::text = $1::text
47
+ )
48
+ ) usage
49
+ ORDER BY CASE WHEN usage.status IN ('running', 'decided') THEN 0 ELSE 1 END, usage.name
50
+ LIMIT 1`, [
51
+ String(id)
52
+ ]);
53
+ return result.rows[0] ?? null;
54
+ }
@@ -83,6 +83,8 @@ export declare const COLUMNS: {
83
83
  readonly decisionDiscardedAt: "decision_discarded_at";
84
84
  readonly decisionContentReport: "decision_content_report";
85
85
  readonly decisionHoldoutReleasedAt: "decision_holdout_released_at";
86
+ readonly decisionPagesReleasedAt: "decision_pages_released_at";
87
+ readonly decisionPagesReleasedBy: "decision_pages_released_by";
86
88
  readonly updatedAt: "updated_at";
87
89
  readonly createdAt: "created_at";
88
90
  };
@@ -151,6 +153,8 @@ export declare const COLUMNS: {
151
153
  readonly versionDecisionDiscardedAt: "version_decision_discarded_at";
152
154
  readonly versionDecisionContentReport: "version_decision_content_report";
153
155
  readonly versionDecisionHoldoutReleasedAt: "version_decision_holdout_released_at";
156
+ readonly versionDecisionPagesReleasedAt: "version_decision_pages_released_at";
157
+ readonly versionDecisionPagesReleasedBy: "version_decision_pages_released_by";
154
158
  readonly versionUpdatedAt: "version_updated_at";
155
159
  readonly versionCreatedAt: "version_created_at";
156
160
  readonly createdAt: "created_at";
@@ -227,6 +231,7 @@ export declare const COLUMNS: {
227
231
  readonly anonymousId: "anonymous_id";
228
232
  readonly userId: "user_id";
229
233
  readonly linkedAt: "linked_at";
234
+ readonly verifiedAt: "verified_at";
230
235
  readonly updatedAt: "updated_at";
231
236
  readonly createdAt: "created_at";
232
237
  };
package/dist/db/names.js CHANGED
@@ -82,6 +82,8 @@ export const COLUMNS = {
82
82
  decisionDiscardedAt: 'decision_discarded_at',
83
83
  decisionContentReport: 'decision_content_report',
84
84
  decisionHoldoutReleasedAt: 'decision_holdout_released_at',
85
+ decisionPagesReleasedAt: 'decision_pages_released_at',
86
+ decisionPagesReleasedBy: 'decision_pages_released_by',
85
87
  updatedAt: 'updated_at',
86
88
  createdAt: 'created_at'
87
89
  },
@@ -150,6 +152,8 @@ export const COLUMNS = {
150
152
  versionDecisionDiscardedAt: 'version_decision_discarded_at',
151
153
  versionDecisionContentReport: 'version_decision_content_report',
152
154
  versionDecisionHoldoutReleasedAt: 'version_decision_holdout_released_at',
155
+ versionDecisionPagesReleasedAt: 'version_decision_pages_released_at',
156
+ versionDecisionPagesReleasedBy: 'version_decision_pages_released_by',
153
157
  versionUpdatedAt: 'version_updated_at',
154
158
  versionCreatedAt: 'version_created_at',
155
159
  createdAt: 'created_at',
@@ -226,6 +230,7 @@ export const COLUMNS = {
226
230
  anonymousId: 'anonymous_id',
227
231
  userId: 'user_id',
228
232
  linkedAt: 'linked_at',
233
+ verifiedAt: 'verified_at',
229
234
  updatedAt: 'updated_at',
230
235
  createdAt: 'created_at'
231
236
  },
@@ -0,0 +1,12 @@
1
+ import type { Arm, DocId } from '../types/contracts.js';
2
+ import type { Executor } from './executor.js';
3
+ /** Page tests that occupy paths, with the arms the runtime routes on (`compileManifest`):
4
+ * - running or decided: the latest round's arms, or for a rollout the latest test round's;
5
+ * - archived with a standing result (winner, not discarded, pages not released): the latest test
6
+ * round's arms whose paths are rewritten to the winner (archivedPages), so a new test cannot
7
+ * silently take them over.
8
+ * Documents' arms may have been edited while stopped, so occupancy never reads them. */
9
+ export declare function routedPageTests(exec: Executor): Promise<Array<{
10
+ id: DocId;
11
+ arms: Arm[];
12
+ }>>;