@delmaredigital/payload-experiments 0.3.0-rc.4 → 0.4.0-rc.2

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 (299) hide show
  1. package/README.md +122 -27
  2. package/dist/admin/MetricSourceSelect.js +3 -3
  3. package/dist/admin/PlacementStatus.d.ts +3 -0
  4. package/dist/admin/PlacementStatus.js +54 -0
  5. package/dist/admin/PreviewLinks.d.ts +2 -1
  6. package/dist/admin/PreviewLinks.js +2 -2
  7. package/dist/admin/TestsDashboardCards.d.ts +1 -0
  8. package/dist/admin/TestsDashboardCards.js +4 -8
  9. package/dist/admin/TestsNavLink.d.ts +1 -0
  10. package/dist/admin/TestsNavLink.js +4 -10
  11. package/dist/admin/actions/ConfirmDialog.d.ts +2 -1
  12. package/dist/admin/actions/ConfirmDialog.js +2 -2
  13. package/dist/admin/actions/DecideDialog.d.ts +2 -1
  14. package/dist/admin/actions/DecideDialog.js +2 -2
  15. package/dist/admin/api.d.ts +18 -2
  16. package/dist/admin/api.js +47 -2
  17. package/dist/admin/blocks/BlockPanel.d.ts +4 -0
  18. package/dist/admin/blocks/BlockPanel.js +199 -0
  19. package/dist/admin/blocks/TestBlockField.d.ts +5 -0
  20. package/dist/admin/blocks/TestBlockField.js +163 -0
  21. package/dist/admin/blocks/formApi.d.ts +46 -0
  22. package/dist/admin/blocks/formApi.js +159 -0
  23. package/dist/admin/blocks/inject.d.ts +7 -0
  24. package/dist/admin/blocks/inject.js +41 -0
  25. package/dist/admin/blocks/model.d.ts +17 -0
  26. package/dist/admin/blocks/model.js +45 -0
  27. package/dist/admin/blocks/statusCache.d.ts +9 -0
  28. package/dist/admin/blocks/statusCache.js +92 -0
  29. package/dist/admin/blocks/useBlockForm.d.ts +17 -0
  30. package/dist/admin/blocks/useBlockForm.js +46 -0
  31. package/dist/admin/copy/actions.d.ts +2 -0
  32. package/dist/admin/copy/actions.js +9 -0
  33. package/dist/admin/copy/blocks.d.ts +43 -0
  34. package/dist/admin/copy/blocks.js +64 -0
  35. package/dist/admin/copy/common.d.ts +1 -0
  36. package/dist/admin/copy/common.js +1 -1
  37. package/dist/admin/copy/goals.d.ts +2 -0
  38. package/dist/admin/copy/goals.js +4 -1
  39. package/dist/admin/copy/newTest.d.ts +10 -0
  40. package/dist/admin/copy/newTest.js +10 -0
  41. package/dist/admin/copy/pages.d.ts +24 -0
  42. package/dist/admin/copy/pages.js +24 -0
  43. package/dist/admin/copy/readiness.d.ts +23 -3
  44. package/dist/admin/copy/readiness.js +24 -3
  45. package/dist/admin/copy/results.d.ts +1 -0
  46. package/dist/admin/copy/results.js +1 -0
  47. package/dist/admin/copy/testPage.d.ts +4 -0
  48. package/dist/admin/copy/testPage.js +4 -0
  49. package/dist/admin/goals/XpGoalField.d.ts +4 -0
  50. package/dist/admin/goals/XpGoalField.js +168 -0
  51. package/dist/admin/goals/xpGoalField.css +48 -0
  52. package/dist/admin/metricSource.model.d.ts +2 -0
  53. package/dist/admin/metricSource.model.js +4 -0
  54. package/dist/admin/pageCollections.d.ts +10 -0
  55. package/dist/admin/pageCollections.js +20 -0
  56. package/dist/admin/paths.d.ts +9 -0
  57. package/dist/admin/paths.js +9 -0
  58. package/dist/admin/placementStatus.model.d.ts +2 -0
  59. package/dist/admin/placementStatus.model.js +19 -0
  60. package/dist/admin/placements/placementLabels.d.ts +11 -0
  61. package/dist/admin/placements/placementLabels.js +21 -0
  62. package/dist/admin/placements/placementLabels.model.d.ts +13 -0
  63. package/dist/admin/placements/placementLabels.model.js +12 -0
  64. package/dist/admin/placements/usePlacementLabels.d.ts +12 -0
  65. package/dist/admin/placements/usePlacementLabels.js +102 -0
  66. package/dist/admin/results/DataQualityPanel.js +4 -1
  67. package/dist/admin/results/results.model.d.ts +3 -0
  68. package/dist/admin/results/results.model.js +8 -4
  69. package/dist/admin/surface/TestsHome.js +39 -10
  70. package/dist/admin/surface/TestsSurface.js +7 -6
  71. package/dist/admin/surface/contracts.d.ts +6 -5
  72. package/dist/admin/surface/goals/GoalForm.js +71 -44
  73. package/dist/admin/surface/goals/GoalsList.js +5 -4
  74. package/dist/admin/surface/goals/data.js +4 -1
  75. package/dist/admin/surface/goals/goals.model.d.ts +1 -2
  76. package/dist/admin/surface/goals/goals.model.js +1 -3
  77. package/dist/admin/surface/home.model.d.ts +3 -3
  78. package/dist/admin/surface/home.model.js +8 -4
  79. package/dist/admin/surface/newTest/NewTest.js +56 -17
  80. package/dist/admin/surface/newTest/PageWizard.d.ts +7 -0
  81. package/dist/admin/surface/newTest/PageWizard.js +214 -0
  82. package/dist/admin/surface/pagePicker/CopyRoute.d.ts +6 -0
  83. package/dist/admin/surface/pagePicker/CopyRoute.js +53 -0
  84. package/dist/admin/surface/pagePicker/PageCombobox.d.ts +10 -0
  85. package/dist/admin/surface/pagePicker/PageCombobox.js +395 -0
  86. package/dist/admin/surface/pagePicker/pagePicker.css +10 -0
  87. package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +27 -0
  88. package/dist/admin/surface/pagePicker/pagePicker.model.js +25 -0
  89. package/dist/admin/surface/readiness/ActionDialogs.js +99 -64
  90. package/dist/admin/surface/readiness/Banner.js +30 -6
  91. package/dist/admin/surface/readiness/readiness.model.d.ts +8 -1
  92. package/dist/admin/surface/readiness/readiness.model.js +92 -9
  93. package/dist/admin/surface/results/ReleasePages.d.ts +3 -1
  94. package/dist/admin/surface/results/ReleasePages.js +3 -7
  95. package/dist/admin/surface/results/ResultsBody.js +21 -2
  96. package/dist/admin/surface/results/ResultsHeader.js +5 -13
  97. package/dist/admin/surface/results/useResults.d.ts +20 -1
  98. package/dist/admin/surface/test/AdvancedSection.js +111 -104
  99. package/dist/admin/surface/test/GoalSection.js +94 -14
  100. package/dist/admin/surface/test/LengthSection.js +1 -1
  101. package/dist/admin/surface/test/PageHoldout.d.ts +3 -0
  102. package/dist/admin/surface/test/PageHoldout.js +67 -0
  103. package/dist/admin/surface/test/TestPage.js +5 -9
  104. package/dist/admin/surface/test/VersionsSection.js +12 -5
  105. package/dist/admin/surface/test/fields.d.ts +13 -4
  106. package/dist/admin/surface/test/fields.js +46 -14
  107. package/dist/admin/surface/test/jumpToSection.d.ts +1 -1
  108. package/dist/admin/surface/test/jumpToSection.js +4 -3
  109. package/dist/admin/surface/test/pageParts.d.ts +4 -1
  110. package/dist/admin/surface/test/pageParts.js +49 -1
  111. package/dist/admin/surface/test/testPage.css +3 -1
  112. package/dist/admin/surface/testDocument.model.d.ts +1 -0
  113. package/dist/admin/surface/testDocument.model.js +2 -1
  114. package/dist/admin/surface/useTestDocument.d.ts +15 -2
  115. package/dist/admin/surface/useTestDocument.js +53 -15
  116. package/dist/admin/surfaceUrls.d.ts +7 -0
  117. package/dist/admin/surfaceUrls.js +19 -0
  118. package/dist/admin/theme.css +2 -0
  119. package/dist/admin/useApi.d.ts +21 -2
  120. package/dist/admin/useApi.js +3 -5
  121. package/dist/admin/views/CollectionListRedirect.d.ts +1 -0
  122. package/dist/admin/views/CollectionListRedirect.js +1 -1
  123. package/dist/admin/views/ExperimentsRootView.js +28 -133
  124. package/dist/admin/views/GoalEditRedirect.d.ts +4 -0
  125. package/dist/admin/views/GoalEditRedirect.js +14 -0
  126. package/dist/admin/views/GoalsCollectionListView.d.ts +2 -0
  127. package/dist/admin/views/GoalsCollectionListView.js +4 -0
  128. package/dist/admin/views/TestsCollectionListView.d.ts +2 -0
  129. package/dist/admin/views/TestsCollectionListView.js +4 -0
  130. package/dist/admin/views/TestsNavLinkServer.d.ts +1 -0
  131. package/dist/admin/views/TestsNavLinkServer.js +1 -1
  132. package/dist/admin/views/buildSurfaceProps.d.ts +8 -0
  133. package/dist/admin/views/buildSurfaceProps.js +143 -0
  134. package/dist/admin/views/renderCollectionList.d.ts +2 -0
  135. package/dist/admin/views/renderCollectionList.js +34 -0
  136. package/dist/admin/views/renderTestsSurface.d.ts +3 -0
  137. package/dist/admin/views/renderTestsSurface.js +34 -0
  138. package/dist/blocks/bindings.d.ts +3 -0
  139. package/dist/blocks/bindings.js +34 -0
  140. package/dist/blocks/createExperimentBlock.d.ts +6 -0
  141. package/dist/blocks/createExperimentBlock.js +77 -0
  142. package/dist/blocks/formState.d.ts +31 -0
  143. package/dist/blocks/formState.js +146 -0
  144. package/dist/blocks/model.d.ts +41 -0
  145. package/dist/blocks/model.js +34 -0
  146. package/dist/client/ExperimentGroup.d.ts +6 -0
  147. package/dist/client/ExperimentGroup.js +9 -0
  148. package/dist/client/ExperimentsProvider.js +20 -4
  149. package/dist/client/clickGoals.d.ts +4 -0
  150. package/dist/client/clickGoals.js +43 -0
  151. package/dist/collections/experiments/config.js +21 -7
  152. package/dist/collections/experiments/deleteGuard.d.ts +2 -1
  153. package/dist/collections/experiments/deleteGuard.js +12 -1
  154. package/dist/collections/experiments/editability.js +4 -2
  155. package/dist/collections/experiments/pickSlug.d.ts +2 -0
  156. package/dist/collections/experiments/pickSlug.js +8 -0
  157. package/dist/collections/labels.d.ts +3 -0
  158. package/dist/collections/labels.js +20 -0
  159. package/dist/collections/metrics.js +28 -6
  160. package/dist/collections/phases.js +1 -0
  161. package/dist/collections/serialize.js +7 -0
  162. package/dist/db/clickGoals.d.ts +9 -0
  163. package/dist/db/clickGoals.js +22 -0
  164. package/dist/db/delete.d.ts +1 -0
  165. package/dist/db/delete.js +1 -0
  166. package/dist/db/locks.d.ts +8 -0
  167. package/dist/db/locks.js +27 -4
  168. package/dist/db/names.d.ts +4 -0
  169. package/dist/db/names.js +4 -0
  170. package/dist/db/pageBindings.d.ts +4 -0
  171. package/dist/db/pageBindings.js +17 -0
  172. package/dist/db/pageBusy.d.ts +2 -0
  173. package/dist/db/pageBusy.js +10 -0
  174. package/dist/db/pageContent.d.ts +10 -0
  175. package/dist/db/pageContent.js +79 -0
  176. package/dist/db/placements.d.ts +33 -0
  177. package/dist/db/placements.js +54 -0
  178. package/dist/db/slugs.d.ts +8 -0
  179. package/dist/db/slugs.js +40 -0
  180. package/dist/db/snapshots.d.ts +6 -0
  181. package/dist/db/snapshots.js +8 -5
  182. package/dist/endpoints/clickKeys.d.ts +5 -0
  183. package/dist/endpoints/clickKeys.js +17 -0
  184. package/dist/endpoints/events.js +4 -1
  185. package/dist/endpoints/index.js +28 -0
  186. package/dist/endpoints/pages.d.ts +5 -0
  187. package/dist/endpoints/pages.js +120 -0
  188. package/dist/endpoints/placementStatus.d.ts +6 -0
  189. package/dist/endpoints/placementStatus.js +238 -0
  190. package/dist/endpoints/readBody.d.ts +2 -0
  191. package/dist/endpoints/readBody.js +24 -0
  192. package/dist/endpoints/results.js +5 -1
  193. package/dist/endpoints/summary.js +11 -4
  194. package/dist/endpoints/wrapBlock.d.ts +6 -0
  195. package/dist/endpoints/wrapBlock.js +76 -0
  196. package/dist/errors.d.ts +3 -3
  197. package/dist/errors.js +4 -4
  198. package/dist/events/rows.d.ts +1 -1
  199. package/dist/events/rows.js +5 -3
  200. package/dist/exports/adminClient.d.ts +5 -0
  201. package/dist/exports/adminClient.js +5 -0
  202. package/dist/exports/adminRsc.d.ts +3 -0
  203. package/dist/exports/adminRsc.js +3 -0
  204. package/dist/exports/blocks.d.ts +2 -0
  205. package/dist/exports/blocks.js +1 -0
  206. package/dist/exports/client.d.ts +1 -0
  207. package/dist/exports/client.js +1 -0
  208. package/dist/exports/index.d.ts +6 -1
  209. package/dist/exports/index.js +3 -0
  210. package/dist/exports/puck.d.ts +3 -1
  211. package/dist/exports/puck.js +2 -0
  212. package/dist/exports/puckEditor.d.ts +3 -0
  213. package/dist/exports/puckEditor.js +2 -0
  214. package/dist/exports/rsc.d.ts +3 -0
  215. package/dist/exports/rsc.js +2 -0
  216. package/dist/fields/xpGoal.d.ts +3 -0
  217. package/dist/fields/xpGoal.js +16 -0
  218. package/dist/globals/state.js +10 -0
  219. package/dist/goals/cache.d.ts +3 -0
  220. package/dist/goals/cache.js +35 -0
  221. package/dist/goals/fairness.d.ts +40 -0
  222. package/dist/goals/fairness.js +200 -0
  223. package/dist/goals/key.d.ts +4 -0
  224. package/dist/goals/key.js +3 -0
  225. package/dist/goals/picker/clickGoalPicker.model.d.ts +29 -0
  226. package/dist/goals/picker/clickGoalPicker.model.js +45 -0
  227. package/dist/goals/picker/copy.d.ts +16 -0
  228. package/dist/goals/picker/copy.js +15 -0
  229. package/dist/goals/picker/useClickGoalPicker.d.ts +30 -0
  230. package/dist/goals/picker/useClickGoalPicker.js +220 -0
  231. package/dist/goals/validate.d.ts +3 -0
  232. package/dist/goals/validate.js +14 -0
  233. package/dist/hooks/pageGuard.d.ts +5 -1
  234. package/dist/hooks/pageGuard.js +157 -95
  235. package/dist/hooks/puckGoals.d.ts +3 -0
  236. package/dist/hooks/puckGoals.js +38 -0
  237. package/dist/onInit.js +29 -0
  238. package/dist/options.d.ts +2 -0
  239. package/dist/options.js +41 -1
  240. package/dist/plugin.js +50 -43
  241. package/dist/puck/copy.d.ts +1 -0
  242. package/dist/puck/copy.js +1 -0
  243. package/dist/puck/editor/editor.chrome.css +14 -0
  244. package/dist/puck/editor/editor.css +106 -0
  245. package/dist/puck/editor/editor.model.d.ts +4 -2
  246. package/dist/puck/editor/editor.model.js +6 -3
  247. package/dist/puck/editor/integration.d.ts +10 -0
  248. package/dist/puck/editor/integration.js +30 -0
  249. package/dist/puck/editor/xpGoalPuckEditorField.d.ts +7 -0
  250. package/dist/puck/editor/xpGoalPuckEditorField.js +142 -0
  251. package/dist/puck/integration.d.ts +6 -0
  252. package/dist/puck/integration.js +21 -0
  253. package/dist/puck/xpGoalField.d.ts +3 -0
  254. package/dist/puck/xpGoalField.js +6 -0
  255. package/dist/results/health.d.ts +4 -0
  256. package/dist/results/health.js +27 -0
  257. package/dist/results/snapshot.js +15 -2
  258. package/dist/sanitizeReadinessDetail.js +43 -0
  259. package/dist/server/pageKindSeoContract.d.ts +27 -0
  260. package/dist/server/pageKindSeoContract.js +80 -0
  261. package/dist/server/recordContentChange.js +58 -8
  262. package/dist/server/resolveExperimentBlocks.d.ts +5 -0
  263. package/dist/server/resolveExperimentBlocks.js +54 -0
  264. package/dist/server/runResults.js +56 -15
  265. package/dist/server/toClientAssignments.js +2 -1
  266. package/dist/transitions/addMetric.d.ts +2 -1
  267. package/dist/transitions/addMetric.js +28 -1
  268. package/dist/transitions/checkStart.d.ts +14 -1
  269. package/dist/transitions/checkStart.js +92 -8
  270. package/dist/transitions/decide.js +25 -0
  271. package/dist/transitions/deleteExperiment.d.ts +3 -1
  272. package/dist/transitions/deleteExperiment.js +27 -1
  273. package/dist/transitions/kernel.js +4 -0
  274. package/dist/transitions/makeContent.d.ts +5 -0
  275. package/dist/transitions/makeContent.js +39 -11
  276. package/dist/transitions/pageCollection.d.ts +10 -0
  277. package/dist/transitions/pageCollection.js +20 -0
  278. package/dist/transitions/pageVersions.d.ts +10 -0
  279. package/dist/transitions/pageVersions.js +44 -0
  280. package/dist/transitions/phaseRecord.d.ts +1 -0
  281. package/dist/transitions/placementInteractions.d.ts +40 -0
  282. package/dist/transitions/placementInteractions.js +94 -0
  283. package/dist/transitions/placements.d.ts +8 -0
  284. package/dist/transitions/placements.js +148 -1
  285. package/dist/transitions/preflightData.d.ts +2 -2
  286. package/dist/transitions/preflightData.js +137 -5
  287. package/dist/transitions/resolveCaps.d.ts +1 -0
  288. package/dist/transitions/resolveCaps.js +6 -2
  289. package/dist/transitions/start.js +46 -1
  290. package/dist/types/admin.d.ts +1 -1
  291. package/dist/types/assignments.d.ts +1 -0
  292. package/dist/types/contracts.d.ts +15 -0
  293. package/dist/types/endpoints.d.ts +92 -5
  294. package/dist/types/index.d.ts +3 -0
  295. package/dist/types/puck.d.ts +7 -1
  296. package/dist/types/snapshot.d.ts +17 -4
  297. package/dist/types/surface.d.ts +13 -0
  298. package/dist/ui/ui.css +4 -0
  299. package/package.json +8 -1
@@ -1,3 +1,4 @@
1
+ import { asymmetricGoal } from '../goals/fairness.js';
1
2
  import { checkArms } from '../collections/experiments/armInvariants.js';
2
3
  import { normalisePath } from '../runtime/normalisePath.js';
3
4
  import { isOpValid } from '../targeting/ops.js';
@@ -18,6 +19,11 @@ export const relationshipId = (value)=>value == null ? undefined : typeof value
18
19
  * pageTarget, holdout included, normalised the same way ('' is '/'). Occupancy and lock keys use this. */ export const pageTargetPaths = (arms)=>[
19
20
  ...new Set(arms.filter((arm)=>typeof arm.pageTarget === 'string').map((arm)=>normalisePath(arm.pageTarget)))
20
21
  ].sort();
22
+ const placementDetail = ({ collection, id, title })=>({
23
+ collection,
24
+ id,
25
+ title
26
+ });
21
27
  const positive = (value)=>typeof value === 'number' && Number.isFinite(value) && value > 0;
22
28
  function invalidParams(arms, point) {
23
29
  return arms.flatMap((arm)=>{
@@ -61,6 +67,28 @@ function invalidParams(arms, point) {
61
67
  'viewport'
62
68
  ].includes(e.exposurePolicy) || positive(e.analysis?.reachPerDay));
63
69
  add('page-no-targets', e.kind !== 'page' || e.arms.filter((arm)=>arm.kind !== 'holdout').every((arm)=>typeof arm.pageTarget === 'string' && arm.pageTarget.trim().length > 0));
70
+ add('interacting-tests', !input.interactions?.length, input.interactions);
71
+ add('unknown-collection', !input.unknownPageCollection);
72
+ const addressed = new Map();
73
+ const duplicate = e.kind === 'page' ? e.arms.filter((arm)=>arm.kind !== 'holdout' && arm.pageTarget).flatMap((arm)=>{
74
+ const path = normalisePath(arm.pageTarget);
75
+ const otherArm = addressed.get(path);
76
+ addressed.set(path, arm.key);
77
+ return otherArm ? [
78
+ {
79
+ arm: arm.key,
80
+ otherArm,
81
+ path
82
+ }
83
+ ] : [];
84
+ }) : [];
85
+ add('version-same-address', duplicate.length === 0, duplicate[0]);
86
+ for (const detail of input.identicalVersions ?? [])checks.push({
87
+ code: 'version-identical',
88
+ ok: true,
89
+ warning: true,
90
+ detail
91
+ });
64
92
  const occupiedPaths = new Set((input.pageTestsInUse ?? []).filter((other)=>String(other.id) !== String(e.id)).flatMap((other)=>pageTargetPaths(other.arms)));
65
93
  const sharedPaths = e.kind === 'page' ? pageTargetPaths(e.arms).filter((path)=>occupiedPaths.has(path)) : [];
66
94
  add('page-in-use', sharedPaths.length === 0, {
@@ -79,12 +107,38 @@ function invalidParams(arms, point) {
79
107
  const failed = input.flagPlacements?.filter((placement)=>!placement.ok) ?? [];
80
108
  add('flag-placement-missing', e.kind !== 'flag' || !input.flagPlacementError && missing.length === 0 && failed.length === 0, input.flagPlacementError ?? {
81
109
  missing: missing.map((arm)=>arm.key),
82
- placements: failed
110
+ placements: failed.map(({ arm, ok, detail })=>({
111
+ arm,
112
+ ok,
113
+ detail
114
+ }))
83
115
  });
84
116
  const placements = input.placements ?? [];
85
- add('no-placements', e.kind !== 'block' || !input.placementError && placements.length > 0, input.placementError);
86
- const empty = placements.filter((placement)=>e.arms.some((arm)=>arm.kind !== 'holdout' && (!arm.slot || !(placement.slots[arm.slot] > 0))));
87
- add('empty-slot', e.kind !== 'block' || empty.length === 0, empty);
117
+ const placementRemoved = e.kind === 'block' && e.status === 'stopped' && (e.phase ?? 0) >= 1 && placements.length === 0 && input.placementError === undefined;
118
+ add('no-placements', placementRemoved || e.kind !== 'block' || !input.placementError && placements.length > 0, input.placementError);
119
+ add('placement-removed', !placementRemoved);
120
+ const unpublished = placements.filter((p)=>!p.published && !placements.some((other)=>other.published && other.collection === p.collection && String(other.id) === String(p.id)));
121
+ add('placement-unpublished', unpublished.length === 0, unpublished.map(placementDetail));
122
+ const duplicates = placements.filter((p)=>p.published && p.duplicateRows);
123
+ add('duplicate-version-row', duplicates.length === 0, duplicates.map(placementDetail));
124
+ for (const p of placements.filter((p)=>p.published))for (const slot of p.identicalSlots ?? []){
125
+ if (e.arms.some((a)=>a.slot === slot && a.kind !== 'holdout')) checks.push({
126
+ code: 'version-identical',
127
+ ok: true,
128
+ warning: true,
129
+ detail: {
130
+ arm: slot,
131
+ collection: p.collection,
132
+ id: p.id,
133
+ title: p.title
134
+ }
135
+ });
136
+ }
137
+ const empty = placements.filter((placement)=>placement.published && e.arms.some((arm)=>arm.kind !== 'holdout' && (!arm.slot || !(placement.slots[arm.slot] > 0))));
138
+ add('empty-slot', e.kind !== 'block' || empty.length === 0, empty.map((placement)=>({
139
+ ...placementDetail(placement),
140
+ slots: placement.slots
141
+ })));
88
142
  add('make-content-unsupported', true);
89
143
  const localized = placements.find((placement)=>placement.localized);
90
144
  if (localized) Object.assign(checks[checks.length - 1], {
@@ -97,10 +151,40 @@ function invalidParams(arms, point) {
97
151
  available: free,
98
152
  requested: e.allocation
99
153
  });
100
- const unknown = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && !input.metricSources?.some((source)=>source.id === metric.source));
101
- add('unknown-metric-source', unknown.length === 0, unknown);
102
- const mismatches = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && e.unitType === 'user' && input.metricSources?.some((source)=>source.id === metric.source && source.keyedBy === 'visitor'));
103
- add('unit-source-mismatch', mismatches.length === 0, mismatches);
154
+ for (const detail of input.goalContent?.unchecked ?? [])checks.push({
155
+ code: 'placement-unchecked',
156
+ ok: true,
157
+ warning: true,
158
+ detail
159
+ });
160
+ const protectedGoals = [
161
+ relationshipId(e.metrics?.primary),
162
+ ...e.metrics?.guardrails?.map(relationshipId) ?? []
163
+ ].map(String);
164
+ const asymmetric = input.metrics.filter((metric)=>metric.source.startsWith('click:') && input.goalContent && asymmetricGoal(input.goalContent, metric.source, e.arms));
165
+ add('goal-in-some-versions', !asymmetric.some((metric)=>protectedGoals.includes(String(metric.id))), asymmetric.filter((metric)=>protectedGoals.includes(String(metric.id))).map((metric)=>({
166
+ metricId: metric.id,
167
+ name: metric.name
168
+ })));
169
+ for (const metric of asymmetric.filter((metric)=>!protectedGoals.includes(String(metric.id))))checks.push({
170
+ code: 'goal-in-some-versions',
171
+ ok: true,
172
+ warning: true,
173
+ detail: {
174
+ metricId: metric.id,
175
+ name: metric.name
176
+ }
177
+ });
178
+ const unknown = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && !metric.source.startsWith('click:') && !input.metricSources?.some((source)=>source.id === metric.source));
179
+ add('unknown-metric-source', unknown.length === 0, unknown.map(({ id, source })=>({
180
+ id,
181
+ source
182
+ })));
183
+ const mismatches = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && !metric.source.startsWith('click:') && e.unitType === 'user' && input.metricSources?.some((source)=>source.id === metric.source && source.keyedBy === 'visitor'));
184
+ add('unit-source-mismatch', mismatches.length === 0, mismatches.map(({ id, source })=>({
185
+ id,
186
+ source
187
+ })));
104
188
  const pathRules = (e.targeting ?? []).flatMap((rule, index)=>rule.attribute === 'path' ? [
105
189
  index
106
190
  ] : []);
@@ -1,4 +1,7 @@
1
+ import { detached, scanPlacements, lockAndRescanPlacements, placementInteractions } from './placementInteractions.js';
1
2
  import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
3
+ import { refreshPlacementDocs } from '../db/placements.js';
4
+ import { unpublishedPageTargets } from './preflightData.js';
2
5
  import { reclaimSlices } from '../db/slices.js';
3
6
  import { decideGuard } from '../results/decideGuard.js';
4
7
  import { lockPagePaths } from '../db/locks.js';
@@ -10,6 +13,8 @@ import { readPhaseRecord, setPhaseEndedAt, writePhaseRecord } from './phaseRecor
10
13
  import { readExperiment, refuse, updateExperiment } from './start.js';
11
14
  export async function decide(req, { experimentId, winnerArm, note, confirmation }) {
12
15
  const before = await readExperiment(req, experimentId);
16
+ const options = req.payload.config.custom?.xp;
17
+ const placements = before.status === 'stopped' && before.kind === 'block' ? await detached(req, ()=>scanPlacements(req, options, before.slug)) : [];
13
18
  const { result: doc, revision } = await withTransition(req, {
14
19
  experimentId,
15
20
  layerId: relationshipId(before.layer),
@@ -47,6 +52,14 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
47
52
  }
48
53
  const test = await readPhaseRecord(req, experimentId, current.phase);
49
54
  if (test.phaseKind !== 'test') refuse('invalid-status');
55
+ if (current.kind === 'block' && current.status === 'stopped') {
56
+ const rescan = await lockAndRescanPlacements(req, exec, options, {
57
+ ...current,
58
+ arms: test.arms
59
+ }, placements);
60
+ if (rescan.placements.length === 0) refuse('placement-removed');
61
+ await refreshPlacementDocs(exec, experimentId, test.phase, rescan.docs);
62
+ }
50
63
  if (current.kind === 'page' && current.status === 'stopped') {
51
64
  // Deciding a stopped page test restores its routing: recheck page occupancy under the
52
65
  // sorted path locks Start takes, so another test that started on these pages meanwhile wins.
@@ -59,6 +72,18 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
59
72
  if (shared.length) refuse('page-in-use', {
60
73
  paths: shared
61
74
  });
75
+ const conflicts = await placementInteractions(req, exec, options, {
76
+ ...current,
77
+ arms: test.arms
78
+ }, mine);
79
+ if (conflicts.length) refuse('interacting-tests', conflicts);
80
+ if (options?.pages) {
81
+ const unpublished = await detached(req, ()=>unpublishedPageTargets(req, options, {
82
+ ...current,
83
+ arms: test.arms.filter((arm)=>arm.kind === 'control' || arm.key === winnerArm)
84
+ }, true));
85
+ if (unpublished.length) refuse('page-target-unpublished', unpublished[0]);
86
+ }
62
87
  }
63
88
  const winner = test.arms.find((arm)=>arm.key === winnerArm && arm.kind !== 'holdout');
64
89
  if (!winner) refuse('invalid-winner');
@@ -1,5 +1,7 @@
1
- import type { PayloadRequest } from 'payload';
1
+ import type { PayloadRequest, Where } from 'payload';
2
2
  import type { DocId } from '../types/contracts.js';
3
+ /** Lock the entire bulk scope before any per-document hook can take state. */
4
+ export declare function prepareBulkDelete(req: PayloadRequest, where: Where): Promise<void>;
3
5
  /** Payload deleteByID owns the request transaction; never commit inside hooks. */
4
6
  export declare function deleteExperiment(req: PayloadRequest, experimentId: DocId): Promise<void>;
5
7
  export declare function deleteExperimentAfter(req: PayloadRequest, experimentId: DocId): Promise<void>;
@@ -1,10 +1,33 @@
1
1
  import { safeError } from '../log.js';
2
+ import { retireSlug } from '../db/slugs.js';
2
3
  import { txExecutor } from '../db/executor.js';
3
4
  import { deleteExperimentSnapshots, serializeDelete } from '../db/delete.js';
4
5
  import { deleteExperimentEvents } from '../db/events.js';
5
- import { lockTransitionScope } from '../db/locks.js';
6
+ import { lockTransitionScope, sortLockKeys } from '../db/locks.js';
6
7
  import { buildAndStoreManifest } from '../manifest/compile.js';
7
8
  import { readExperiment, refuse } from './start.js';
9
+ /** Lock the entire bulk scope before any per-document hook can take state. */ export async function prepareBulkDelete(req, where) {
10
+ await serializeDelete(req, async ()=>{
11
+ // §7.8 transition exception: hook read only discovers the lock set; delete still checks access.
12
+ const { docs } = await req.payload.find({
13
+ collection: 'xp-experiments',
14
+ req,
15
+ overrideAccess: true,
16
+ depth: 0,
17
+ limit: 0,
18
+ pagination: false,
19
+ select: {
20
+ id: true
21
+ },
22
+ where
23
+ });
24
+ for (const experimentId of sortLockKeys(docs.map((doc)=>String(doc.id)))){
25
+ await lockTransitionScope(txExecutor(req), {
26
+ experimentId
27
+ });
28
+ }
29
+ });
30
+ }
8
31
  /** Payload deleteByID owns the request transaction; never commit inside hooks. */ export async function deleteExperiment(req, experimentId) {
9
32
  await serializeDelete(req, async (state)=>{
10
33
  const exec = txExecutor(req);
@@ -40,6 +63,7 @@ import { readExperiment, refuse } from './start.js';
40
63
  }
41
64
  await deleteExperimentSnapshots(exec, experimentId);
42
65
  await deleteExperimentEvents(exec, experimentId);
66
+ state.retiredSlugs.add(current.slug);
43
67
  state.excludedIds.set(String(experimentId), experimentId);
44
68
  const decision = current.decision;
45
69
  if (current.status !== 'archived' || decision?.discardedAt) state.pruneFlagDefaultIds.set(String(experimentId), experimentId);
@@ -54,6 +78,8 @@ import { readExperiment, refuse } from './start.js';
54
78
  }
55
79
  export async function deleteExperimentAfter(req, experimentId) {
56
80
  await serializeDelete(req, async (state)=>{
81
+ for (const slug of state.retiredSlugs)await retireSlug(txExecutor(req), slug);
82
+ state.retiredSlugs.clear();
57
83
  await buildAndStoreManifest(req, {
58
84
  excludeIds: [
59
85
  ...state.excludedIds.values(),
@@ -3,6 +3,7 @@ import { pooledExecutor, txExecutor } from '../db/executor.js';
3
3
  import { lockTransitionScope } from '../db/locks.js';
4
4
  import { currentTxid, txidStatus } from '../db/txid.js';
5
5
  import { contentChangeSavepoint } from '../db/appends.js';
6
+ import { isPageBusy } from '../db/pageBusy.js';
6
7
  import { XpRefusal } from '../errors.js';
7
8
  import { buildAndStoreManifest } from '../manifest/compile.js';
8
9
  /** Runs transition writes on the request's transaction under ordered locks.
@@ -61,6 +62,9 @@ import { buildAndStoreManifest } from '../manifest/compile.js';
61
62
  };
62
63
  } catch (error) {
63
64
  if (!borrowedAudit) await killTransaction(req);
65
+ if (!scope.audit && isPageBusy(error)) throw new XpRefusal('page-busy', "One of this test's pages is being saved right now. Try again in a moment.", undefined, 409, {
66
+ cause: error
67
+ });
64
68
  throw error;
65
69
  } finally{
66
70
  if (hadTransition) req.context.xpTransition = previousTransition;
@@ -10,6 +10,11 @@ type ContentDoc = {
10
10
  dismissedAt?: string;
11
11
  dismissedBy?: string | null;
12
12
  };
13
+ /** Each placement commits independently while the outer transition retains its
14
+ * experiment locks. Keep the manager's exact req/user, temporarily lending it a
15
+ * fresh transaction; a denied host write must not kill the outer transaction.
16
+ */
17
+ export declare function hostTransaction(req: PayloadRequest, experimentId: DocId, fn: () => Promise<void>): Promise<void>;
13
18
  export declare function makeContent<TUser extends {
14
19
  id: DocId;
15
20
  }>(req: PayloadRequest, { experimentId, options }: {
@@ -1,5 +1,7 @@
1
1
  import { Forbidden, NotFound, ValidationError, Locked, commitTransaction, initTransaction, killTransaction } from 'payload';
2
2
  import { latestDraftContent, publishedContent as readPublishedContent } from '../db/publishedContent.js';
3
+ import { EXPERIMENT_BLOCK_SLUG, hostBlocks, slotBlocks } from '../blocks/model.js';
4
+ import { findRegisteredBlocksField, freshBlockIds } from '../blocks/formState.js';
3
5
  import { contentBlocks, hasLocalizedFields } from './contentFields.js';
4
6
  import { safeError } from '../log.js';
5
7
  import { XpRefusal } from '../errors.js';
@@ -7,7 +9,7 @@ import { lockContentDefaults } from '../db/appends.js';
7
9
  import { pooledExecutor, txExecutor } from '../db/executor.js';
8
10
  import { currentTxid, txidStatus } from '../db/txid.js';
9
11
  import { buildAndStoreManifest } from '../manifest/compile.js';
10
- import { puckCollections } from '../options.js';
12
+ import { documentEditor, puckCollections } from '../options.js';
11
13
  import { contentDone } from '../contentDone.js';
12
14
  import { archive } from './archive.js';
13
15
  import { relationshipId } from './checkStart.js';
@@ -162,9 +164,14 @@ async function fieldRefusal(fields, req, doc, operation = 'update', data = doc,
162
164
  /** Each placement commits independently while the outer transition retains its
163
165
  * experiment locks. Keep the manager's exact req/user, temporarily lending it a
164
166
  * fresh transaction; a denied host write must not kill the outer transaction.
165
- */ async function hostTransaction(req, fn) {
167
+ */ export async function hostTransaction(req, experimentId, fn) {
166
168
  const transactionID = req.transactionID;
167
169
  const transition = req.context.xpTransition;
170
+ const hadHeld = Object.hasOwn(req.context, 'xpHeldExperiments');
171
+ const held = req.context.xpHeldExperiments;
172
+ req.context.xpHeldExperiments = [
173
+ experimentId
174
+ ];
168
175
  delete req.transactionID;
169
176
  delete req.context.xpTransition;
170
177
  try {
@@ -178,6 +185,8 @@ async function fieldRefusal(fields, req, doc, operation = 'update', data = doc,
178
185
  throw error;
179
186
  } finally{
180
187
  req.transactionID = transactionID;
188
+ if (hadHeld) req.context.xpHeldExperiments = held;
189
+ else delete req.context.xpHeldExperiments;
181
190
  if (transition === undefined) delete req.context.xpTransition;
182
191
  else req.context.xpTransition = transition;
183
192
  }
@@ -217,7 +226,7 @@ export async function makeContent(req, { experimentId, options }) {
217
226
  if (retainDismissed(collection, id)) return;
218
227
  let missing = false;
219
228
  try {
220
- await hostTransaction(req, async ()=>{
229
+ await hostTransaction(req, experimentId, async ()=>{
221
230
  try {
222
231
  await fn();
223
232
  } catch (error) {
@@ -264,7 +273,10 @@ export async function makeContent(req, { experimentId, options }) {
264
273
  };
265
274
  if (current.kind === 'block') {
266
275
  if (!winner.slot) refuse('invalid-winner');
267
- const registrations = puckCollections(options);
276
+ const registrations = [
277
+ ...puckCollections(options),
278
+ ...options.blocks?.collections ?? []
279
+ ];
268
280
  const scanned = [];
269
281
  for (const registration of registrations){
270
282
  const transactionID = req.transactionID;
@@ -272,9 +284,8 @@ export async function makeContent(req, { experimentId, options }) {
272
284
  try {
273
285
  scanned.push(...await scanBlockPlacements(req, {
274
286
  slug: current.slug,
275
- puckCollections: [
276
- registration
277
- ]
287
+ puckCollections: puckCollections(options).filter((r)=>r === registration),
288
+ blocksCollections: (options.blocks?.collections ?? []).filter((r)=>r === registration)
278
289
  }));
279
290
  } catch (error) {
280
291
  // A collection made unreadable since the last attempt still has
@@ -330,7 +341,7 @@ export async function makeContent(req, { experimentId, options }) {
330
341
  let detail = isNotFound(error) ? 'forbidden' : contentFailureReason(error);
331
342
  if (isNotFound(error)) {
332
343
  try {
333
- await hostTransaction(req, async ()=>{
344
+ await hostTransaction(req, experimentId, async ()=>{
334
345
  missing = !await readPublishedContent(req, collection, id);
335
346
  });
336
347
  } catch (checkError) {
@@ -367,7 +378,7 @@ export async function makeContent(req, { experimentId, options }) {
367
378
  for (const [key, { collection, id }] of placements){
368
379
  if (skipped.has(key)) continue;
369
380
  await attempt(collection, id, async ()=>{
370
- const field = registrations.find((entry)=>entry.slug === collection)?.field;
381
+ let field = registrations.find((entry)=>entry.slug === collection)?.field;
371
382
  if (!field) throw new Error('This content collection is no longer configured.');
372
383
  const config = req.payload.config.collections.find((config)=>config.slug === collection);
373
384
  if (hasLocalizedFields(config?.fields ?? [], req.payload.config)) throw new LocalizedContent();
@@ -382,10 +393,27 @@ export async function makeContent(req, { experimentId, options }) {
382
393
  const drafts = Boolean(config?.versions && config.versions.drafts);
383
394
  const published = await readPublishedContent(req, collection, id);
384
395
  if (!published) throw new NotFound();
396
+ const editor = documentEditor(options, collection, published);
397
+ const blockRegistration = options.blocks?.collections.find((r)=>r.slug === collection);
398
+ const contentField = editor === 'blocks' ? blockRegistration?.field : field;
399
+ if (!contentField) throw new Error('This content field is no longer configured.');
400
+ field = contentField;
401
+ const transform = (value)=>editor === 'blocks' ? !hostBlocks(value).some((block)=>block.blockType === (blockRegistration?.blockSlug ?? EXPERIMENT_BLOCK_SLUG) && block.experiment === current.slug) ? value : hostBlocks(value).flatMap((block)=>{
402
+ if (block.blockType !== (blockRegistration?.blockSlug ?? EXPERIMENT_BLOCK_SLUG) || block.experiment !== current.slug) return [
403
+ block
404
+ ];
405
+ const rows = Array.isArray(block.slots) ? block.slots : [];
406
+ if (!rows.some((row)=>row?.slot === winner.slot)) throw new Error('The winning content slot is missing.');
407
+ const registered = findRegisteredBlocksField(config?.fields ?? [], field);
408
+ if (!registered) throw new Error('This content field is no longer configured.');
409
+ return slotBlocks(freshBlockIds([
410
+ block
411
+ ], registered.field, req.payload.config)[0], winner.slot);
412
+ }) : unwrap(value, current.slug, winner.slot);
385
413
  const draft = drafts ? await latestDraftContent(req, collection, id) ?? published : published;
386
414
  const draftValue = draft[field] ?? null;
387
- const draftContent = unwrap(draftValue, current.slug, winner.slot);
388
- const publishedContent = unwrap(published[field], current.slug, winner.slot);
415
+ const draftContent = transform(draftValue);
416
+ const publishedContent = transform(published[field]);
389
417
  const changedDraft = JSON.stringify(draftContent) !== JSON.stringify(draftValue);
390
418
  const changedPublished = JSON.stringify(publishedContent) !== JSON.stringify(published[field]);
391
419
  if (!changedDraft && !changedPublished) return;
@@ -0,0 +1,10 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { ExperimentsPageCollection, ResolvedOptions } from '../types/contracts.js';
3
+ /** A stored collection is authoritative. Legacy tests fall back by the Original's path. */
4
+ export declare function resolvePageCollection(req: PayloadRequest, options: Pick<ResolvedOptions, 'pages'>, experiment: {
5
+ pageCollection?: string | null;
6
+ arms: readonly {
7
+ kind?: string;
8
+ pageTarget?: string;
9
+ }[];
10
+ }, propagateErrors?: boolean): Promise<ExperimentsPageCollection | undefined>;
@@ -0,0 +1,20 @@
1
+ import { safeError } from '../log.js';
2
+ /** A stored collection is authoritative. Legacy tests fall back by the Original's path. */ export async function resolvePageCollection(req, options, experiment, propagateErrors = false) {
3
+ const collections = options.pages?.collections ?? [];
4
+ if (experiment.pageCollection) return collections.find((entry)=>entry.slug === experiment.pageCollection);
5
+ if (collections.length === 1) return collections[0];
6
+ const path = experiment.arms.find((arm)=>arm.kind === 'control')?.pageTarget;
7
+ if (!path) return undefined;
8
+ for (const entry of collections){
9
+ try {
10
+ if (await entry.findByPath(path, req)) return entry;
11
+ } catch (cause) {
12
+ if (propagateErrors) throw cause;
13
+ req.payload.logger.error({
14
+ err: safeError(cause),
15
+ collection: entry.slug
16
+ }, 'Experiment page lookup failed');
17
+ }
18
+ }
19
+ return undefined;
20
+ }
@@ -0,0 +1,10 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { Arm, ExperimentsPageCollection } from '../types/contracts.js';
3
+ /** Page copies have fresh nested row ids and editor labels; neither is rendered content. */
4
+ export declare function comparableContent(value: unknown): unknown;
5
+ export declare function identicalPageVersions(req: PayloadRequest, collection: ExperimentsPageCollection | undefined, arms: Arm[]): Promise<{
6
+ arm: import("../types/contracts.js").ArmKey;
7
+ path: string;
8
+ collection: string;
9
+ id: string | number;
10
+ }[]>;
@@ -0,0 +1,44 @@
1
+ import { safeError } from '../log.js';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { pageVersionContent } from '../db/pageContent.js';
4
+ /** Page copies have fresh nested row ids and editor labels; neither is rendered content. */ export function comparableContent(value) {
5
+ if (Array.isArray(value)) return value.map(comparableContent);
6
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).filter(([key])=>key !== 'id' && key !== 'blockName').map(([key, nested])=>[
7
+ key,
8
+ comparableContent(nested)
9
+ ]));
10
+ return value;
11
+ }
12
+ export async function identicalPageVersions(req, collection, arms) {
13
+ if (!collection?.contentOf) return [];
14
+ const content = await Promise.all(arms.filter((arm)=>arm.kind !== 'holdout' && arm.pageTarget).map(async (arm)=>{
15
+ try {
16
+ const page = await collection.findByPath(arm.pageTarget, req);
17
+ if (!page?.published) return null;
18
+ const raw = await pageVersionContent(req, collection.slug, page.id);
19
+ if (!raw || raw._status === 'draft') return null;
20
+ return {
21
+ arm,
22
+ id: page.id,
23
+ value: comparableContent(collection.contentOf(raw))
24
+ };
25
+ } catch (cause) {
26
+ req.payload.logger.error({
27
+ err: safeError(cause),
28
+ collection: collection.slug,
29
+ id: arm.pageTarget
30
+ }, 'Experiment version comparison failed');
31
+ return null;
32
+ }
33
+ }));
34
+ const control = content.find((row)=>row?.arm.kind === 'control');
35
+ if (!control) return [];
36
+ return content.flatMap((row)=>row && row.arm.kind === 'variant' && isDeepStrictEqual(row.value, control.value) ? [
37
+ {
38
+ arm: row.arm.key,
39
+ path: row.arm.pageTarget,
40
+ collection: collection.slug,
41
+ id: row.id
42
+ }
43
+ ] : []);
44
+ }
@@ -16,6 +16,7 @@ export interface PhaseRecord {
16
16
  analysis?: Record<string, unknown> & {
17
17
  sequentialTuningParameter?: number | null;
18
18
  };
19
+ placementDocs?: import('../db/placements.js').PlacementDoc[] | null;
19
20
  contentChanges?: ContentChange[];
20
21
  }
21
22
  export declare function readPhaseRecord(req: PayloadRequest, experimentId: DocId, phase: number): Promise<PhaseRecord & {
@@ -0,0 +1,40 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { Arm, DocId, ResolvedOptions } from '../types/contracts.js';
3
+ import type { Executor } from '../db/executor.js';
4
+ import { type PlacementDoc } from '../db/placements.js';
5
+ import { type BlockPlacement } from './placements.js';
6
+ export declare function detached<T>(req: PayloadRequest, fn: () => Promise<T>): Promise<T>;
7
+ export declare function uniquePlacementDocs(placements: readonly Pick<BlockPlacement, 'collection' | 'id'>[]): PlacementDoc[];
8
+ export declare function scanPlacements<TUser extends {
9
+ id: DocId;
10
+ }>(req: PayloadRequest, options: ResolvedOptions<TUser>, slug: string): Promise<BlockPlacement[]>;
11
+ export declare function placementPaths<TUser extends {
12
+ id: DocId;
13
+ }>(req: PayloadRequest, options: ResolvedOptions<TUser>, docs: readonly PlacementDoc[]): Promise<string[]>;
14
+ export interface InteractionDetail {
15
+ experimentId: DocId;
16
+ slug: string;
17
+ name: string;
18
+ status: string;
19
+ path: string;
20
+ version: boolean;
21
+ }
22
+ export declare function placementInteractions<TUser extends {
23
+ id: DocId;
24
+ }>(req: PayloadRequest, exec: Executor, options: ResolvedOptions<TUser>, experiment: {
25
+ id: DocId;
26
+ kind: string;
27
+ arms: Arm[];
28
+ pageCollection?: string | null;
29
+ }, paths: string[]): Promise<InteractionDetail[]>;
30
+ export declare function lockAndRescanPlacements<TUser extends {
31
+ id: DocId;
32
+ }>(req: PayloadRequest, exec: Executor, options: ResolvedOptions<TUser>, experiment: {
33
+ id: DocId;
34
+ slug: string;
35
+ kind: string;
36
+ arms: Arm[];
37
+ }, before: readonly BlockPlacement[]): Promise<{
38
+ placements: BlockPlacement[];
39
+ docs: PlacementDoc[];
40
+ }>;
@@ -0,0 +1,94 @@
1
+ import { placementPathContent, placementTests } from '../db/placements.js';
2
+ import { lockPagePaths } from '../db/locks.js';
3
+ import { normalisePath } from '../runtime/normalisePath.js';
4
+ import { scanBlockPlacements } from './placements.js';
5
+ import { interactingTestsCopy } from '../admin/copy/readiness.js';
6
+ import { XpRefusal } from '../errors.js';
7
+ import { pageTargetPaths } from './checkStart.js';
8
+ export async function detached(req, fn) {
9
+ const transactionID = req.transactionID;
10
+ delete req.transactionID;
11
+ try {
12
+ return await fn();
13
+ } finally{
14
+ req.transactionID = transactionID;
15
+ }
16
+ }
17
+ export function uniquePlacementDocs(placements) {
18
+ return [
19
+ ...new Map(placements.map((p)=>[
20
+ JSON.stringify([
21
+ p.collection,
22
+ String(p.id)
23
+ ]),
24
+ {
25
+ collection: p.collection,
26
+ id: p.id
27
+ }
28
+ ])).values()
29
+ ];
30
+ }
31
+ export function scanPlacements(req, options, slug) {
32
+ return scanBlockPlacements(req, {
33
+ slug,
34
+ puckCollections: options.puck?.collections ?? [],
35
+ blocksCollections: options.blocks?.collections ?? []
36
+ });
37
+ }
38
+ export async function placementPaths(req, options, docs) {
39
+ const paths = new Set();
40
+ for (const doc of docs){
41
+ const registration = options.pages?.collections.find((r)=>r.slug === doc.collection);
42
+ if (!registration) continue;
43
+ const row = await placementPathContent(req, doc.collection, doc.id);
44
+ if (!row || row._status === 'draft') continue;
45
+ const path = registration.pathOf(row);
46
+ if (path !== null) paths.add(normalisePath(path));
47
+ }
48
+ return [
49
+ ...paths
50
+ ].sort();
51
+ }
52
+ const active = (test)=>test.status === 'running' || test.status === 'decided';
53
+ export async function placementInteractions(req, exec, options, experiment, paths) {
54
+ const result = [];
55
+ for (const test of (await placementTests(exec))){
56
+ if (!active(test) || String(test.id) === String(experiment.id)) continue;
57
+ if (experiment.kind === 'block' && test.kind === 'page') {
58
+ for (const path of paths.filter((path)=>pageTargetPaths(test.arms).includes(path)))result.push({
59
+ experimentId: test.id,
60
+ slug: test.slug,
61
+ name: test.name,
62
+ status: test.status,
63
+ path,
64
+ version: test.arms.some((a)=>a.kind === 'variant' && a.pageTarget && normalisePath(a.pageTarget) === path)
65
+ });
66
+ } else if (experiment.kind === 'page' && test.kind === 'block') {
67
+ const docs = test.placementDocs ?? uniquePlacementDocs(await detached(req, ()=>scanPlacements(req, options, test.slug)));
68
+ const relevant = docs.filter((doc)=>!experiment.pageCollection || doc.collection === experiment.pageCollection);
69
+ for (const path of (await placementPaths(req, options, relevant)).filter((path)=>paths.includes(path)))result.push({
70
+ experimentId: test.id,
71
+ slug: test.slug,
72
+ name: test.name,
73
+ status: test.status,
74
+ path,
75
+ version: experiment.arms.some((a)=>a.kind === 'variant' && a.pageTarget && normalisePath(a.pageTarget) === path)
76
+ });
77
+ }
78
+ }
79
+ return result;
80
+ }
81
+ export async function lockAndRescanPlacements(req, exec, options, experiment, before) {
82
+ const paths = await placementPaths(req, options, uniquePlacementDocs(before));
83
+ await lockPagePaths(exec, paths);
84
+ const placements = await detached(req, ()=>scanPlacements(req, options, experiment.slug));
85
+ const docs = uniquePlacementDocs(placements);
86
+ const afterPaths = await placementPaths(req, options, docs);
87
+ if (afterPaths.some((path)=>!paths.includes(path))) throw new XpRefusal('placements-changed', "This test's pages changed while it was starting. Try again.", undefined, 409);
88
+ const conflicts = await placementInteractions(req, exec, options, experiment, paths);
89
+ if (conflicts.length) throw new XpRefusal('interacting-tests', interactingTestsCopy(conflicts[0].path, conflicts[0].status, conflicts[0].name, conflicts[0].version), conflicts, 409);
90
+ return {
91
+ placements,
92
+ docs
93
+ };
94
+ }