@delmaredigital/payload-experiments 0.3.0-rc.4 → 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 (265) hide show
  1. package/README.md +113 -20
  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/actions/ConfirmDialog.d.ts +2 -1
  8. package/dist/admin/actions/ConfirmDialog.js +2 -2
  9. package/dist/admin/actions/DecideDialog.d.ts +2 -1
  10. package/dist/admin/actions/DecideDialog.js +2 -2
  11. package/dist/admin/api.d.ts +11 -2
  12. package/dist/admin/api.js +20 -2
  13. package/dist/admin/blocks/BlockPanel.d.ts +4 -0
  14. package/dist/admin/blocks/BlockPanel.js +153 -0
  15. package/dist/admin/blocks/TestBlockField.d.ts +5 -0
  16. package/dist/admin/blocks/TestBlockField.js +163 -0
  17. package/dist/admin/blocks/formApi.d.ts +43 -0
  18. package/dist/admin/blocks/formApi.js +153 -0
  19. package/dist/admin/blocks/inject.d.ts +7 -0
  20. package/dist/admin/blocks/inject.js +41 -0
  21. package/dist/admin/blocks/model.d.ts +17 -0
  22. package/dist/admin/blocks/model.js +45 -0
  23. package/dist/admin/blocks/statusCache.d.ts +7 -0
  24. package/dist/admin/blocks/statusCache.js +83 -0
  25. package/dist/admin/blocks/useBlockForm.d.ts +17 -0
  26. package/dist/admin/blocks/useBlockForm.js +46 -0
  27. package/dist/admin/copy/actions.d.ts +2 -0
  28. package/dist/admin/copy/actions.js +9 -0
  29. package/dist/admin/copy/blocks.d.ts +27 -0
  30. package/dist/admin/copy/blocks.js +38 -0
  31. package/dist/admin/copy/goals.d.ts +2 -0
  32. package/dist/admin/copy/goals.js +4 -1
  33. package/dist/admin/copy/newTest.d.ts +10 -0
  34. package/dist/admin/copy/newTest.js +10 -0
  35. package/dist/admin/copy/pages.d.ts +26 -0
  36. package/dist/admin/copy/pages.js +26 -0
  37. package/dist/admin/copy/readiness.d.ts +22 -3
  38. package/dist/admin/copy/readiness.js +23 -3
  39. package/dist/admin/copy/results.d.ts +1 -0
  40. package/dist/admin/copy/results.js +1 -0
  41. package/dist/admin/copy/testPage.d.ts +4 -0
  42. package/dist/admin/copy/testPage.js +4 -0
  43. package/dist/admin/goals/XpGoalField.d.ts +2 -0
  44. package/dist/admin/goals/XpGoalField.js +152 -0
  45. package/dist/admin/metricSource.model.d.ts +2 -0
  46. package/dist/admin/metricSource.model.js +4 -0
  47. package/dist/admin/pageCollections.d.ts +10 -0
  48. package/dist/admin/pageCollections.js +20 -0
  49. package/dist/admin/paths.d.ts +6 -0
  50. package/dist/admin/paths.js +6 -0
  51. package/dist/admin/placementStatus.model.d.ts +2 -0
  52. package/dist/admin/placementStatus.model.js +19 -0
  53. package/dist/admin/placements/placementLabels.d.ts +11 -0
  54. package/dist/admin/placements/placementLabels.js +21 -0
  55. package/dist/admin/placements/placementLabels.model.d.ts +13 -0
  56. package/dist/admin/placements/placementLabels.model.js +12 -0
  57. package/dist/admin/placements/usePlacementLabels.d.ts +12 -0
  58. package/dist/admin/placements/usePlacementLabels.js +102 -0
  59. package/dist/admin/results/DataQualityPanel.js +4 -1
  60. package/dist/admin/results/results.model.d.ts +3 -0
  61. package/dist/admin/results/results.model.js +8 -4
  62. package/dist/admin/surface/TestsHome.js +37 -6
  63. package/dist/admin/surface/TestsSurface.js +5 -1
  64. package/dist/admin/surface/contracts.d.ts +6 -5
  65. package/dist/admin/surface/goals/GoalForm.js +67 -34
  66. package/dist/admin/surface/goals/GoalsList.js +3 -3
  67. package/dist/admin/surface/goals/data.js +4 -1
  68. package/dist/admin/surface/goals/goals.model.d.ts +1 -2
  69. package/dist/admin/surface/goals/goals.model.js +1 -3
  70. package/dist/admin/surface/home.model.d.ts +1 -1
  71. package/dist/admin/surface/home.model.js +8 -4
  72. package/dist/admin/surface/newTest/NewTest.js +54 -13
  73. package/dist/admin/surface/newTest/PageWizard.d.ts +7 -0
  74. package/dist/admin/surface/newTest/PageWizard.js +209 -0
  75. package/dist/admin/surface/pagePicker/CopyRoute.d.ts +6 -0
  76. package/dist/admin/surface/pagePicker/CopyRoute.js +53 -0
  77. package/dist/admin/surface/pagePicker/PagePicker.d.ts +10 -0
  78. package/dist/admin/surface/pagePicker/PagePicker.js +172 -0
  79. package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +25 -0
  80. package/dist/admin/surface/pagePicker/pagePicker.model.js +25 -0
  81. package/dist/admin/surface/readiness/ActionDialogs.js +99 -64
  82. package/dist/admin/surface/readiness/Banner.js +30 -6
  83. package/dist/admin/surface/readiness/readiness.model.d.ts +8 -1
  84. package/dist/admin/surface/readiness/readiness.model.js +91 -9
  85. package/dist/admin/surface/results/ReleasePages.d.ts +3 -1
  86. package/dist/admin/surface/results/ReleasePages.js +3 -7
  87. package/dist/admin/surface/results/ResultsBody.js +21 -2
  88. package/dist/admin/surface/results/ResultsHeader.js +2 -5
  89. package/dist/admin/surface/results/useResults.d.ts +10 -1
  90. package/dist/admin/surface/test/AdvancedSection.js +111 -104
  91. package/dist/admin/surface/test/GoalSection.js +90 -7
  92. package/dist/admin/surface/test/LengthSection.js +1 -1
  93. package/dist/admin/surface/test/PageHoldout.d.ts +3 -0
  94. package/dist/admin/surface/test/PageHoldout.js +67 -0
  95. package/dist/admin/surface/test/TestPage.js +2 -1
  96. package/dist/admin/surface/test/VersionsSection.js +12 -5
  97. package/dist/admin/surface/test/fields.d.ts +13 -4
  98. package/dist/admin/surface/test/fields.js +46 -14
  99. package/dist/admin/surface/test/jumpToSection.d.ts +1 -1
  100. package/dist/admin/surface/test/jumpToSection.js +4 -3
  101. package/dist/admin/surface/test/pageParts.d.ts +4 -1
  102. package/dist/admin/surface/test/pageParts.js +79 -2
  103. package/dist/admin/surface/test/testPage.css +8 -1
  104. package/dist/admin/surface/testDocument.model.d.ts +1 -0
  105. package/dist/admin/surface/testDocument.model.js +2 -1
  106. package/dist/admin/surface/useTestDocument.d.ts +11 -2
  107. package/dist/admin/surface/useTestDocument.js +53 -15
  108. package/dist/admin/useApi.d.ts +11 -2
  109. package/dist/admin/useApi.js +3 -5
  110. package/dist/admin/views/ExperimentsRootView.js +58 -16
  111. package/dist/blocks/bindings.d.ts +3 -0
  112. package/dist/blocks/bindings.js +34 -0
  113. package/dist/blocks/createExperimentBlock.d.ts +6 -0
  114. package/dist/blocks/createExperimentBlock.js +77 -0
  115. package/dist/blocks/formState.d.ts +31 -0
  116. package/dist/blocks/formState.js +146 -0
  117. package/dist/blocks/model.d.ts +41 -0
  118. package/dist/blocks/model.js +34 -0
  119. package/dist/client/ExperimentGroup.d.ts +6 -0
  120. package/dist/client/ExperimentGroup.js +9 -0
  121. package/dist/client/ExperimentsProvider.js +20 -4
  122. package/dist/client/clickGoals.d.ts +4 -0
  123. package/dist/client/clickGoals.js +43 -0
  124. package/dist/collections/experiments/config.js +12 -2
  125. package/dist/collections/experiments/deleteGuard.d.ts +2 -1
  126. package/dist/collections/experiments/deleteGuard.js +12 -1
  127. package/dist/collections/experiments/editability.js +4 -2
  128. package/dist/collections/experiments/pickSlug.d.ts +2 -0
  129. package/dist/collections/experiments/pickSlug.js +8 -0
  130. package/dist/collections/labels.d.ts +3 -0
  131. package/dist/collections/labels.js +20 -0
  132. package/dist/collections/metrics.js +12 -2
  133. package/dist/collections/phases.js +1 -0
  134. package/dist/collections/serialize.js +7 -0
  135. package/dist/db/clickGoals.d.ts +9 -0
  136. package/dist/db/clickGoals.js +22 -0
  137. package/dist/db/delete.d.ts +1 -0
  138. package/dist/db/delete.js +1 -0
  139. package/dist/db/locks.d.ts +8 -0
  140. package/dist/db/locks.js +27 -4
  141. package/dist/db/names.d.ts +4 -0
  142. package/dist/db/names.js +4 -0
  143. package/dist/db/pageBindings.d.ts +4 -0
  144. package/dist/db/pageBindings.js +17 -0
  145. package/dist/db/pageBusy.d.ts +2 -0
  146. package/dist/db/pageBusy.js +10 -0
  147. package/dist/db/pageContent.d.ts +10 -0
  148. package/dist/db/pageContent.js +79 -0
  149. package/dist/db/placements.d.ts +33 -0
  150. package/dist/db/placements.js +54 -0
  151. package/dist/db/slugs.d.ts +8 -0
  152. package/dist/db/slugs.js +40 -0
  153. package/dist/db/snapshots.d.ts +6 -0
  154. package/dist/db/snapshots.js +8 -5
  155. package/dist/endpoints/clickKeys.d.ts +5 -0
  156. package/dist/endpoints/clickKeys.js +17 -0
  157. package/dist/endpoints/events.js +4 -1
  158. package/dist/endpoints/index.js +28 -0
  159. package/dist/endpoints/pages.d.ts +5 -0
  160. package/dist/endpoints/pages.js +120 -0
  161. package/dist/endpoints/placementStatus.d.ts +6 -0
  162. package/dist/endpoints/placementStatus.js +238 -0
  163. package/dist/endpoints/readBody.d.ts +2 -0
  164. package/dist/endpoints/readBody.js +24 -0
  165. package/dist/endpoints/results.js +5 -1
  166. package/dist/endpoints/summary.js +11 -4
  167. package/dist/endpoints/wrapBlock.d.ts +6 -0
  168. package/dist/endpoints/wrapBlock.js +76 -0
  169. package/dist/errors.d.ts +3 -3
  170. package/dist/errors.js +4 -4
  171. package/dist/events/rows.d.ts +1 -1
  172. package/dist/events/rows.js +5 -3
  173. package/dist/exports/adminClient.d.ts +5 -0
  174. package/dist/exports/adminClient.js +5 -0
  175. package/dist/exports/blocks.d.ts +2 -0
  176. package/dist/exports/blocks.js +1 -0
  177. package/dist/exports/client.d.ts +1 -0
  178. package/dist/exports/client.js +1 -0
  179. package/dist/exports/index.d.ts +6 -1
  180. package/dist/exports/index.js +3 -0
  181. package/dist/exports/puck.d.ts +3 -1
  182. package/dist/exports/puck.js +2 -0
  183. package/dist/exports/puckEditor.d.ts +3 -0
  184. package/dist/exports/puckEditor.js +2 -0
  185. package/dist/exports/rsc.d.ts +3 -0
  186. package/dist/exports/rsc.js +2 -0
  187. package/dist/fields/xpGoal.d.ts +3 -0
  188. package/dist/fields/xpGoal.js +28 -0
  189. package/dist/globals/state.js +10 -0
  190. package/dist/goals/cache.d.ts +3 -0
  191. package/dist/goals/cache.js +35 -0
  192. package/dist/goals/fairness.d.ts +40 -0
  193. package/dist/goals/fairness.js +200 -0
  194. package/dist/goals/key.d.ts +4 -0
  195. package/dist/goals/key.js +3 -0
  196. package/dist/goals/picker/clickGoalPicker.model.d.ts +29 -0
  197. package/dist/goals/picker/clickGoalPicker.model.js +45 -0
  198. package/dist/goals/picker/copy.d.ts +16 -0
  199. package/dist/goals/picker/copy.js +15 -0
  200. package/dist/goals/picker/useClickGoalPicker.d.ts +30 -0
  201. package/dist/goals/picker/useClickGoalPicker.js +220 -0
  202. package/dist/hooks/pageGuard.d.ts +5 -1
  203. package/dist/hooks/pageGuard.js +157 -95
  204. package/dist/onInit.js +29 -0
  205. package/dist/options.d.ts +2 -0
  206. package/dist/options.js +41 -1
  207. package/dist/plugin.js +41 -28
  208. package/dist/puck/copy.d.ts +1 -0
  209. package/dist/puck/copy.js +1 -0
  210. package/dist/puck/editor/editor.chrome.css +1 -0
  211. package/dist/puck/editor/editor.css +1 -0
  212. package/dist/puck/editor/editor.model.d.ts +4 -2
  213. package/dist/puck/editor/editor.model.js +6 -3
  214. package/dist/puck/editor/integration.d.ts +10 -0
  215. package/dist/puck/editor/integration.js +30 -0
  216. package/dist/puck/editor/xpGoalPuckEditorField.d.ts +7 -0
  217. package/dist/puck/editor/xpGoalPuckEditorField.js +134 -0
  218. package/dist/puck/integration.d.ts +6 -0
  219. package/dist/puck/integration.js +21 -0
  220. package/dist/puck/xpGoalField.d.ts +3 -0
  221. package/dist/puck/xpGoalField.js +6 -0
  222. package/dist/results/health.d.ts +4 -0
  223. package/dist/results/health.js +27 -0
  224. package/dist/results/snapshot.js +15 -2
  225. package/dist/sanitizeReadinessDetail.js +41 -0
  226. package/dist/server/pageKindSeoContract.d.ts +27 -0
  227. package/dist/server/pageKindSeoContract.js +80 -0
  228. package/dist/server/recordContentChange.js +58 -8
  229. package/dist/server/resolveExperimentBlocks.d.ts +5 -0
  230. package/dist/server/resolveExperimentBlocks.js +54 -0
  231. package/dist/server/runResults.js +56 -15
  232. package/dist/server/toClientAssignments.js +2 -1
  233. package/dist/transitions/addMetric.d.ts +2 -1
  234. package/dist/transitions/addMetric.js +28 -1
  235. package/dist/transitions/checkStart.d.ts +13 -1
  236. package/dist/transitions/checkStart.js +89 -7
  237. package/dist/transitions/decide.js +24 -0
  238. package/dist/transitions/deleteExperiment.d.ts +3 -1
  239. package/dist/transitions/deleteExperiment.js +27 -1
  240. package/dist/transitions/kernel.js +4 -0
  241. package/dist/transitions/makeContent.d.ts +5 -0
  242. package/dist/transitions/makeContent.js +39 -11
  243. package/dist/transitions/pageCollection.d.ts +10 -0
  244. package/dist/transitions/pageCollection.js +20 -0
  245. package/dist/transitions/pageVersions.d.ts +10 -0
  246. package/dist/transitions/pageVersions.js +44 -0
  247. package/dist/transitions/phaseRecord.d.ts +1 -0
  248. package/dist/transitions/placementInteractions.d.ts +40 -0
  249. package/dist/transitions/placementInteractions.js +94 -0
  250. package/dist/transitions/placements.d.ts +8 -0
  251. package/dist/transitions/placements.js +148 -1
  252. package/dist/transitions/preflightData.d.ts +2 -2
  253. package/dist/transitions/preflightData.js +137 -5
  254. package/dist/transitions/resolveCaps.d.ts +1 -0
  255. package/dist/transitions/resolveCaps.js +6 -2
  256. package/dist/transitions/start.js +45 -1
  257. package/dist/types/admin.d.ts +1 -1
  258. package/dist/types/assignments.d.ts +1 -0
  259. package/dist/types/contracts.d.ts +15 -0
  260. package/dist/types/endpoints.d.ts +92 -5
  261. package/dist/types/index.d.ts +3 -0
  262. package/dist/types/puck.d.ts +7 -1
  263. package/dist/types/snapshot.d.ts +17 -4
  264. package/dist/types/surface.d.ts +13 -0
  265. package/package.json +8 -1
@@ -1,8 +1,13 @@
1
+ import { EXPERIMENT_BLOCK_SLUG, hostBlocks } from '../blocks/model.js';
1
2
  import { safeError } from '../log.js';
2
3
  import { isDeepStrictEqual } from 'node:util';
3
4
  import { appendContentChange, contentChangeSavepoint, previousPublishedContent, runningContentExperiment, storedContent } from '../db/appends.js';
4
5
  import { txExecutor } from '../db/executor.js';
5
- import { puckCollections } from '../options.js';
6
+ import { pageContentBindings } from '../db/pageBindings.js';
7
+ import { sortLockKeys } from '../db/locks.js';
8
+ import { normalisePath } from '../runtime/normalisePath.js';
9
+ import { comparableContent } from '../transitions/pageVersions.js';
10
+ import { documentEditor, puckCollections } from '../options.js';
6
11
  import { withTransition } from '../transitions/kernel.js';
7
12
  import { normaliseContentRelationships } from './normaliseContentRelationships.js';
8
13
  const slots = [
@@ -98,8 +103,32 @@ async function auditContentChange({ doc, previousDoc, req, collection }) {
98
103
  const options = req.payload.config.custom?.xp;
99
104
  const registration = puckCollections(options ?? {}).find((entry)=>entry.slug === collection.slug);
100
105
  const changes = (previous, current)=>{
101
- const after = bindings(registration && object(current) ? current[registration.field] : current, Boolean(registration));
102
- const before = bindings(registration && object(previous) ? previous[registration.field] : previous, Boolean(registration));
106
+ const after = bindings(registration && object(current) ? current[registration.field] : current, Boolean(registration) && documentEditor(options ?? {}, collection.slug, object(current) ? current : {}) === 'puck');
107
+ const before = bindings(registration && object(previous) ? previous[registration.field] : previous, Boolean(registration) && documentEditor(options ?? {}, collection.slug, object(previous) ? previous : {}) === 'puck');
108
+ const blockRegistration = options?.blocks?.collections.find((entry)=>entry.slug === collection.slug);
109
+ for (const [row, found] of [
110
+ [
111
+ previous,
112
+ before
113
+ ],
114
+ [
115
+ current,
116
+ after
117
+ ]
118
+ ]){
119
+ if (!blockRegistration || !object(row) || documentEditor(options ?? {}, collection.slug, row) !== 'blocks') continue;
120
+ for (const block of hostBlocks(row[blockRegistration.field])){
121
+ if (block.blockType !== (blockRegistration.blockSlug ?? EXPERIMENT_BLOCK_SLUG) || typeof block.experiment !== 'string') continue;
122
+ const key = 'slug:' + block.experiment;
123
+ const binding = found.get(key) ?? {
124
+ field: 'slug',
125
+ value: block.experiment,
126
+ contents: []
127
+ };
128
+ binding.contents.push(block.slots);
129
+ found.set(key, binding);
130
+ }
131
+ }
103
132
  return [
104
133
  ...new Set([
105
134
  ...before.keys(),
@@ -117,20 +146,41 @@ async function auditContentChange({ doc, previousDoc, req, collection }) {
117
146
  const locales = req.payload.config.localization ? req.payload.config.localization.localeCodes : [];
118
147
  const normalise = (value)=>normaliseContentRelationships(value, collection.fields, req.payload.config.blocks, locales);
119
148
  let changed;
149
+ let beforeRow, afterRow;
120
150
  if (baseline) {
121
151
  const current = await storedContent(req, collection.slug, doc.id);
122
152
  if (!current) throw new Error('The saved content row was not found.');
123
- changed = changes(baseline.doc, current);
124
- } else changed = changes(normalise(previousDoc), normalise(doc));
153
+ beforeRow = baseline.doc;
154
+ afterRow = current;
155
+ changed = changes(beforeRow, afterRow);
156
+ } else {
157
+ beforeRow = normalise(previousDoc);
158
+ afterRow = normalise(doc);
159
+ changed = changes(beforeRow, afterRow);
160
+ }
125
161
  const experiments = new Map();
126
162
  for (const binding of changed){
127
163
  const experiment = await runningContentExperiment(executor, binding);
128
164
  if (experiment) experiments.set(String(experiment.id), experiment.id);
129
165
  }
166
+ const page = options?.pages?.collections.find((entry)=>entry.slug === collection.slug);
167
+ if (page?.contentOf && !isDeepStrictEqual(comparableContent(object(beforeRow) ? page.contentOf(beforeRow) : undefined), comparableContent(object(afterRow) ? page.contentOf(afterRow) : undefined))) {
168
+ const paths = [
169
+ beforeRow,
170
+ afterRow
171
+ ].flatMap((row)=>{
172
+ const path = object(row) ? page.pathOf(row) : null;
173
+ return path === null ? [] : [
174
+ normalisePath(path)
175
+ ];
176
+ });
177
+ for (const id of (await pageContentBindings(executor, collection.slug, paths)))experiments.set(String(id), id);
178
+ }
130
179
  // Successful audit locks persist until commit; acquire multiple E locks in order.
131
- for (const [, experimentId] of [
132
- ...experiments
133
- ].sort(([a], [b])=>a.localeCompare(b))){
180
+ for (const key of sortLockKeys([
181
+ ...experiments.keys()
182
+ ])){
183
+ const experimentId = experiments.get(key);
134
184
  await withTransition(req, {
135
185
  experimentId,
136
186
  bump: false,
@@ -0,0 +1,5 @@
1
+ import type { Assignments } from '../types/assignments.js';
2
+ import { type ResolvedExperimentBlocks } from '../blocks/model.js';
3
+ export declare function resolveExperimentBlocks(blocks: unknown, assignments: Assignments, { blockSlug }?: {
4
+ blockSlug?: string;
5
+ }): ResolvedExperimentBlocks;
@@ -0,0 +1,54 @@
1
+ import { BLOCK_SLOTS, EXPERIMENT_BLOCK_SLUG, hostBlocks, slotBlocks } from '../blocks/model.js';
2
+ import { manifestOf, suppressExposure } from './context.js';
3
+ export function resolveExperimentBlocks(blocks, assignments, { blockSlug = EXPERIMENT_BLOCK_SLUG } = {}) {
4
+ const result = {
5
+ blocks: [],
6
+ rendered: [],
7
+ groups: []
8
+ };
9
+ const exposed = new Set();
10
+ for (const block of hostBlocks(blocks)){
11
+ if (block.blockType !== blockSlug) {
12
+ const clean = {
13
+ ...block
14
+ };
15
+ delete clean.xpGroup;
16
+ result.blocks.push(clean);
17
+ continue;
18
+ }
19
+ const slug = typeof block.experiment === 'string' ? block.experiment : '';
20
+ const assignment = Object.hasOwn(assignments.bySlug, slug) ? assignments.bySlug[slug] : undefined;
21
+ const experiment = manifestOf(assignments)?.experiments.find((e)=>e.slug === slug);
22
+ const arm = experiment?.arms.find((a)=>a.key === assignment?.arm);
23
+ const archived = Object.hasOwn(assignments.archivedWinners, slug) ? assignments.archivedWinners[slug] : undefined;
24
+ const slot = assignment ? assignment.arm === 'holdout' ? 'control' : arm?.slot ?? assignment.arm : archived ?? 'control';
25
+ const selected = BLOCK_SLOTS.includes(slot) ? slotBlocks(block, slot) : [];
26
+ const content = selected.length ? selected : slotBlocks(block, 'control');
27
+ const group = {
28
+ slug
29
+ };
30
+ if (selected.length && assignment?.exposable && !suppressExposure(assignments)) {
31
+ const exposure = {
32
+ slug,
33
+ arm: assignment.arm,
34
+ phase: assignment.phase
35
+ };
36
+ if (assignment.policy === 'render' && !exposed.has(slug)) {
37
+ result.rendered.push(exposure);
38
+ exposed.add(slug);
39
+ }
40
+ if (assignment.policy === 'viewport') group.expose = exposure;
41
+ }
42
+ const start = result.blocks.length;
43
+ result.blocks.push(...content.map((row)=>({
44
+ ...row,
45
+ xpGroup: group
46
+ })));
47
+ if (content.length) result.groups.push({
48
+ ...group,
49
+ start,
50
+ end: result.blocks.length
51
+ });
52
+ }
53
+ return result;
54
+ }
@@ -1,4 +1,6 @@
1
+ import { asymmetricGoal, goalContent, goalPlacementDetail, LEGACY_FAIRNESS } from '../goals/fairness.js';
1
2
  import { XpAnalysisError } from '../errors.js';
3
+ import { canonicalHealth, mergePageHealth } from '../results/health.js';
2
4
  import { safeError } from '../log.js';
3
5
  import { createLocalReq } from 'payload';
4
6
  import { resultsCandidates, resultsExecutor, writeSnapshots } from '../db/snapshots.js';
@@ -120,9 +122,9 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
120
122
  ].reduce((earliest, unit)=>Math.min(earliest, +unit.firstTs), timestampMs(phase.startedAt)));
121
123
  const preFrom = new Date(+from - 14 * 86_400_000);
122
124
  let events, covariates;
123
- if (definition.source.startsWith('events:')) {
124
- const eventName = definition.source.slice(7);
125
- if (!options.eventSources?.some((source)=>source.name === eventName)) throw new XpAnalysisError('source-unavailable');
125
+ if (definition.source.startsWith('events:') || definition.source.startsWith('click:')) {
126
+ const eventName = definition.source.startsWith('click:') ? definition.source : definition.source.slice(7);
127
+ if (!definition.source.startsWith('click:') && !options.eventSources?.some((source)=>source.name === eventName)) throw new XpAnalysisError('source-unavailable');
126
128
  const read = async (start)=>{
127
129
  if (candidate.unitType === 'user') return metricEventRows(exec, {
128
130
  eventName,
@@ -221,9 +223,12 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
221
223
  }
222
224
  }
223
225
  if (events.some((event)=>!Number.isFinite(event.value))) throw new XpAnalysisError('non-finite-values');
226
+ // The click-goal delta supersedes whole-phase attribution for mid-test adds.
227
+ // Retain pre-period events for CUPED above; exclude them from the outcome.
228
+ const outcomeEvents = definition.source.startsWith('click:') && definition.addedAt ? events.filter((event)=>timestampMs(event.ts) >= timestampMs(definition.addedAt)) : events;
224
229
  metricData.push({
225
230
  metricId: definition.metricId,
226
- values: perUnitValues(units, events, {
231
+ values: perUnitValues(units, outcomeEvents, {
227
232
  kind,
228
233
  windowHours,
229
234
  endedAt: to
@@ -349,7 +354,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
349
354
  } : await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics, payload.logger));
350
355
  }
351
356
  if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
352
- const current = phases.find((candidate)=>candidate.kind === 'page' && candidate.phase.phase === candidate.currentPhase && (candidate.status === 'running' || candidate.status === 'decided'));
357
+ const current = phases.find((candidate)=>candidate.phase.phase === candidate.currentPhase && (candidate.status === 'running' || candidate.status === 'decided'));
353
358
  if (current) {
354
359
  const req = await raceDeadline(analysisDeadline, ()=>createLocalReq({}, payload));
355
360
  const health = [];
@@ -358,12 +363,12 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
358
363
  ...current.phase.arms.filter((arm)=>arm.kind !== 'holdout'),
359
364
  ...current.testArms.filter((arm)=>arm.kind === 'control')
360
365
  ] : current.testArms.filter((arm)=>arm.kind !== 'holdout' && (current.status !== 'decided' || arm.kind === 'control' || arm.key === current.winnerArm));
361
- const targets = new Set(arms.flatMap((arm)=>arm.pageTarget ? [
366
+ const targets = new Set((current.kind === 'page' ? arms : []).flatMap((arm)=>arm.pageTarget ? [
362
367
  arm.pageTarget
363
368
  ] : []));
364
369
  for (const path of targets){
365
370
  let published = false, found = false, unchecked = false;
366
- for (const collection of options.pages?.collections ?? []){
371
+ for (const collection of current.pageCollection ? options.pages?.collections.filter((entry)=>entry.slug === current.pageCollection) ?? [] : options.pages?.collections ?? []){
367
372
  try {
368
373
  const page = await raceDeadline(analysisDeadline, ()=>collection.findByPath(path, req));
369
374
  found ||= page != null;
@@ -382,16 +387,52 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
382
387
  path
383
388
  });
384
389
  }
385
- for (const snapshot of snapshots)snapshot.health = health;
390
+ const clicks = current.phase.metricDefinitions.filter((metric)=>metric.source.startsWith('click:'));
391
+ const content = clicks.length ? await raceDeadline(analysisDeadline, ()=>goalContent(req, options, {
392
+ id,
393
+ slug: current.slug ?? String(id),
394
+ kind: current.kind ?? 'code',
395
+ arms: arms,
396
+ pageCollection: current.pageCollection
397
+ }, current.testPlacementDocs ?? null)) : undefined;
398
+ const goals = clicks.filter((metric)=>content && asymmetricGoal(content, metric.source, arms)).map((metric)=>({
399
+ code: 'goal-in-some-versions',
400
+ metricId: metric.metricId,
401
+ name: metric.name
402
+ }));
403
+ const unchecked = (content?.unchecked ?? []).map((placement)=>({
404
+ code: 'placement-unchecked',
405
+ detail: goalPlacementDetail(placement)
406
+ }));
407
+ for (const [index, snapshot] of snapshots.entries()){
408
+ const previous = phases[index].previousHealth ?? phases[index].frozenSnapshot?.health ?? [];
409
+ const unjudged = previous.filter((row)=>row.code === 'goal-in-some-versions' && content && content.unchecked.length > 0 && clicks.some((metric)=>metric.metricId === row.metricId && !asymmetricGoal(content, metric.source, arms)));
410
+ snapshot.health = mergePageHealth(unjudged, [
411
+ ...health,
412
+ ...goals,
413
+ ...unchecked
414
+ ]);
415
+ if (content?.skipped && current.kind === 'block') snapshot.goalFairnessNotice = LEGACY_FAIRNESS;
416
+ else delete snapshot.goalFairnessNotice;
417
+ }
386
418
  }
387
419
  if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
388
- // Order and optional empty health are semantically identical. Frozen
389
- // inference gets a new row only when its page availability changes.
390
- const canonicalHealth = (snapshot)=>JSON.stringify((snapshot.health ?? []).map(({ code, path })=>[
391
- code,
392
- path
393
- ]).map((entry)=>JSON.stringify(entry)).sort((a, b)=>a < b ? -1 : a > b ? 1 : 0));
394
- const changed = snapshots.filter((snapshot, index)=>!phases[index].frozenSnapshot || canonicalHealth(snapshot) !== canonicalHealth(phases[index].frozenSnapshot));
420
+ // New read failures alone do not rewrite frozen inference. Recovery must
421
+ // clear an unchecked row stored alongside a real health change.
422
+ const changed = snapshots.filter((snapshot, index)=>{
423
+ const frozen = phases[index].frozenSnapshot;
424
+ if (!frozen || canonicalHealth(snapshot, true) !== canonicalHealth(frozen, true)) return true;
425
+ const freshUnchecked = new Set((snapshot.health ?? []).filter((row)=>row.code === 'placement-unchecked').map((row)=>canonicalHealth({
426
+ health: [
427
+ row
428
+ ]
429
+ })));
430
+ return (frozen.health ?? []).some((row)=>row.code === 'placement-unchecked' && !freshUnchecked.has(canonicalHealth({
431
+ health: [
432
+ row
433
+ ]
434
+ })));
435
+ });
395
436
  // Unchanged health is a successful refresh with nothing to write.
396
437
  if (!changed.length) {
397
438
  summary.ran++;
@@ -40,7 +40,8 @@ export function toClientAssignments(assignments, opts) {
40
40
  apiRoute: opts.apiRoute ?? '/api',
41
41
  experiments,
42
42
  flagPoints,
43
- pageviews: []
43
+ pageviews: [],
44
+ clickGoalsArmed: !blocked && Object.values(assignments.bySlug).some((a)=>a.exposable)
44
45
  };
45
46
  if (!blocked) for (const e of manifest?.experiments ?? []){
46
47
  const a = assignments.bySlug[e.slug];
@@ -1,6 +1,6 @@
1
1
  import type { PayloadRequest } from 'payload';
2
2
  import type { DocId, ResolvedOptions } from '../types/contracts.js';
3
- import type { AddMetricBody } from '../types/endpoints.js';
3
+ import type { AddMetricBody, AddMetricWarning } from '../types/endpoints.js';
4
4
  export declare function addMetric<TUser extends {
5
5
  id: DocId;
6
6
  }>(req: PayloadRequest, { experimentId, metric, role, options }: AddMetricBody & {
@@ -11,6 +11,7 @@ export declare function addMetric<TUser extends {
11
11
  metricId: DocId;
12
12
  message: string;
13
13
  }[] | undefined;
14
+ warnings: AddMetricWarning[];
14
15
  doc: import("payload").JsonObject & import("payload").TypeWithID;
15
16
  revision: number;
16
17
  }>;
@@ -1,3 +1,4 @@
1
+ import { asymmetricGoal, goalContent, goalPlacementDetail } from '../goals/fairness.js';
1
2
  import { lockMetricsForFreeze } from '../db/metricUsage.js';
2
3
  import { readState } from '../db/state.js';
3
4
  import { XpRefusal } from '../errors.js';
@@ -23,6 +24,31 @@ export async function addMetric(req, { experimentId, metric, role, options }) {
23
24
  });
24
25
  if (!found.docs[0]) throw new XpRefusal('not-found', 'Metric not found', undefined, 404);
25
26
  const metricId = found.docs[0].id;
27
+ const warnings = [];
28
+ if (String(found.docs[0].source).startsWith('click:')) {
29
+ const content = await goalContent(req, options, before);
30
+ if (asymmetricGoal(content, String(found.docs[0].source), before.arms)) {
31
+ const detail = {
32
+ metricId,
33
+ name: found.docs[0].name
34
+ };
35
+ if (role === 'guardrail') refuse('goal-in-some-versions', detail);
36
+ warnings.push({
37
+ code: 'goal-in-some-versions',
38
+ detail
39
+ });
40
+ }
41
+ warnings.push(...content.unchecked.map((placement)=>({
42
+ code: 'placement-unchecked',
43
+ detail: goalPlacementDetail(placement)
44
+ })));
45
+ warnings.push({
46
+ code: 'goal-added-mid-test',
47
+ detail: {
48
+ metricId
49
+ }
50
+ });
51
+ }
26
52
  const at = new Date();
27
53
  // §5.4: cap history ends at the phase start, never inside the running phase.
28
54
  const started = before.startedAt;
@@ -53,7 +79,7 @@ export async function addMetric(req, { experimentId, metric, role, options }) {
53
79
  if (lockedMetrics[0].slug !== definition.slug || lockedMetrics[0].source !== definition.source) {
54
80
  throw new XpRefusal('stale-draft', 'This goal changed. Review it again before adding it.', undefined, 409);
55
81
  }
56
- if (current.unitType === 'user' && definition.keyedBy === 'visitor' && !definition.source.startsWith('events:')) refuse('unit-source-mismatch');
82
+ if (current.unitType === 'user' && definition.keyedBy === 'visitor' && !definition.source.startsWith('events:') && !definition.source.startsWith('click:')) refuse('unit-source-mismatch');
57
83
  // §7.8: phase lookup serves the access-authorised transition write.
58
84
  const phases = await req.payload.find({
59
85
  collection: 'xp-phases',
@@ -127,6 +153,7 @@ export async function addMetric(req, { experimentId, metric, role, options }) {
127
153
  });
128
154
  return {
129
155
  ...result,
156
+ warnings,
130
157
  ...caps.detail.length ? {
131
158
  detail: caps.detail
132
159
  } : {}
@@ -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-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
+ 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,6 +52,15 @@ 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;
54
66
  pageTestsInUse?: Pick<StartExperiment, 'id' | 'arms'>[];
@@ -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,36 @@ 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
117
  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);
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
+ })));
88
140
  add('make-content-unsupported', true);
89
141
  const localized = placements.find((placement)=>placement.localized);
90
142
  if (localized) Object.assign(checks[checks.length - 1], {
@@ -97,10 +149,40 @@ function invalidParams(arms, point) {
97
149
  available: free,
98
150
  requested: e.allocation
99
151
  });
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);
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
+ })));
104
186
  const pathRules = (e.targeting ?? []).flatMap((rule, index)=>rule.attribute === 'path' ? [
105
187
  index
106
188
  ] : []);
@@ -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,13 @@ 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
+ await refreshPlacementDocs(exec, experimentId, test.phase, rescan.docs);
61
+ }
50
62
  if (current.kind === 'page' && current.status === 'stopped') {
51
63
  // Deciding a stopped page test restores its routing: recheck page occupancy under the
52
64
  // sorted path locks Start takes, so another test that started on these pages meanwhile wins.
@@ -59,6 +71,18 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
59
71
  if (shared.length) refuse('page-in-use', {
60
72
  paths: shared
61
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
+ }
62
86
  }
63
87
  const winner = test.arms.find((arm)=>arm.key === winnerArm && arm.kind !== 'holdout');
64
88
  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(),