@delmaredigital/payload-experiments 0.3.0-rc.1 → 0.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. package/README.md +172 -21
  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 +47 -26
  7. package/dist/admin/TestsNavLink.d.ts +3 -1
  8. package/dist/admin/TestsNavLink.js +2 -2
  9. package/dist/admin/actions/ConfirmDialog.d.ts +2 -1
  10. package/dist/admin/actions/ConfirmDialog.js +2 -2
  11. package/dist/admin/actions/DecideDialog.d.ts +2 -1
  12. package/dist/admin/actions/DecideDialog.js +2 -2
  13. package/dist/admin/api.d.ts +11 -2
  14. package/dist/admin/api.js +20 -2
  15. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  16. package/dist/admin/arms/arms.model.d.ts +1 -21
  17. package/dist/admin/arms/arms.model.js +1 -153
  18. package/dist/admin/blocks/BlockPanel.d.ts +4 -0
  19. package/dist/admin/blocks/BlockPanel.js +153 -0
  20. package/dist/admin/blocks/TestBlockField.d.ts +5 -0
  21. package/dist/admin/blocks/TestBlockField.js +163 -0
  22. package/dist/admin/blocks/formApi.d.ts +43 -0
  23. package/dist/admin/blocks/formApi.js +153 -0
  24. package/dist/admin/blocks/inject.d.ts +7 -0
  25. package/dist/admin/blocks/inject.js +41 -0
  26. package/dist/admin/blocks/model.d.ts +17 -0
  27. package/dist/admin/blocks/model.js +45 -0
  28. package/dist/admin/blocks/statusCache.d.ts +7 -0
  29. package/dist/admin/blocks/statusCache.js +83 -0
  30. package/dist/admin/blocks/useBlockForm.d.ts +17 -0
  31. package/dist/admin/blocks/useBlockForm.js +46 -0
  32. package/dist/admin/copy/actions.d.ts +2 -0
  33. package/dist/admin/copy/actions.js +9 -0
  34. package/dist/admin/copy/arms.d.ts +1 -6
  35. package/dist/admin/copy/arms.js +1 -16
  36. package/dist/admin/copy/blocks.d.ts +27 -0
  37. package/dist/admin/copy/blocks.js +38 -0
  38. package/dist/admin/copy/goals.d.ts +2 -0
  39. package/dist/admin/copy/goals.js +4 -1
  40. package/dist/admin/copy/newTest.d.ts +10 -0
  41. package/dist/admin/copy/newTest.js +10 -0
  42. package/dist/admin/copy/pages.d.ts +26 -0
  43. package/dist/admin/copy/pages.js +26 -0
  44. package/dist/admin/copy/readiness.d.ts +25 -3
  45. package/dist/admin/copy/readiness.js +25 -3
  46. package/dist/admin/copy/results.d.ts +3 -0
  47. package/dist/admin/copy/results.js +4 -1
  48. package/dist/admin/copy/review.d.ts +1 -1
  49. package/dist/admin/copy/review.js +1 -1
  50. package/dist/admin/copy/surface.d.ts +0 -1
  51. package/dist/admin/copy/surface.js +0 -1
  52. package/dist/admin/copy/testPage.d.ts +4 -0
  53. package/dist/admin/copy/testPage.js +4 -0
  54. package/dist/admin/goals/XpGoalField.d.ts +2 -0
  55. package/dist/admin/goals/XpGoalField.js +152 -0
  56. package/dist/admin/metricSource.model.d.ts +2 -0
  57. package/dist/admin/metricSource.model.js +4 -0
  58. package/dist/admin/pageCollections.d.ts +10 -0
  59. package/dist/admin/pageCollections.js +20 -0
  60. package/dist/admin/paths.d.ts +8 -1
  61. package/dist/admin/paths.js +8 -1
  62. package/dist/admin/placementStatus.model.d.ts +2 -0
  63. package/dist/admin/placementStatus.model.js +19 -0
  64. package/dist/admin/placements/placementLabels.d.ts +11 -0
  65. package/dist/admin/placements/placementLabels.js +21 -0
  66. package/dist/admin/placements/placementLabels.model.d.ts +13 -0
  67. package/dist/admin/placements/placementLabels.model.js +12 -0
  68. package/dist/admin/placements/usePlacementLabels.d.ts +12 -0
  69. package/dist/admin/placements/usePlacementLabels.js +102 -0
  70. package/dist/admin/previewLinks.model.d.ts +2 -0
  71. package/dist/admin/previewLinks.model.js +3 -0
  72. package/dist/admin/results/DataQualityPanel.js +4 -1
  73. package/dist/admin/results/legacy.d.ts +16 -0
  74. package/dist/admin/results/legacy.js +27 -0
  75. package/dist/admin/results/results.model.d.ts +9 -6
  76. package/dist/admin/results/results.model.js +22 -9
  77. package/dist/admin/results/snapshot.d.ts +14 -0
  78. package/dist/admin/results/snapshot.js +3 -0
  79. package/dist/admin/surface/TestsHome.js +43 -15
  80. package/dist/admin/surface/TestsSurface.js +5 -1
  81. package/dist/admin/surface/contracts.d.ts +6 -5
  82. package/dist/admin/surface/goals/GoalForm.js +67 -34
  83. package/dist/admin/surface/goals/GoalsList.js +3 -3
  84. package/dist/admin/surface/goals/data.js +19 -16
  85. package/dist/admin/surface/goals/goals.model.d.ts +1 -2
  86. package/dist/admin/surface/goals/goals.model.js +1 -3
  87. package/dist/admin/surface/home.model.d.ts +2 -1
  88. package/dist/admin/surface/home.model.js +8 -4
  89. package/dist/admin/surface/newTest/NewTest.js +58 -17
  90. package/dist/admin/surface/newTest/PageWizard.d.ts +7 -0
  91. package/dist/admin/surface/newTest/PageWizard.js +209 -0
  92. package/dist/admin/surface/pagePicker/CopyRoute.d.ts +6 -0
  93. package/dist/admin/surface/pagePicker/CopyRoute.js +53 -0
  94. package/dist/admin/surface/pagePicker/PagePicker.d.ts +10 -0
  95. package/dist/admin/surface/pagePicker/PagePicker.js +172 -0
  96. package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +25 -0
  97. package/dist/admin/surface/pagePicker/pagePicker.model.js +25 -0
  98. package/dist/admin/surface/readiness/ActionDialogs.js +103 -69
  99. package/dist/admin/surface/readiness/Banner.js +30 -6
  100. package/dist/admin/surface/readiness/readiness.model.d.ts +8 -1
  101. package/dist/admin/surface/readiness/readiness.model.js +95 -6
  102. package/dist/admin/surface/results/ReleasePages.d.ts +3 -1
  103. package/dist/admin/surface/results/ReleasePages.js +3 -7
  104. package/dist/admin/surface/results/ResultsBody.js +23 -3
  105. package/dist/admin/surface/results/ResultsHeader.js +2 -5
  106. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  107. package/dist/admin/surface/results/recommendation.model.js +10 -3
  108. package/dist/admin/surface/results/useResults.d.ts +10 -1
  109. package/dist/admin/surface/surface.css +0 -1
  110. package/dist/admin/surface/test/AdvancedSection.js +111 -104
  111. package/dist/admin/surface/test/GoalSection.js +97 -22
  112. package/dist/admin/surface/test/LengthSection.js +1 -1
  113. package/dist/admin/surface/test/PageHoldout.d.ts +3 -0
  114. package/dist/admin/surface/test/PageHoldout.js +67 -0
  115. package/dist/admin/surface/test/TestPage.js +2 -1
  116. package/dist/admin/surface/test/VersionsSection.js +12 -5
  117. package/dist/admin/surface/test/fields.d.ts +13 -4
  118. package/dist/admin/surface/test/fields.js +46 -14
  119. package/dist/admin/surface/test/jumpToSection.d.ts +1 -1
  120. package/dist/admin/surface/test/jumpToSection.js +4 -3
  121. package/dist/admin/surface/test/pageParts.d.ts +4 -1
  122. package/dist/admin/surface/test/pageParts.js +79 -2
  123. package/dist/admin/surface/test/testPage.css +8 -1
  124. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  125. package/dist/admin/surface/test/testPage.model.js +1 -21
  126. package/dist/admin/surface/testDocument.model.d.ts +1 -0
  127. package/dist/admin/surface/testDocument.model.js +2 -1
  128. package/dist/admin/surface/useTestDocument.d.ts +11 -2
  129. package/dist/admin/surface/useTestDocument.js +55 -16
  130. package/dist/admin/useApi.d.ts +11 -2
  131. package/dist/admin/useApi.js +3 -5
  132. package/dist/admin/useLayerUsage.js +14 -23
  133. package/dist/admin/views/ExperimentsRootView.js +58 -16
  134. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  135. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  136. package/dist/arms/copy.d.ts +6 -0
  137. package/dist/arms/copy.js +16 -0
  138. package/dist/arms/model.d.ts +23 -0
  139. package/dist/arms/model.js +173 -0
  140. package/dist/blocks/bindings.d.ts +3 -0
  141. package/dist/blocks/bindings.js +34 -0
  142. package/dist/blocks/createExperimentBlock.d.ts +6 -0
  143. package/dist/blocks/createExperimentBlock.js +77 -0
  144. package/dist/blocks/formState.d.ts +31 -0
  145. package/dist/blocks/formState.js +146 -0
  146. package/dist/blocks/model.d.ts +41 -0
  147. package/dist/blocks/model.js +34 -0
  148. package/dist/client/ExperimentGroup.d.ts +6 -0
  149. package/dist/client/ExperimentGroup.js +9 -0
  150. package/dist/client/ExperimentsProvider.js +20 -4
  151. package/dist/client/clickGoals.d.ts +4 -0
  152. package/dist/client/clickGoals.js +43 -0
  153. package/dist/collections/experiments/config.js +12 -2
  154. package/dist/collections/experiments/deleteGuard.d.ts +2 -1
  155. package/dist/collections/experiments/deleteGuard.js +12 -1
  156. package/dist/collections/experiments/editability.js +4 -2
  157. package/dist/collections/experiments/pickSlug.d.ts +2 -0
  158. package/dist/collections/experiments/pickSlug.js +8 -0
  159. package/dist/collections/labels.d.ts +3 -0
  160. package/dist/collections/labels.js +20 -0
  161. package/dist/collections/metrics.js +12 -2
  162. package/dist/collections/phases.js +1 -0
  163. package/dist/collections/serialize.js +7 -0
  164. package/dist/db/appends.d.ts +6 -2
  165. package/dist/db/appends.js +10 -4
  166. package/dist/db/clickGoals.d.ts +9 -0
  167. package/dist/db/clickGoals.js +22 -0
  168. package/dist/db/delete.d.ts +1 -0
  169. package/dist/db/delete.js +1 -0
  170. package/dist/db/locks.d.ts +14 -0
  171. package/dist/db/locks.js +36 -1
  172. package/dist/db/names.d.ts +4 -0
  173. package/dist/db/names.js +4 -0
  174. package/dist/db/pageBindings.d.ts +4 -0
  175. package/dist/db/pageBindings.js +17 -0
  176. package/dist/db/pageBusy.d.ts +2 -0
  177. package/dist/db/pageBusy.js +10 -0
  178. package/dist/db/pageContent.d.ts +10 -0
  179. package/dist/db/pageContent.js +79 -0
  180. package/dist/db/pageOccupancy.d.ts +12 -0
  181. package/dist/db/pageOccupancy.js +45 -0
  182. package/dist/db/placements.d.ts +33 -0
  183. package/dist/db/placements.js +54 -0
  184. package/dist/db/slugs.d.ts +8 -0
  185. package/dist/db/slugs.js +40 -0
  186. package/dist/db/snapshots.d.ts +6 -0
  187. package/dist/db/snapshots.js +9 -5
  188. package/dist/endpoints/clickKeys.d.ts +5 -0
  189. package/dist/endpoints/clickKeys.js +17 -0
  190. package/dist/endpoints/events.js +4 -1
  191. package/dist/endpoints/index.js +28 -0
  192. package/dist/endpoints/pages.d.ts +5 -0
  193. package/dist/endpoints/pages.js +120 -0
  194. package/dist/endpoints/placementStatus.d.ts +6 -0
  195. package/dist/endpoints/placementStatus.js +238 -0
  196. package/dist/endpoints/readBody.d.ts +2 -0
  197. package/dist/endpoints/readBody.js +24 -0
  198. package/dist/endpoints/results.js +5 -1
  199. package/dist/endpoints/summary.js +11 -4
  200. package/dist/endpoints/wrapBlock.d.ts +6 -0
  201. package/dist/endpoints/wrapBlock.js +76 -0
  202. package/dist/errors.d.ts +3 -3
  203. package/dist/errors.js +4 -4
  204. package/dist/events/rows.d.ts +1 -1
  205. package/dist/events/rows.js +5 -3
  206. package/dist/exports/adminClient.d.ts +5 -0
  207. package/dist/exports/adminClient.js +5 -0
  208. package/dist/exports/adminRsc.d.ts +1 -0
  209. package/dist/exports/adminRsc.js +1 -0
  210. package/dist/exports/blocks.d.ts +2 -0
  211. package/dist/exports/blocks.js +1 -0
  212. package/dist/exports/client.d.ts +1 -0
  213. package/dist/exports/client.js +1 -0
  214. package/dist/exports/index.d.ts +6 -1
  215. package/dist/exports/index.js +3 -0
  216. package/dist/exports/puck.d.ts +5 -2
  217. package/dist/exports/puck.js +3 -1
  218. package/dist/exports/puckEditor.d.ts +6 -1
  219. package/dist/exports/puckEditor.js +4 -1
  220. package/dist/exports/rsc.d.ts +4 -0
  221. package/dist/exports/rsc.js +2 -0
  222. package/dist/fields/xpGoal.d.ts +3 -0
  223. package/dist/fields/xpGoal.js +28 -0
  224. package/dist/globals/state.js +10 -0
  225. package/dist/goals/cache.d.ts +3 -0
  226. package/dist/goals/cache.js +35 -0
  227. package/dist/goals/fairness.d.ts +40 -0
  228. package/dist/goals/fairness.js +200 -0
  229. package/dist/goals/key.d.ts +4 -0
  230. package/dist/goals/key.js +3 -0
  231. package/dist/goals/picker/clickGoalPicker.model.d.ts +29 -0
  232. package/dist/goals/picker/clickGoalPicker.model.js +45 -0
  233. package/dist/goals/picker/copy.d.ts +16 -0
  234. package/dist/goals/picker/copy.js +15 -0
  235. package/dist/goals/picker/useClickGoalPicker.d.ts +30 -0
  236. package/dist/goals/picker/useClickGoalPicker.js +220 -0
  237. package/dist/hooks/pageGuard.d.ts +5 -1
  238. package/dist/hooks/pageGuard.js +160 -80
  239. package/dist/lib/fetchAllDocs.d.ts +16 -0
  240. package/dist/lib/fetchAllDocs.js +56 -0
  241. package/dist/onInit.js +29 -0
  242. package/dist/options.d.ts +2 -0
  243. package/dist/options.js +43 -10
  244. package/dist/plugin.js +45 -28
  245. package/dist/puck/ExperimentPicker.js +47 -16
  246. package/dist/puck/ExperimentSlot.d.ts +51 -1
  247. package/dist/puck/ExperimentSlot.js +23 -12
  248. package/dist/puck/copy.d.ts +6 -4
  249. package/dist/puck/copy.js +6 -4
  250. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  251. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  252. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  253. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  254. package/dist/puck/editor/ResultsReadout.js +99 -0
  255. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  256. package/dist/puck/editor/VersionBuilder.js +317 -0
  257. package/dist/puck/editor/armWrites.d.ts +7 -0
  258. package/dist/puck/editor/armWrites.js +71 -0
  259. package/dist/puck/editor/armsCache.d.ts +14 -0
  260. package/dist/puck/editor/armsCache.js +78 -0
  261. package/dist/puck/editor/copy.d.ts +33 -1
  262. package/dist/puck/editor/copy.js +45 -8
  263. package/dist/puck/editor/createTest.d.ts +1 -1
  264. package/dist/puck/editor/createTest.js +25 -14
  265. package/dist/puck/editor/editor.chrome.css +48 -161
  266. package/dist/puck/editor/editor.css +48 -161
  267. package/dist/puck/editor/editor.model.d.ts +8 -3
  268. package/dist/puck/editor/editor.model.js +16 -10
  269. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  270. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  271. package/dist/puck/editor/integration.d.ts +10 -0
  272. package/dist/puck/editor/integration.js +30 -0
  273. package/dist/puck/editor/xpGoalPuckEditorField.d.ts +7 -0
  274. package/dist/puck/editor/xpGoalPuckEditorField.js +134 -0
  275. package/dist/puck/experimentSlotField.d.ts +2 -0
  276. package/dist/puck/experimentSlotField.js +11 -3
  277. package/dist/puck/hostContext.d.ts +10 -0
  278. package/dist/puck/hostContext.js +12 -0
  279. package/dist/puck/integration.d.ts +6 -0
  280. package/dist/puck/integration.js +21 -0
  281. package/dist/puck/routes.d.ts +12 -0
  282. package/dist/puck/routes.js +11 -0
  283. package/dist/puck/slotConfig.d.ts +1 -1
  284. package/dist/puck/slotConfig.js +2 -2
  285. package/dist/puck/xpGoalField.d.ts +3 -0
  286. package/dist/puck/xpGoalField.js +6 -0
  287. package/dist/results/health.d.ts +4 -0
  288. package/dist/results/health.js +27 -0
  289. package/dist/results/snapshot.js +15 -2
  290. package/dist/sanitizeReadinessDetail.js +51 -1
  291. package/dist/server/metadata.js +9 -2
  292. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  293. package/dist/server/normaliseContentRelationships.js +62 -0
  294. package/dist/server/pageKindSeoContract.d.ts +27 -0
  295. package/dist/server/pageKindSeoContract.js +80 -0
  296. package/dist/server/recordContentChange.d.ts +1 -1
  297. package/dist/server/recordContentChange.js +72 -13
  298. package/dist/server/resolveExperimentBlocks.d.ts +5 -0
  299. package/dist/server/resolveExperimentBlocks.js +54 -0
  300. package/dist/server/runResults.js +92 -19
  301. package/dist/server/toClientAssignments.js +2 -1
  302. package/dist/transitions/addMetric.d.ts +2 -1
  303. package/dist/transitions/addMetric.js +28 -1
  304. package/dist/transitions/checkStart.d.ts +17 -1
  305. package/dist/transitions/checkStart.js +99 -7
  306. package/dist/transitions/decide.js +40 -1
  307. package/dist/transitions/deleteExperiment.d.ts +3 -1
  308. package/dist/transitions/deleteExperiment.js +27 -1
  309. package/dist/transitions/kernel.js +4 -0
  310. package/dist/transitions/makeContent.d.ts +5 -0
  311. package/dist/transitions/makeContent.js +39 -11
  312. package/dist/transitions/pageCollection.d.ts +10 -0
  313. package/dist/transitions/pageCollection.js +20 -0
  314. package/dist/transitions/pageVersions.d.ts +10 -0
  315. package/dist/transitions/pageVersions.js +44 -0
  316. package/dist/transitions/phaseRecord.d.ts +1 -0
  317. package/dist/transitions/placementInteractions.d.ts +40 -0
  318. package/dist/transitions/placementInteractions.js +94 -0
  319. package/dist/transitions/placements.d.ts +10 -6
  320. package/dist/transitions/placements.js +156 -2
  321. package/dist/transitions/preflightData.d.ts +8 -0
  322. package/dist/transitions/preflightData.js +248 -36
  323. package/dist/transitions/resolveCaps.d.ts +1 -0
  324. package/dist/transitions/resolveCaps.js +6 -2
  325. package/dist/transitions/start.js +77 -3
  326. package/dist/types/admin.d.ts +1 -1
  327. package/dist/types/assignments.d.ts +1 -0
  328. package/dist/types/contracts.d.ts +31 -10
  329. package/dist/types/endpoints.d.ts +92 -5
  330. package/dist/types/index.d.ts +3 -0
  331. package/dist/types/puck.d.ts +7 -1
  332. package/dist/types/snapshot.d.ts +17 -4
  333. package/dist/types/surface.d.ts +13 -0
  334. package/dist/ui/Tabs.d.ts +4 -2
  335. package/dist/ui/Tabs.js +33 -26
  336. package/dist/ui/accent.d.ts +22 -3
  337. package/dist/ui/accent.js +74 -16
  338. package/package.json +12 -2
@@ -1,13 +1,15 @@
1
+ import { type GoalContent } from '../goals/fairness.js';
1
2
  import type { Arm, DocId, ExperimentKind, ExposurePolicy, FlagPoint, MetricSource, ResolvedOptions, Status, TargetingRule, UnitType } from '../types/contracts.js';
2
3
  import type { ArmIssueCode, PreflightResult } from '../types/endpoints.js';
3
4
  import type { BlockPlacement, FlagPlacement } from './placements.js';
4
- export type ReadinessCode = ArmIssueCode | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'page-no-targets' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
5
+ export type ReadinessCode = ArmIssueCode | 'goal-in-some-versions' | 'placement-unchecked' | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'placement-unpublished' | 'duplicate-version-row' | 'interacting-tests' | 'version-identical' | 'version-same-address' | 'unknown-collection' | 'page-no-targets' | 'page-in-use' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
5
6
  export interface StartExperiment {
6
7
  id: DocId;
7
8
  slug: string;
8
9
  status: Status;
9
10
  kind: ExperimentKind;
10
11
  unitType: UnitType;
12
+ pageCollection?: string | null;
11
13
  arms: Arm[];
12
14
  allocation: number;
13
15
  exposurePolicy: ExposurePolicy;
@@ -37,6 +39,7 @@ export interface StartExperiment {
37
39
  export interface StartMetric {
38
40
  id: DocId;
39
41
  source: string;
42
+ name?: string;
40
43
  }
41
44
  export type PlacementFailure = 'placements-failed' | 'forbidden' | 'not-found';
42
45
  export interface CheckStartInput {
@@ -49,8 +52,18 @@ export interface CheckStartInput {
49
52
  placementError?: PlacementFailure;
50
53
  flagPlacementError?: PlacementFailure;
51
54
  flagPointBusy?: boolean;
55
+ goalContent?: GoalContent;
56
+ interactions?: import('./placementInteractions.js').InteractionDetail[];
57
+ unknownPageCollection?: boolean;
58
+ identicalVersions?: Array<{
59
+ arm: string;
60
+ path: string;
61
+ collection: string;
62
+ id: DocId;
63
+ }>;
52
64
  unpublishedPageTargets?: string[];
53
65
  pageTargetsUnchecked?: boolean;
66
+ pageTestsInUse?: Pick<StartExperiment, 'id' | 'arms'>[];
54
67
  layer?: {
55
68
  unitType: UnitType;
56
69
  usedSlices: number[];
@@ -61,5 +74,8 @@ export declare const ARM_CHECK_ORDER: readonly ArmIssueCode[];
61
74
  export declare const relationshipId: (value: DocId | {
62
75
  id: DocId;
63
76
  } | null | undefined) => DocId | undefined;
77
+ /** Every path the runtime would match for these arms (pageRewrite, experimentMetadata): any string
78
+ * pageTarget, holdout included, normalised the same way ('' is '/'). Occupancy and lock keys use this. */
79
+ export declare const pageTargetPaths: (arms: readonly Arm[]) => string[];
64
80
  /** Pure Review/Start predicates in refusal order. Inapplicable checks pass. */
65
81
  export declare function checkStart(input: CheckStartInput): PreflightResult['checks'];
@@ -1,4 +1,6 @@
1
+ import { asymmetricGoal } from '../goals/fairness.js';
1
2
  import { checkArms } from '../collections/experiments/armInvariants.js';
3
+ import { normalisePath } from '../runtime/normalisePath.js';
2
4
  import { isOpValid } from '../targeting/ops.js';
3
5
  export const ARM_CHECK_ORDER = [
4
6
  'weights-total',
@@ -13,6 +15,15 @@ export const ARM_CHECK_ORDER = [
13
15
  'duplicate-slot'
14
16
  ];
15
17
  export const relationshipId = (value)=>value == null ? undefined : typeof value === 'object' ? value.id : value;
18
+ /** Every path the runtime would match for these arms (pageRewrite, experimentMetadata): any string
19
+ * pageTarget, holdout included, normalised the same way ('' is '/'). Occupancy and lock keys use this. */ export const pageTargetPaths = (arms)=>[
20
+ ...new Set(arms.filter((arm)=>typeof arm.pageTarget === 'string').map((arm)=>normalisePath(arm.pageTarget)))
21
+ ].sort();
22
+ const placementDetail = ({ collection, id, title })=>({
23
+ collection,
24
+ id,
25
+ title
26
+ });
16
27
  const positive = (value)=>typeof value === 'number' && Number.isFinite(value) && value > 0;
17
28
  function invalidParams(arms, point) {
18
29
  return arms.flatMap((arm)=>{
@@ -56,6 +67,33 @@ function invalidParams(arms, point) {
56
67
  'viewport'
57
68
  ].includes(e.exposurePolicy) || positive(e.analysis?.reachPerDay));
58
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
+ });
92
+ const occupiedPaths = new Set((input.pageTestsInUse ?? []).filter((other)=>String(other.id) !== String(e.id)).flatMap((other)=>pageTargetPaths(other.arms)));
93
+ const sharedPaths = e.kind === 'page' ? pageTargetPaths(e.arms).filter((path)=>occupiedPaths.has(path)) : [];
94
+ add('page-in-use', sharedPaths.length === 0, {
95
+ paths: sharedPaths
96
+ });
59
97
  add('page-target-unpublished', !input.unpublishedPageTargets?.length, input.unpublishedPageTargets?.[0]);
60
98
  add('page-targets-unchecked', true);
61
99
  if (input.pageTargetsUnchecked) checks[checks.length - 1].warning = true;
@@ -69,12 +107,36 @@ function invalidParams(arms, point) {
69
107
  const failed = input.flagPlacements?.filter((placement)=>!placement.ok) ?? [];
70
108
  add('flag-placement-missing', e.kind !== 'flag' || !input.flagPlacementError && missing.length === 0 && failed.length === 0, input.flagPlacementError ?? {
71
109
  missing: missing.map((arm)=>arm.key),
72
- placements: failed
110
+ placements: failed.map(({ arm, ok, detail })=>({
111
+ arm,
112
+ ok,
113
+ detail
114
+ }))
73
115
  });
74
116
  const placements = input.placements ?? [];
75
117
  add('no-placements', e.kind !== 'block' || !input.placementError && placements.length > 0, input.placementError);
76
- const empty = placements.filter((placement)=>e.arms.some((arm)=>arm.kind !== 'holdout' && (!arm.slot || !(placement.slots[arm.slot] > 0))));
77
- add('empty-slot', e.kind !== 'block' || empty.length === 0, empty);
118
+ const unpublished = placements.filter((p)=>!p.published && !placements.some((other)=>other.published && other.collection === p.collection && String(other.id) === String(p.id)));
119
+ add('placement-unpublished', unpublished.length === 0, unpublished.map(placementDetail));
120
+ const duplicates = placements.filter((p)=>p.published && p.duplicateRows);
121
+ add('duplicate-version-row', duplicates.length === 0, duplicates.map(placementDetail));
122
+ for (const p of placements.filter((p)=>p.published))for (const slot of p.identicalSlots ?? []){
123
+ if (e.arms.some((a)=>a.slot === slot && a.kind !== 'holdout')) checks.push({
124
+ code: 'version-identical',
125
+ ok: true,
126
+ warning: true,
127
+ detail: {
128
+ arm: slot,
129
+ collection: p.collection,
130
+ id: p.id,
131
+ title: p.title
132
+ }
133
+ });
134
+ }
135
+ const empty = placements.filter((placement)=>placement.published && e.arms.some((arm)=>arm.kind !== 'holdout' && (!arm.slot || !(placement.slots[arm.slot] > 0))));
136
+ add('empty-slot', e.kind !== 'block' || empty.length === 0, empty.map((placement)=>({
137
+ ...placementDetail(placement),
138
+ slots: placement.slots
139
+ })));
78
140
  add('make-content-unsupported', true);
79
141
  const localized = placements.find((placement)=>placement.localized);
80
142
  if (localized) Object.assign(checks[checks.length - 1], {
@@ -87,10 +149,40 @@ function invalidParams(arms, point) {
87
149
  available: free,
88
150
  requested: e.allocation
89
151
  });
90
- const unknown = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && !input.metricSources?.some((source)=>source.id === metric.source));
91
- add('unknown-metric-source', unknown.length === 0, unknown);
92
- const mismatches = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && e.unitType === 'user' && input.metricSources?.some((source)=>source.id === metric.source && source.keyedBy === 'visitor'));
93
- add('unit-source-mismatch', mismatches.length === 0, mismatches);
152
+ for (const detail of input.goalContent?.unchecked ?? [])checks.push({
153
+ code: 'placement-unchecked',
154
+ ok: true,
155
+ warning: true,
156
+ detail
157
+ });
158
+ const protectedGoals = [
159
+ relationshipId(e.metrics?.primary),
160
+ ...e.metrics?.guardrails?.map(relationshipId) ?? []
161
+ ].map(String);
162
+ const asymmetric = input.metrics.filter((metric)=>metric.source.startsWith('click:') && input.goalContent && asymmetricGoal(input.goalContent, metric.source, e.arms));
163
+ add('goal-in-some-versions', !asymmetric.some((metric)=>protectedGoals.includes(String(metric.id))), asymmetric.filter((metric)=>protectedGoals.includes(String(metric.id))).map((metric)=>({
164
+ metricId: metric.id,
165
+ name: metric.name
166
+ })));
167
+ for (const metric of asymmetric.filter((metric)=>!protectedGoals.includes(String(metric.id))))checks.push({
168
+ code: 'goal-in-some-versions',
169
+ ok: true,
170
+ warning: true,
171
+ detail: {
172
+ metricId: metric.id,
173
+ name: metric.name
174
+ }
175
+ });
176
+ const unknown = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && !metric.source.startsWith('click:') && !input.metricSources?.some((source)=>source.id === metric.source));
177
+ add('unknown-metric-source', unknown.length === 0, unknown.map(({ id, source })=>({
178
+ id,
179
+ source
180
+ })));
181
+ 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'));
182
+ add('unit-source-mismatch', mismatches.length === 0, mismatches.map(({ id, source })=>({
183
+ id,
184
+ source
185
+ })));
94
186
  const pathRules = (e.targeting ?? []).flatMap((rule, index)=>rule.attribute === 'path' ? [
95
187
  index
96
188
  ] : []);
@@ -1,13 +1,20 @@
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
- import { relationshipId } from './checkStart.js';
7
+ import { lockPagePaths } from '../db/locks.js';
8
+ import { routedPageTests } from '../db/pageOccupancy.js';
9
+ import { pageTargetPaths, relationshipId } from './checkStart.js';
5
10
  import { expectedConfirm, matchesConfirm, storedWinnerLabel } from './confirmPhrases.js';
6
11
  import { withTransition } from './kernel.js';
7
12
  import { readPhaseRecord, setPhaseEndedAt, writePhaseRecord } from './phaseRecord.js';
8
13
  import { readExperiment, refuse, updateExperiment } from './start.js';
9
14
  export async function decide(req, { experimentId, winnerArm, note, confirmation }) {
10
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)) : [];
11
18
  const { result: doc, revision } = await withTransition(req, {
12
19
  experimentId,
13
20
  layerId: relationshipId(before.layer),
@@ -45,6 +52,38 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
45
52
  }
46
53
  const test = await readPhaseRecord(req, experimentId, current.phase);
47
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
+ await refreshPlacementDocs(exec, experimentId, test.phase, rescan.docs);
61
+ }
62
+ if (current.kind === 'page' && current.status === 'stopped') {
63
+ // Deciding a stopped page test restores its routing: recheck page occupancy under the
64
+ // sorted path locks Start takes, so another test that started on these pages meanwhile wins.
65
+ // The frozen test-round arms are what decide restores; the stopped document's arms may have been edited.
66
+ const mine = pageTargetPaths(test.arms);
67
+ await lockPagePaths(exec, mine);
68
+ // Other tests' routed (frozen-round) arms, as the runtime matches them.
69
+ const occupied = new Set((await routedPageTests(exec)).filter((other)=>String(other.id) !== String(experimentId)).flatMap((other)=>pageTargetPaths(other.arms)));
70
+ const shared = mine.filter((path)=>occupied.has(path));
71
+ if (shared.length) refuse('page-in-use', {
72
+ paths: shared
73
+ });
74
+ const conflicts = await placementInteractions(req, exec, options, {
75
+ ...current,
76
+ arms: test.arms
77
+ }, mine);
78
+ if (conflicts.length) refuse('interacting-tests', conflicts);
79
+ if (options?.pages) {
80
+ const unpublished = await detached(req, ()=>unpublishedPageTargets(req, options, {
81
+ ...current,
82
+ arms: test.arms.filter((arm)=>arm.kind === 'control' || arm.key === winnerArm)
83
+ }, true));
84
+ if (unpublished.length) refuse('page-target-unpublished', unpublished[0]);
85
+ }
86
+ }
48
87
  const winner = test.arms.find((arm)=>arm.key === winnerArm && arm.kind !== 'holdout');
49
88
  if (!winner) refuse('invalid-winner');
50
89
  // §7.8: decision snapshot lookup serves the authorised transition.
@@ -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
+ }>;