@delmaredigital/payload-experiments 0.3.0-rc.1 → 0.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -21
- 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 +47 -26
- package/dist/admin/TestsNavLink.d.ts +3 -1
- package/dist/admin/TestsNavLink.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/armSelect/ArmSelectField.js +14 -22
- package/dist/admin/arms/arms.model.d.ts +1 -21
- package/dist/admin/arms/arms.model.js +1 -153
- 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/arms.d.ts +1 -6
- package/dist/admin/copy/arms.js +1 -16
- 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 +25 -3
- package/dist/admin/copy/readiness.js +25 -3
- package/dist/admin/copy/results.d.ts +3 -0
- package/dist/admin/copy/results.js +4 -1
- package/dist/admin/copy/review.d.ts +1 -1
- package/dist/admin/copy/review.js +1 -1
- package/dist/admin/copy/surface.d.ts +0 -1
- package/dist/admin/copy/surface.js +0 -1
- 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 +8 -1
- package/dist/admin/paths.js +8 -1
- 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/previewLinks.model.d.ts +2 -0
- package/dist/admin/previewLinks.model.js +3 -0
- package/dist/admin/results/DataQualityPanel.js +4 -1
- package/dist/admin/results/legacy.d.ts +16 -0
- package/dist/admin/results/legacy.js +27 -0
- package/dist/admin/results/results.model.d.ts +9 -6
- package/dist/admin/results/results.model.js +22 -9
- package/dist/admin/results/snapshot.d.ts +14 -0
- package/dist/admin/results/snapshot.js +3 -0
- package/dist/admin/surface/TestsHome.js +43 -15
- 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 +19 -16
- 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 +2 -1
- package/dist/admin/surface/home.model.js +8 -4
- package/dist/admin/surface/newTest/NewTest.js +58 -17
- 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 +103 -69
- 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 +95 -6
- 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 +23 -3
- package/dist/admin/surface/results/ResultsHeader.js +2 -5
- package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
- package/dist/admin/surface/results/recommendation.model.js +10 -3
- package/dist/admin/surface/results/useResults.d.ts +10 -1
- package/dist/admin/surface/surface.css +0 -1
- package/dist/admin/surface/test/AdvancedSection.js +111 -104
- package/dist/admin/surface/test/GoalSection.js +97 -22
- 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/test/testPage.model.d.ts +2 -3
- package/dist/admin/surface/test/testPage.model.js +1 -21
- 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 +55 -16
- package/dist/admin/useApi.d.ts +11 -2
- package/dist/admin/useApi.js +3 -5
- package/dist/admin/useLayerUsage.js +14 -23
- package/dist/admin/views/ExperimentsRootView.js +58 -16
- package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
- package/dist/admin/views/TestsNavLinkServer.js +43 -0
- package/dist/arms/copy.d.ts +6 -0
- package/dist/arms/copy.js +16 -0
- package/dist/arms/model.d.ts +23 -0
- package/dist/arms/model.js +173 -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 +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/appends.d.ts +6 -2
- package/dist/db/appends.js +10 -4
- 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 +14 -0
- package/dist/db/locks.js +36 -1
- 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/pageOccupancy.d.ts +12 -0
- package/dist/db/pageOccupancy.js +45 -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 +9 -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 +1 -0
- package/dist/exports/adminRsc.js +1 -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 +5 -2
- package/dist/exports/puck.js +3 -1
- package/dist/exports/puckEditor.d.ts +6 -1
- package/dist/exports/puckEditor.js +4 -1
- package/dist/exports/rsc.d.ts +4 -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 +160 -80
- package/dist/lib/fetchAllDocs.d.ts +16 -0
- package/dist/lib/fetchAllDocs.js +56 -0
- package/dist/onInit.js +29 -0
- package/dist/options.d.ts +2 -0
- package/dist/options.js +43 -10
- package/dist/plugin.js +45 -28
- package/dist/puck/ExperimentPicker.js +47 -16
- package/dist/puck/ExperimentSlot.d.ts +51 -1
- package/dist/puck/ExperimentSlot.js +23 -12
- package/dist/puck/copy.d.ts +6 -4
- package/dist/puck/copy.js +6 -4
- package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
- package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
- package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
- package/dist/puck/editor/ResultsReadout.d.ts +6 -0
- package/dist/puck/editor/ResultsReadout.js +99 -0
- package/dist/puck/editor/VersionBuilder.d.ts +34 -0
- package/dist/puck/editor/VersionBuilder.js +317 -0
- package/dist/puck/editor/armWrites.d.ts +7 -0
- package/dist/puck/editor/armWrites.js +71 -0
- package/dist/puck/editor/armsCache.d.ts +14 -0
- package/dist/puck/editor/armsCache.js +78 -0
- package/dist/puck/editor/copy.d.ts +33 -1
- package/dist/puck/editor/copy.js +45 -8
- package/dist/puck/editor/createTest.d.ts +1 -1
- package/dist/puck/editor/createTest.js +25 -14
- package/dist/puck/editor/editor.chrome.css +48 -161
- package/dist/puck/editor/editor.css +48 -161
- package/dist/puck/editor/editor.model.d.ts +8 -3
- package/dist/puck/editor/editor.model.js +16 -10
- package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
- package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
- 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/experimentSlotField.d.ts +2 -0
- package/dist/puck/experimentSlotField.js +11 -3
- package/dist/puck/hostContext.d.ts +10 -0
- package/dist/puck/hostContext.js +12 -0
- package/dist/puck/integration.d.ts +6 -0
- package/dist/puck/integration.js +21 -0
- package/dist/puck/routes.d.ts +12 -0
- package/dist/puck/routes.js +11 -0
- package/dist/puck/slotConfig.d.ts +1 -1
- package/dist/puck/slotConfig.js +2 -2
- 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 +51 -1
- package/dist/server/metadata.js +9 -2
- package/dist/server/normaliseContentRelationships.d.ts +7 -0
- package/dist/server/normaliseContentRelationships.js +62 -0
- package/dist/server/pageKindSeoContract.d.ts +27 -0
- package/dist/server/pageKindSeoContract.js +80 -0
- package/dist/server/recordContentChange.d.ts +1 -1
- package/dist/server/recordContentChange.js +72 -13
- package/dist/server/resolveExperimentBlocks.d.ts +5 -0
- package/dist/server/resolveExperimentBlocks.js +54 -0
- package/dist/server/runResults.js +92 -19
- 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 +17 -1
- package/dist/transitions/checkStart.js +99 -7
- package/dist/transitions/decide.js +40 -1
- 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 +10 -6
- package/dist/transitions/placements.js +156 -2
- package/dist/transitions/preflightData.d.ts +8 -0
- package/dist/transitions/preflightData.js +248 -36
- package/dist/transitions/resolveCaps.d.ts +1 -0
- package/dist/transitions/resolveCaps.js +6 -2
- package/dist/transitions/start.js +77 -3
- package/dist/types/admin.d.ts +1 -1
- package/dist/types/assignments.d.ts +1 -0
- package/dist/types/contracts.d.ts +31 -10
- 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/Tabs.d.ts +4 -2
- package/dist/ui/Tabs.js +33 -26
- package/dist/ui/accent.d.ts +22 -3
- package/dist/ui/accent.js +74 -16
- package/package.json +12 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { contentBlocks } from '../transitions/contentFields.js';
|
|
2
|
+
const object = (value)=>value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
const id = (value)=>object(value) && 'id' in value ? value.id : value;
|
|
4
|
+
/** Undo depth expansion only where the collection schema declares relationships.
|
|
5
|
+
* Hidden fields and field-level afterRead hooks are unaffected by depth, so
|
|
6
|
+
* relationship expansion is the only depth-dependent transformation to undo.
|
|
7
|
+
* Other objects (especially JSON content with its own ids) must stay intact.
|
|
8
|
+
*/ export function normaliseContentRelationships(data, fields, blocks = [], locales = []) {
|
|
9
|
+
// With locale 'all', a localized field's value is { [locale]: value }: normalise each locale's value.
|
|
10
|
+
const localeMap = (value)=>locales.length > 0 && object(value) && Object.keys(value).length > 0 && Object.keys(value).every((key)=>locales.includes(key));
|
|
11
|
+
function lexical(value) {
|
|
12
|
+
if (!object(value)) return value;
|
|
13
|
+
const result = {
|
|
14
|
+
...value
|
|
15
|
+
};
|
|
16
|
+
if (value.type === 'upload' || value.type === 'relationship') result.value = id(value.value);
|
|
17
|
+
if (Array.isArray(value.children)) result.children = value.children.map(lexical);
|
|
18
|
+
if (object(value.root)) result.root = lexical(value.root);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function fieldValue(value, field) {
|
|
22
|
+
if (field.type === 'relationship' || field.type === 'upload') {
|
|
23
|
+
const one = (entry)=>Array.isArray(field.relationTo) && object(entry) ? {
|
|
24
|
+
...entry,
|
|
25
|
+
value: id(entry.value)
|
|
26
|
+
} : id(entry);
|
|
27
|
+
return field.hasMany && Array.isArray(value) ? value.map(one) : one(value);
|
|
28
|
+
}
|
|
29
|
+
if (field.type === 'richText') return lexical(value);
|
|
30
|
+
if (field.type === 'array' && Array.isArray(value)) return value.map((row)=>walk(row, field.fields));
|
|
31
|
+
if (field.type === 'blocks' && Array.isArray(value)) return value.map((row)=>{
|
|
32
|
+
const block = object(row) && contentBlocks(field, blocks).find((block)=>block.slug === row.blockType);
|
|
33
|
+
return block ? walk(row, block.fields) : row;
|
|
34
|
+
});
|
|
35
|
+
return 'fields' in field ? walk(value, field.fields) : value;
|
|
36
|
+
}
|
|
37
|
+
function walk(value, schema) {
|
|
38
|
+
if (!object(value)) return value;
|
|
39
|
+
let result = {
|
|
40
|
+
...value
|
|
41
|
+
};
|
|
42
|
+
for (const field of schema){
|
|
43
|
+
if (field.type === 'tabs') {
|
|
44
|
+
for (const tab of field.tabs){
|
|
45
|
+
if ('name' in tab && tab.name) {
|
|
46
|
+
if (tab.name in result) result[tab.name] = walk(result[tab.name], tab.fields);
|
|
47
|
+
} else result = walk(result, tab.fields);
|
|
48
|
+
}
|
|
49
|
+
} else if ('name' in field && field.name) {
|
|
50
|
+
if (field.name in result) {
|
|
51
|
+
const value = result[field.name];
|
|
52
|
+
result[field.name] = 'localized' in field && field.localized && localeMap(value) ? Object.fromEntries(Object.entries(value).map(([locale, entry])=>[
|
|
53
|
+
locale,
|
|
54
|
+
fieldValue(entry, field)
|
|
55
|
+
])) : fieldValue(value, field);
|
|
56
|
+
}
|
|
57
|
+
} else if ('fields' in field) result = walk(result, field.fields);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
return walk(data, fields);
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Manifest } from '../types/manifest.js';
|
|
2
|
+
export interface PageKindSeoContractOptions {
|
|
3
|
+
secret: string;
|
|
4
|
+
original: string;
|
|
5
|
+
versions: string[];
|
|
6
|
+
state: 'running' | 'decided' | 'archived';
|
|
7
|
+
winner?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PageKindSeoContractRow {
|
|
10
|
+
requestPath: string;
|
|
11
|
+
servedPath: string;
|
|
12
|
+
headers: {
|
|
13
|
+
'x-xp-request': string;
|
|
14
|
+
};
|
|
15
|
+
expect: {
|
|
16
|
+
robots?: {
|
|
17
|
+
index: false;
|
|
18
|
+
};
|
|
19
|
+
canonical?: string;
|
|
20
|
+
inSitemap: boolean;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Host-test fixture: use this manifest in the runtime that serves the signed row headers. */
|
|
24
|
+
export declare function pageKindSeoContract({ secret, original, versions, state, winner }: PageKindSeoContractOptions): {
|
|
25
|
+
manifest: Manifest;
|
|
26
|
+
rows: PageKindSeoContractRow[];
|
|
27
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { sign } from '../runtime/signing.js';
|
|
2
|
+
import { normalisePath } from '../runtime/normalisePath.js';
|
|
3
|
+
/** Host-test fixture: use this manifest in the runtime that serves the signed row headers. */ export function pageKindSeoContract({ secret, original, versions, state, winner }) {
|
|
4
|
+
const paths = [
|
|
5
|
+
original,
|
|
6
|
+
...versions
|
|
7
|
+
];
|
|
8
|
+
if (!secret || paths.some((path)=>!path.startsWith('/') || /[\\\r\n#|]/.test(path)) || new Set(paths.map(normalisePath)).size !== paths.length || !versions.length || versions.length > 3 || state !== 'running' && (!winner || !paths.includes(winner))) throw new Error('Invalid page SEO contract');
|
|
9
|
+
const manifest = {
|
|
10
|
+
v: 1,
|
|
11
|
+
revision: 1,
|
|
12
|
+
experiments: [],
|
|
13
|
+
flagDefaults: {},
|
|
14
|
+
archivedWinners: {},
|
|
15
|
+
archivedPages: {}
|
|
16
|
+
};
|
|
17
|
+
if (state === 'archived') {
|
|
18
|
+
for (const path of paths)if (path !== winner) manifest.archivedPages[path] = {
|
|
19
|
+
slug: 'page-seo-contract',
|
|
20
|
+
winnerPath: winner
|
|
21
|
+
};
|
|
22
|
+
} else {
|
|
23
|
+
manifest.experiments.push({
|
|
24
|
+
id: 'page-seo-contract',
|
|
25
|
+
slug: 'page-seo-contract',
|
|
26
|
+
kind: 'page',
|
|
27
|
+
unitType: 'visitor',
|
|
28
|
+
status: state,
|
|
29
|
+
phase: 1,
|
|
30
|
+
phaseKind: 'test',
|
|
31
|
+
seed: 'page-seo-contract|',
|
|
32
|
+
coverage: 1,
|
|
33
|
+
targeting: [],
|
|
34
|
+
exposurePolicy: 'pageview',
|
|
35
|
+
deferrable: false,
|
|
36
|
+
arms: paths.map((path, index)=>({
|
|
37
|
+
key: index === 0 ? 'control' : `variant${String.fromCharCode(64 + index)}`,
|
|
38
|
+
kind: index === 0 ? 'control' : 'variant',
|
|
39
|
+
weight: index === paths.length - 1 ? 100 - Math.floor(100 / paths.length) * index : Math.floor(100 / paths.length),
|
|
40
|
+
pageTarget: path
|
|
41
|
+
})),
|
|
42
|
+
...winner ? {
|
|
43
|
+
winnerArm: paths.indexOf(winner) === 0 ? 'control' : `variant${String.fromCharCode(64 + paths.indexOf(winner))}`
|
|
44
|
+
} : {}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const rows = [];
|
|
48
|
+
for (const requestPath of paths){
|
|
49
|
+
const servedPaths = state === 'running' ? paths : [
|
|
50
|
+
winner
|
|
51
|
+
];
|
|
52
|
+
for (const servedPath of servedPaths){
|
|
53
|
+
const canonical = state === 'archived' ? requestPath === winner ? undefined : winner : original;
|
|
54
|
+
rows.push({
|
|
55
|
+
requestPath,
|
|
56
|
+
servedPath,
|
|
57
|
+
headers: {
|
|
58
|
+
'x-xp-request': `GET|${requestPath}|c1|${sign(`xp:request:GET|${requestPath}|c1`, [
|
|
59
|
+
secret
|
|
60
|
+
])}`
|
|
61
|
+
},
|
|
62
|
+
expect: {
|
|
63
|
+
...state !== 'archived' && requestPath !== original ? {
|
|
64
|
+
robots: {
|
|
65
|
+
index: false
|
|
66
|
+
}
|
|
67
|
+
} : {},
|
|
68
|
+
...canonical ? {
|
|
69
|
+
canonical
|
|
70
|
+
} : {},
|
|
71
|
+
inSitemap: state === 'archived' ? requestPath === winner : requestPath === original
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
manifest,
|
|
78
|
+
rows
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook } from 'payload';
|
|
2
|
-
/** Paired beforeChange capture: previousDoc may be an autosaved draft. */
|
|
2
|
+
/** Paired raw beforeChange capture: previousDoc may be an autosaved draft. */
|
|
3
3
|
export declare function capturePublishedContent({ req, collection, originalDoc, operation }: Parameters<CollectionBeforeChangeHook>[0]): Promise<void>;
|
|
4
4
|
type ContentChangeArgs = Pick<Parameters<CollectionAfterChangeHook>[0], 'doc' | 'previousDoc' | 'req' | 'collection'>;
|
|
5
5
|
/** Publish-only host helper. Audit failures are logged, never thrown into saves. */
|
|
@@ -1,9 +1,15 @@
|
|
|
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
|
-
import { appendContentChange, contentChangeSavepoint, previousPublishedContent, runningContentExperiment } from '../db/appends.js';
|
|
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';
|
|
12
|
+
import { normaliseContentRelationships } from './normaliseContentRelationships.js';
|
|
7
13
|
const slots = [
|
|
8
14
|
'control',
|
|
9
15
|
'variantA',
|
|
@@ -54,8 +60,7 @@ function bindings(data, puck) {
|
|
|
54
60
|
return found;
|
|
55
61
|
}
|
|
56
62
|
const baselineKey = (collection, id)=>`${collection}:${id}`;
|
|
57
|
-
/** Paired beforeChange capture: previousDoc may be an autosaved draft. */ export async function capturePublishedContent({ req, collection, originalDoc, operation }) {
|
|
58
|
-
if (!collection.versions || !collection.versions.drafts) return;
|
|
63
|
+
/** Paired raw beforeChange capture: previousDoc may be an autosaved draft. */ export async function capturePublishedContent({ req, collection, originalDoc, operation }) {
|
|
59
64
|
if (await req.transactionID == null) {
|
|
60
65
|
req.payload.logger?.debug('[payload-experiments] contentChanges baseline skipped without a transaction');
|
|
61
66
|
return;
|
|
@@ -64,7 +69,7 @@ const baselineKey = (collection, id)=>`${collection}:${id}`;
|
|
|
64
69
|
const baselines = req.context.xpPublishedContent ??= {};
|
|
65
70
|
const key = baselineKey(collection.slug, id);
|
|
66
71
|
delete baselines[key];
|
|
67
|
-
const audit = await contentChangeSavepoint(txExecutor(req), async ()=>operation === 'create' ? undefined : previousPublishedContent(req, collection.slug, id));
|
|
72
|
+
const audit = await contentChangeSavepoint(txExecutor(req), async ()=>operation === 'create' ? undefined : collection.versions && collection.versions.drafts ? previousPublishedContent(req, collection.slug, id) : storedContent(req, collection.slug, id));
|
|
68
73
|
if (!audit.ok) throw audit.error;
|
|
69
74
|
baselines[key] = {
|
|
70
75
|
collection: collection.slug,
|
|
@@ -97,9 +102,33 @@ async function auditContentChange({ doc, previousDoc, req, collection }) {
|
|
|
97
102
|
}
|
|
98
103
|
const options = req.payload.config.custom?.xp;
|
|
99
104
|
const registration = puckCollections(options ?? {}).find((entry)=>entry.slug === collection.slug);
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
const before = bindings(registration && object(previous) ? previous[registration.field] : previous, Boolean(registration));
|
|
105
|
+
const changes = (previous, current)=>{
|
|
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(),
|
|
@@ -107,21 +136,51 @@ async function auditContentChange({ doc, previousDoc, req, collection }) {
|
|
|
107
136
|
])
|
|
108
137
|
].filter((key)=>!isDeepStrictEqual(before.get(key)?.contents, after.get(key)?.contents)).map((key)=>after.get(key) ?? before.get(key));
|
|
109
138
|
};
|
|
110
|
-
if (!drafts && !changes(previousDoc).length) return;
|
|
111
139
|
// Include discovery reads in the savepoint: a SQL read error also aborts Postgres.
|
|
112
140
|
// An explicit host call must supply its afterChange request's transaction.
|
|
113
141
|
const executor = txExecutor(req);
|
|
114
142
|
const audit = await contentChangeSavepoint(executor, async ()=>{
|
|
115
|
-
|
|
143
|
+
// Compare like with like. A captured baseline is adapter-raw, so it meets the stored row after the
|
|
144
|
+
// write. Without one, previousDoc and doc are both afterRead documents (same locale projection,
|
|
145
|
+
// hidden fields and field hooks) that differ only in depth, so only relationships are normalised.
|
|
146
|
+
const locales = req.payload.config.localization ? req.payload.config.localization.localeCodes : [];
|
|
147
|
+
const normalise = (value)=>normaliseContentRelationships(value, collection.fields, req.payload.config.blocks, locales);
|
|
148
|
+
let changed;
|
|
149
|
+
let beforeRow, afterRow;
|
|
150
|
+
if (baseline) {
|
|
151
|
+
const current = await storedContent(req, collection.slug, doc.id);
|
|
152
|
+
if (!current) throw new Error('The saved content row was not found.');
|
|
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
|
+
}
|
|
116
161
|
const experiments = new Map();
|
|
117
162
|
for (const binding of changed){
|
|
118
163
|
const experiment = await runningContentExperiment(executor, binding);
|
|
119
164
|
if (experiment) experiments.set(String(experiment.id), experiment.id);
|
|
120
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
|
+
}
|
|
121
179
|
// Successful audit locks persist until commit; acquire multiple E locks in order.
|
|
122
|
-
for (const
|
|
123
|
-
...experiments
|
|
124
|
-
]
|
|
180
|
+
for (const key of sortLockKeys([
|
|
181
|
+
...experiments.keys()
|
|
182
|
+
])){
|
|
183
|
+
const experimentId = experiments.get(key);
|
|
125
184
|
await withTransition(req, {
|
|
126
185
|
experimentId,
|
|
127
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';
|
|
@@ -62,19 +64,50 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
62
64
|
];
|
|
63
65
|
const linked = new Set(userIds);
|
|
64
66
|
const identityConflicts = new Set();
|
|
67
|
+
const userByVisitor = new Map(identities.map((identity)=>[
|
|
68
|
+
identity.anonymousId,
|
|
69
|
+
identity.userId
|
|
70
|
+
]));
|
|
71
|
+
const linksByUser = new Map();
|
|
72
|
+
for (const identity of identities){
|
|
73
|
+
const links = linksByUser.get(identity.userId) ?? [];
|
|
74
|
+
links.push(identity);
|
|
75
|
+
linksByUser.set(identity.userId, links);
|
|
76
|
+
}
|
|
77
|
+
// Last arm credited per user: enough to detect that a user's credited events span arms (a conflict).
|
|
78
|
+
const creditedArms = new Map();
|
|
79
|
+
const noteCredit = (userId, unitId)=>{
|
|
80
|
+
const arm = units.get(unitId)?.arm;
|
|
81
|
+
if (arm === undefined) return;
|
|
82
|
+
const previous = creditedArms.get(userId);
|
|
83
|
+
if (previous !== undefined && previous !== arm) identityConflicts.add(userId);
|
|
84
|
+
creditedArms.set(userId, arm);
|
|
85
|
+
};
|
|
65
86
|
const credit = (events, userKeyed)=>{
|
|
66
87
|
if (candidate.unitType === 'user') return events.map((event)=>({
|
|
67
88
|
...event,
|
|
68
89
|
unitId: event.userId ?? event.unitId
|
|
69
90
|
}));
|
|
70
|
-
|
|
91
|
+
// §8.3's earliest exposed linked visitor applies only when the event has no
|
|
92
|
+
// exposed unit of its own. A visitor-keyed event stays on its own exposed
|
|
93
|
+
// browser even if an earlier linked browser saw another arm or is ineligible.
|
|
94
|
+
const direct = userKeyed ? [] : events.filter((event)=>units.has(event.unitId) || event.userId == null || !linked.has(event.userId));
|
|
95
|
+
const userEvents = events.filter((event)=>userKeyed || !units.has(event.unitId) && event.userId != null && linked.has(event.userId)).map((event)=>({
|
|
71
96
|
userId: String(event.userId ?? event.unitId),
|
|
72
97
|
value: event.value,
|
|
73
98
|
ts: event.ts
|
|
74
99
|
}));
|
|
75
100
|
const credited = creditUserEvents(units, identities, userEvents, (userId)=>identityConflicts.add(userId)).events;
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
for (const event of direct)if (event.userId != null && linked.has(event.userId)) {
|
|
102
|
+
noteCredit(event.userId, event.unitId);
|
|
103
|
+
// Keep the eligible-link diagnostic when direct credit bypasses the helper.
|
|
104
|
+
const arms = new Set(eligibleIdentityLinks(linksByUser.get(event.userId), event.ts).map((link)=>units.get(link.anonymousId).arm));
|
|
105
|
+
arms.add(units.get(event.unitId).arm);
|
|
106
|
+
if (arms.size > 1) identityConflicts.add(event.userId);
|
|
107
|
+
}
|
|
108
|
+
for (const event of credited)noteCredit(userByVisitor.get(event.unitId), event.unitId);
|
|
109
|
+
return [
|
|
110
|
+
...direct,
|
|
78
111
|
...credited
|
|
79
112
|
];
|
|
80
113
|
};
|
|
@@ -89,9 +122,9 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
89
122
|
].reduce((earliest, unit)=>Math.min(earliest, +unit.firstTs), timestampMs(phase.startedAt)));
|
|
90
123
|
const preFrom = new Date(+from - 14 * 86_400_000);
|
|
91
124
|
let events, covariates;
|
|
92
|
-
if (definition.source.startsWith('events:')) {
|
|
93
|
-
const eventName = definition.source.slice(7);
|
|
94
|
-
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');
|
|
95
128
|
const read = async (start)=>{
|
|
96
129
|
if (candidate.unitType === 'user') return metricEventRows(exec, {
|
|
97
130
|
eventName,
|
|
@@ -111,9 +144,10 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
111
144
|
from: start,
|
|
112
145
|
to
|
|
113
146
|
});
|
|
147
|
+
// The two lookups overlap when a linked user's own browser was exposed.
|
|
114
148
|
return [
|
|
115
149
|
...direct,
|
|
116
|
-
...signedIn
|
|
150
|
+
...signedIn.filter((event)=>!units.has(event.unitId))
|
|
117
151
|
];
|
|
118
152
|
};
|
|
119
153
|
events = credit(await read(phase.analysis.cuped === 'auto' ? preFrom : from), false);
|
|
@@ -189,9 +223,12 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
189
223
|
}
|
|
190
224
|
}
|
|
191
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;
|
|
192
229
|
metricData.push({
|
|
193
230
|
metricId: definition.metricId,
|
|
194
|
-
values: perUnitValues(units,
|
|
231
|
+
values: perUnitValues(units, outcomeEvents, {
|
|
195
232
|
kind,
|
|
196
233
|
windowHours,
|
|
197
234
|
endedAt: to
|
|
@@ -317,7 +354,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
317
354
|
} : await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics, payload.logger));
|
|
318
355
|
}
|
|
319
356
|
if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
|
|
320
|
-
const current = phases.find((candidate)=>candidate.
|
|
357
|
+
const current = phases.find((candidate)=>candidate.phase.phase === candidate.currentPhase && (candidate.status === 'running' || candidate.status === 'decided'));
|
|
321
358
|
if (current) {
|
|
322
359
|
const req = await raceDeadline(analysisDeadline, ()=>createLocalReq({}, payload));
|
|
323
360
|
const health = [];
|
|
@@ -326,12 +363,12 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
326
363
|
...current.phase.arms.filter((arm)=>arm.kind !== 'holdout'),
|
|
327
364
|
...current.testArms.filter((arm)=>arm.kind === 'control')
|
|
328
365
|
] : current.testArms.filter((arm)=>arm.kind !== 'holdout' && (current.status !== 'decided' || arm.kind === 'control' || arm.key === current.winnerArm));
|
|
329
|
-
const targets = new Set(arms.flatMap((arm)=>arm.pageTarget ? [
|
|
366
|
+
const targets = new Set((current.kind === 'page' ? arms : []).flatMap((arm)=>arm.pageTarget ? [
|
|
330
367
|
arm.pageTarget
|
|
331
368
|
] : []));
|
|
332
369
|
for (const path of targets){
|
|
333
370
|
let published = false, found = false, unchecked = false;
|
|
334
|
-
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 ?? []){
|
|
335
372
|
try {
|
|
336
373
|
const page = await raceDeadline(analysisDeadline, ()=>collection.findByPath(path, req));
|
|
337
374
|
found ||= page != null;
|
|
@@ -350,16 +387,52 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
350
387
|
path
|
|
351
388
|
});
|
|
352
389
|
}
|
|
353
|
-
|
|
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
|
+
}
|
|
354
418
|
}
|
|
355
419
|
if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
});
|
|
363
436
|
// Unchanged health is a successful refresh with nothing to write.
|
|
364
437
|
if (!changed.length) {
|
|
365
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
|
} : {}
|