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