@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.
- package/README.md +113 -20
- 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/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 +11 -2
- package/dist/admin/api.js +20 -2
- package/dist/admin/blocks/BlockPanel.d.ts +4 -0
- package/dist/admin/blocks/BlockPanel.js +153 -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 +43 -0
- package/dist/admin/blocks/formApi.js +153 -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 +7 -0
- package/dist/admin/blocks/statusCache.js +83 -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 +27 -0
- package/dist/admin/copy/blocks.js +38 -0
- 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 +26 -0
- package/dist/admin/copy/pages.js +26 -0
- package/dist/admin/copy/readiness.d.ts +22 -3
- package/dist/admin/copy/readiness.js +23 -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 +2 -0
- package/dist/admin/goals/XpGoalField.js +152 -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 +6 -0
- package/dist/admin/paths.js +6 -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 +37 -6
- package/dist/admin/surface/TestsSurface.js +5 -1
- package/dist/admin/surface/contracts.d.ts +6 -5
- package/dist/admin/surface/goals/GoalForm.js +67 -34
- package/dist/admin/surface/goals/GoalsList.js +3 -3
- 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 +1 -1
- package/dist/admin/surface/home.model.js +8 -4
- package/dist/admin/surface/newTest/NewTest.js +54 -13
- package/dist/admin/surface/newTest/PageWizard.d.ts +7 -0
- package/dist/admin/surface/newTest/PageWizard.js +209 -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/PagePicker.d.ts +10 -0
- package/dist/admin/surface/pagePicker/PagePicker.js +172 -0
- package/dist/admin/surface/pagePicker/pagePicker.model.d.ts +25 -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 +91 -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 +2 -5
- package/dist/admin/surface/results/useResults.d.ts +10 -1
- package/dist/admin/surface/test/AdvancedSection.js +111 -104
- package/dist/admin/surface/test/GoalSection.js +90 -7
- 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 +2 -1
- 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 +79 -2
- package/dist/admin/surface/test/testPage.css +8 -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 +11 -2
- package/dist/admin/surface/useTestDocument.js +53 -15
- package/dist/admin/useApi.d.ts +11 -2
- package/dist/admin/useApi.js +3 -5
- package/dist/admin/views/ExperimentsRootView.js +58 -16
- 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 +12 -2
- 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 +12 -2
- 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/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 +28 -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/hooks/pageGuard.d.ts +5 -1
- package/dist/hooks/pageGuard.js +157 -95
- package/dist/onInit.js +29 -0
- package/dist/options.d.ts +2 -0
- package/dist/options.js +41 -1
- package/dist/plugin.js +41 -28
- package/dist/puck/copy.d.ts +1 -0
- package/dist/puck/copy.js +1 -0
- package/dist/puck/editor/editor.chrome.css +1 -0
- package/dist/puck/editor/editor.css +1 -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 +134 -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 +41 -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 +13 -1
- package/dist/transitions/checkStart.js +89 -7
- package/dist/transitions/decide.js +24 -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 +45 -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/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 {
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
132
|
-
...experiments
|
|
133
|
-
]
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
389
|
-
//
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
87
|
-
add('
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(),
|