@delmaredigital/payload-experiments 0.3.0-rc.1 → 0.3.0-rc.4
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 +74 -16
- package/dist/admin/PreviewLinks.js +45 -24
- package/dist/admin/TestsNavLink.d.ts +3 -1
- package/dist/admin/TestsNavLink.js +2 -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/copy/arms.d.ts +1 -6
- package/dist/admin/copy/arms.js +1 -16
- package/dist/admin/copy/readiness.d.ts +4 -1
- package/dist/admin/copy/readiness.js +3 -1
- package/dist/admin/copy/results.d.ts +2 -0
- package/dist/admin/copy/results.js +3 -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/paths.d.ts +2 -1
- package/dist/admin/paths.js +2 -1
- package/dist/admin/previewLinks.model.d.ts +2 -0
- package/dist/admin/previewLinks.model.js +3 -0
- 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 +6 -6
- package/dist/admin/results/results.model.js +14 -5
- package/dist/admin/results/snapshot.d.ts +14 -0
- package/dist/admin/results/snapshot.js +3 -0
- package/dist/admin/surface/TestsHome.js +6 -9
- package/dist/admin/surface/goals/data.js +16 -16
- package/dist/admin/surface/home.model.d.ts +1 -0
- package/dist/admin/surface/newTest/NewTest.js +4 -4
- package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
- package/dist/admin/surface/readiness/readiness.model.js +8 -1
- package/dist/admin/surface/results/ResultsBody.js +2 -1
- 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/surface.css +0 -1
- package/dist/admin/surface/test/GoalSection.js +7 -15
- 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/useTestDocument.js +2 -1
- package/dist/admin/useLayerUsage.js +14 -23
- 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/db/appends.d.ts +6 -2
- package/dist/db/appends.js +10 -4
- package/dist/db/locks.d.ts +6 -0
- package/dist/db/locks.js +13 -1
- package/dist/db/pageOccupancy.d.ts +12 -0
- package/dist/db/pageOccupancy.js +45 -0
- package/dist/db/snapshots.js +4 -3
- package/dist/exports/adminRsc.d.ts +1 -0
- package/dist/exports/adminRsc.js +1 -0
- package/dist/exports/index.d.ts +1 -1
- package/dist/exports/puck.d.ts +2 -1
- package/dist/exports/puck.js +1 -1
- package/dist/exports/puckEditor.d.ts +3 -1
- package/dist/exports/puckEditor.js +2 -1
- package/dist/exports/rsc.d.ts +1 -0
- package/dist/hooks/pageGuard.js +21 -3
- package/dist/lib/fetchAllDocs.d.ts +16 -0
- package/dist/lib/fetchAllDocs.js +56 -0
- package/dist/options.js +2 -9
- package/dist/plugin.js +4 -0
- 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 +5 -4
- package/dist/puck/copy.js +5 -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 +47 -161
- package/dist/puck/editor/editor.css +47 -161
- package/dist/puck/editor/editor.model.d.ts +4 -1
- package/dist/puck/editor/editor.model.js +10 -7
- package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
- package/dist/puck/editor/experimentOutlinePlugin.js +89 -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/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/sanitizeReadinessDetail.js +10 -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/recordContentChange.d.ts +1 -1
- package/dist/server/recordContentChange.js +17 -8
- package/dist/server/runResults.js +36 -4
- package/dist/transitions/checkStart.d.ts +5 -1
- package/dist/transitions/checkStart.js +10 -0
- package/dist/transitions/decide.js +16 -1
- package/dist/transitions/placements.d.ts +2 -6
- package/dist/transitions/placements.js +8 -1
- package/dist/transitions/preflightData.d.ts +8 -0
- package/dist/transitions/preflightData.js +116 -36
- package/dist/transitions/start.js +33 -3
- package/dist/types/contracts.d.ts +19 -13
- package/dist/types/endpoints.d.ts +1 -1
- 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 +5 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { createUsePuck, walkTree } from '@puckeditor/core';
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
5
|
+
import { EXPERIMENT_SLOT_TYPE } from '../../types/puck.js';
|
|
6
|
+
import { useArms } from './armsCache.js';
|
|
7
|
+
const usePuckSelector = createUsePuck();
|
|
8
|
+
const slots = [
|
|
9
|
+
'control',
|
|
10
|
+
'variantA',
|
|
11
|
+
'variantB',
|
|
12
|
+
'variantC'
|
|
13
|
+
];
|
|
14
|
+
// Puck 0.24.0 dist/chunk-DQXMVPVM.mjs:8040 (item attribute), 8126–8135
|
|
15
|
+
// (direct zone containers). Zones have no stable attribute; avoid the CSS-module hash.
|
|
16
|
+
const zone = 'div[class*="Layer-zones"]';
|
|
17
|
+
const chain = (count)=>Array(count).fill(zone).join(' ~ ');
|
|
18
|
+
function SlotRules({ item: [id, testId, emptySlots] }) {
|
|
19
|
+
const known = useArms(testId ?? undefined);
|
|
20
|
+
const [mounted, setMounted] = useState(false);
|
|
21
|
+
useEffect(()=>{
|
|
22
|
+
setMounted(true);
|
|
23
|
+
}, []);
|
|
24
|
+
// CSS.escape is a browser API: SSR and the first hydration render emit no rules.
|
|
25
|
+
if (!mounted) return null;
|
|
26
|
+
const rules = slots.flatMap((slot, index)=>{
|
|
27
|
+
// Original always remains available. Unknown bound tests must reveal every zone.
|
|
28
|
+
if (slot === 'control' || testId !== null && (!known || known.arms.includes(slot))) return [];
|
|
29
|
+
if (!emptySlots[index]) return [];
|
|
30
|
+
// Exactly the nth zone sibling: the positive chain matches n onward and the
|
|
31
|
+
// negated chain excludes n+1 onward. Unlike nth-child, DropLine siblings do not count.
|
|
32
|
+
const selector = `li[data-puck-layer-tree-id="${CSS.escape(id)}"] > ${chain(index + 1)}:not(${chain(index + 2)})`;
|
|
33
|
+
return [
|
|
34
|
+
`${selector} { display: none; }`
|
|
35
|
+
];
|
|
36
|
+
}).join('\n');
|
|
37
|
+
return /*#__PURE__*/ _jsx("style", {
|
|
38
|
+
"data-xp-outline": id,
|
|
39
|
+
children: rules
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function ExperimentOutline({ children }) {
|
|
43
|
+
// Public selector API in Puck 0.24, rather than the deprecated usePuck hook.
|
|
44
|
+
const key = usePuckSelector((state)=>{
|
|
45
|
+
const { data } = state.appState;
|
|
46
|
+
const items = new Map();
|
|
47
|
+
const collect = (content)=>{
|
|
48
|
+
for (const item of content)if (item.type === EXPERIMENT_SLOT_TYPE) {
|
|
49
|
+
const testId = item.props.experiment?.id;
|
|
50
|
+
items.set(item.props.id, [
|
|
51
|
+
item.props.id,
|
|
52
|
+
testId === undefined ? null : String(testId),
|
|
53
|
+
slots.map((slot)=>{
|
|
54
|
+
const content = item.props[slot];
|
|
55
|
+
return !Array.isArray(content) || content.length === 0;
|
|
56
|
+
})
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walkTree(data, state.config, (content)=>{
|
|
61
|
+
collect(content);
|
|
62
|
+
});
|
|
63
|
+
// walkTree visits slots inside legacy zones, but does not call back on the zone itself.
|
|
64
|
+
for (const content of Object.values(data.zones ?? {}))collect(content);
|
|
65
|
+
return JSON.stringify([
|
|
66
|
+
...items.values()
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
// Only binding/emptiness changes rebuild the rules, not edits to other page fields.
|
|
70
|
+
const rules = useMemo(()=>JSON.parse(key).map((item)=>/*#__PURE__*/ _jsx(SlotRules, {
|
|
71
|
+
item: item
|
|
72
|
+
}, item[0])), [
|
|
73
|
+
key
|
|
74
|
+
]);
|
|
75
|
+
return /*#__PURE__*/ _jsxs(_Fragment, {
|
|
76
|
+
children: [
|
|
77
|
+
children,
|
|
78
|
+
rules
|
|
79
|
+
]
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Add to Puck's plugins so its outline wrapper composes with the host's overrides. */ export function experimentOutlinePlugin() {
|
|
83
|
+
return {
|
|
84
|
+
name: 'experiment-outline',
|
|
85
|
+
overrides: {
|
|
86
|
+
outline: ExperimentOutline
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { CustomField } from '@puckeditor/core';
|
|
2
2
|
import type { ExperimentRef } from '../types/puck.js';
|
|
3
|
+
import { type PuckRoutes } from './routes.js';
|
|
3
4
|
export declare function createExperimentSlotField(opts?: {
|
|
4
5
|
label?: string;
|
|
6
|
+
routes?: PuckRoutes;
|
|
5
7
|
}): CustomField<ExperimentRef | null>;
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { createElement } from 'react';
|
|
2
2
|
import { ExperimentPicker } from './ExperimentPicker.js';
|
|
3
|
+
import { resolvePuckRoutes } from './routes.js';
|
|
4
|
+
import { PuckHostProvider } from './hostContext.js';
|
|
3
5
|
import { pickerCopy } from './copy.js';
|
|
4
6
|
export function createExperimentSlotField(opts) {
|
|
7
|
+
const routes = resolvePuckRoutes(opts?.routes);
|
|
5
8
|
return {
|
|
6
9
|
type: 'custom',
|
|
7
10
|
label: opts?.label ?? pickerCopy.label,
|
|
8
|
-
render: ({ value, onChange })=>createElement(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
render: ({ value, onChange })=>createElement(PuckHostProvider, {
|
|
12
|
+
routes,
|
|
13
|
+
children: createElement('div', {
|
|
14
|
+
className: 'xp-puck-field'
|
|
15
|
+
}, createElement(ExperimentPicker, {
|
|
16
|
+
value,
|
|
17
|
+
onChange
|
|
18
|
+
}))
|
|
11
19
|
})
|
|
12
20
|
};
|
|
13
21
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { resolvePuckRoutes } from './routes.js';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
export declare const usePuckRoutes: () => {
|
|
4
|
+
admin: string;
|
|
5
|
+
api: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function PuckHostProvider({ routes, children }: {
|
|
8
|
+
routes: ReturnType<typeof resolvePuckRoutes>;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext } from 'react';
|
|
4
|
+
import { resolvePuckRoutes } from './routes.js';
|
|
5
|
+
const PuckHostContext = /*#__PURE__*/ createContext(resolvePuckRoutes());
|
|
6
|
+
export const usePuckRoutes = ()=>useContext(PuckHostContext);
|
|
7
|
+
export function PuckHostProvider({ routes, children }) {
|
|
8
|
+
return /*#__PURE__*/ _jsx(PuckHostContext.Provider, {
|
|
9
|
+
value: routes,
|
|
10
|
+
children: children
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface PuckRoutes {
|
|
2
|
+
admin?: string;
|
|
3
|
+
api?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare const DEFAULT_PUCK_ROUTES: {
|
|
6
|
+
readonly admin: "/admin";
|
|
7
|
+
readonly api: "/api";
|
|
8
|
+
};
|
|
9
|
+
export declare function resolvePuckRoutes(routes?: PuckRoutes): {
|
|
10
|
+
admin: string;
|
|
11
|
+
api: string;
|
|
12
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Payload's documented route defaults; hosts pass config.routes when customized.
|
|
2
|
+
export const DEFAULT_PUCK_ROUTES = {
|
|
3
|
+
admin: '/admin',
|
|
4
|
+
api: '/api'
|
|
5
|
+
};
|
|
6
|
+
export function resolvePuckRoutes(routes) {
|
|
7
|
+
return {
|
|
8
|
+
admin: (routes?.admin ?? DEFAULT_PUCK_ROUTES.admin).replace(/\/$/, ''),
|
|
9
|
+
api: (routes?.api ?? DEFAULT_PUCK_ROUTES.api).replace(/\/$/, '')
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ComponentConfig } from '@puckeditor/core';
|
|
2
2
|
import type { ExperimentSlotProps } from '../types/puck.js';
|
|
3
|
-
export declare const experimentSlotLabel
|
|
3
|
+
export declare const experimentSlotLabel = "Experiment block";
|
|
4
4
|
export declare const experimentSlotFields: NonNullable<ComponentConfig<ExperimentSlotProps>['fields']>;
|
|
5
5
|
export declare const experimentSlotDefaultProps: NonNullable<ComponentConfig<ExperimentSlotProps>['defaultProps']>;
|
package/dist/puck/slotConfig.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createExperimentSlotField } from './experimentSlotField.js';
|
|
2
|
-
import {
|
|
3
|
-
export const experimentSlotLabel =
|
|
2
|
+
import { slotLabels } from './copy.js';
|
|
3
|
+
export const experimentSlotLabel = 'Experiment block';
|
|
4
4
|
export const experimentSlotFields = {
|
|
5
5
|
experiment: createExperimentSlotField(),
|
|
6
6
|
// Puck 0.24's defaultSlots prepends each slot, reversing declaration order in the outline.
|
|
@@ -8,6 +8,7 @@ const arm = (value)=>typeof value === 'string' && [
|
|
|
8
8
|
'variantC',
|
|
9
9
|
'holdout'
|
|
10
10
|
].includes(value);
|
|
11
|
+
const path = (value)=>typeof value === 'string' && value.length <= 128 && value.startsWith('/') && !value.includes('//') && !/[^A-Za-z0-9_/-]/.test(value);
|
|
11
12
|
const failure = (value)=>value === 'placements-failed' || value === 'forbidden' || value === 'not-found';
|
|
12
13
|
/** Summary details contain identifiers only. Project known shapes rather than
|
|
13
14
|
* recursively copying host objects: titles, messages and placement detail are text.
|
|
@@ -21,9 +22,17 @@ const failure = (value)=>value === 'placements-failed' || value === 'forbidden'
|
|
|
21
22
|
} : undefined;
|
|
22
23
|
case 'no-placements':
|
|
23
24
|
return failure(detail) ? detail : undefined;
|
|
25
|
+
case 'page-in-use':
|
|
26
|
+
{
|
|
27
|
+
if (!object(detail) || !Array.isArray(detail.paths)) return undefined;
|
|
28
|
+
const paths = detail.paths.filter(path);
|
|
29
|
+
return paths.length ? {
|
|
30
|
+
paths
|
|
31
|
+
} : undefined;
|
|
32
|
+
}
|
|
24
33
|
case 'page-target-unpublished':
|
|
25
34
|
// Paths only: no query, fragment, whitespace, URL authority or encoded text.
|
|
26
|
-
return
|
|
35
|
+
return path(detail) ? detail : undefined;
|
|
27
36
|
case 'flag-params-invalid':
|
|
28
37
|
{
|
|
29
38
|
if (!Array.isArray(detail) || !detail.length || !detail.every((item)=>object(item) && arm(item.arm) && key(item.field))) return undefined;
|
package/dist/server/metadata.js
CHANGED
|
@@ -13,14 +13,21 @@ import { serverManifest } from './context.js';
|
|
|
13
13
|
const live = manifest?.experiments.find((e)=>e.kind === 'page' && e.arms.some((a)=>a.pageTarget !== undefined && normalisePath(a.pageTarget) === normalisePath(path)));
|
|
14
14
|
if (live) {
|
|
15
15
|
const controlPath = live.arms.find((a)=>a.kind === 'control')?.pageTarget;
|
|
16
|
-
|
|
16
|
+
if (!controlPath) return {};
|
|
17
|
+
// The Original's URL may be served with a variant's route: its canonical must stay on the Original,
|
|
18
|
+
// never the (noindexed) variant route the host would otherwise derive.
|
|
19
|
+
return normalisePath(path) !== normalisePath(controlPath) ? {
|
|
17
20
|
robots: {
|
|
18
21
|
index: false
|
|
19
22
|
},
|
|
20
23
|
alternates: {
|
|
21
24
|
canonical: controlPath
|
|
22
25
|
}
|
|
23
|
-
} : {
|
|
26
|
+
} : {
|
|
27
|
+
alternates: {
|
|
28
|
+
canonical: controlPath
|
|
29
|
+
}
|
|
30
|
+
};
|
|
24
31
|
}
|
|
25
32
|
const archivedPath = manifest ? findNormalisedPathKey(manifest.archivedPages, path) : undefined;
|
|
26
33
|
const archived = archivedPath === undefined ? undefined : manifest?.archivedPages[archivedPath];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Block, Field } from 'payload';
|
|
2
|
+
/** Undo depth expansion only where the collection schema declares relationships.
|
|
3
|
+
* Hidden fields and field-level afterRead hooks are unaffected by depth, so
|
|
4
|
+
* relationship expansion is the only depth-dependent transformation to undo.
|
|
5
|
+
* Other objects (especially JSON content with its own ids) must stay intact.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normaliseContentRelationships(data: unknown, fields: Field[], blocks?: Block[], locales?: readonly string[]): unknown;
|
|
@@ -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
|
+
}
|
|
@@ -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,10 @@
|
|
|
1
1
|
import { safeError } from '../log.js';
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
-
import { appendContentChange, contentChangeSavepoint, previousPublishedContent, runningContentExperiment } from '../db/appends.js';
|
|
3
|
+
import { appendContentChange, contentChangeSavepoint, previousPublishedContent, runningContentExperiment, storedContent } from '../db/appends.js';
|
|
4
4
|
import { txExecutor } from '../db/executor.js';
|
|
5
5
|
import { puckCollections } from '../options.js';
|
|
6
6
|
import { withTransition } from '../transitions/kernel.js';
|
|
7
|
+
import { normaliseContentRelationships } from './normaliseContentRelationships.js';
|
|
7
8
|
const slots = [
|
|
8
9
|
'control',
|
|
9
10
|
'variantA',
|
|
@@ -54,8 +55,7 @@ function bindings(data, puck) {
|
|
|
54
55
|
return found;
|
|
55
56
|
}
|
|
56
57
|
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;
|
|
58
|
+
/** Paired raw beforeChange capture: previousDoc may be an autosaved draft. */ export async function capturePublishedContent({ req, collection, originalDoc, operation }) {
|
|
59
59
|
if (await req.transactionID == null) {
|
|
60
60
|
req.payload.logger?.debug('[payload-experiments] contentChanges baseline skipped without a transaction');
|
|
61
61
|
return;
|
|
@@ -64,7 +64,7 @@ const baselineKey = (collection, id)=>`${collection}:${id}`;
|
|
|
64
64
|
const baselines = req.context.xpPublishedContent ??= {};
|
|
65
65
|
const key = baselineKey(collection.slug, id);
|
|
66
66
|
delete baselines[key];
|
|
67
|
-
const audit = await contentChangeSavepoint(txExecutor(req), async ()=>operation === 'create' ? undefined : previousPublishedContent(req, collection.slug, id));
|
|
67
|
+
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
68
|
if (!audit.ok) throw audit.error;
|
|
69
69
|
baselines[key] = {
|
|
70
70
|
collection: collection.slug,
|
|
@@ -97,8 +97,8 @@ async function auditContentChange({ doc, previousDoc, req, collection }) {
|
|
|
97
97
|
}
|
|
98
98
|
const options = req.payload.config.custom?.xp;
|
|
99
99
|
const registration = puckCollections(options ?? {}).find((entry)=>entry.slug === collection.slug);
|
|
100
|
-
const
|
|
101
|
-
|
|
100
|
+
const changes = (previous, current)=>{
|
|
101
|
+
const after = bindings(registration && object(current) ? current[registration.field] : current, Boolean(registration));
|
|
102
102
|
const before = bindings(registration && object(previous) ? previous[registration.field] : previous, Boolean(registration));
|
|
103
103
|
return [
|
|
104
104
|
...new Set([
|
|
@@ -107,12 +107,21 @@ async function auditContentChange({ doc, previousDoc, req, collection }) {
|
|
|
107
107
|
])
|
|
108
108
|
].filter((key)=>!isDeepStrictEqual(before.get(key)?.contents, after.get(key)?.contents)).map((key)=>after.get(key) ?? before.get(key));
|
|
109
109
|
};
|
|
110
|
-
if (!drafts && !changes(previousDoc).length) return;
|
|
111
110
|
// Include discovery reads in the savepoint: a SQL read error also aborts Postgres.
|
|
112
111
|
// An explicit host call must supply its afterChange request's transaction.
|
|
113
112
|
const executor = txExecutor(req);
|
|
114
113
|
const audit = await contentChangeSavepoint(executor, async ()=>{
|
|
115
|
-
|
|
114
|
+
// Compare like with like. A captured baseline is adapter-raw, so it meets the stored row after the
|
|
115
|
+
// write. Without one, previousDoc and doc are both afterRead documents (same locale projection,
|
|
116
|
+
// hidden fields and field hooks) that differ only in depth, so only relationships are normalised.
|
|
117
|
+
const locales = req.payload.config.localization ? req.payload.config.localization.localeCodes : [];
|
|
118
|
+
const normalise = (value)=>normaliseContentRelationships(value, collection.fields, req.payload.config.blocks, locales);
|
|
119
|
+
let changed;
|
|
120
|
+
if (baseline) {
|
|
121
|
+
const current = await storedContent(req, collection.slug, doc.id);
|
|
122
|
+
if (!current) throw new Error('The saved content row was not found.');
|
|
123
|
+
changed = changes(baseline.doc, current);
|
|
124
|
+
} else changed = changes(normalise(previousDoc), normalise(doc));
|
|
116
125
|
const experiments = new Map();
|
|
117
126
|
for (const binding of changed){
|
|
118
127
|
const experiment = await runningContentExperiment(executor, binding);
|
|
@@ -62,19 +62,50 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
62
62
|
];
|
|
63
63
|
const linked = new Set(userIds);
|
|
64
64
|
const identityConflicts = new Set();
|
|
65
|
+
const userByVisitor = new Map(identities.map((identity)=>[
|
|
66
|
+
identity.anonymousId,
|
|
67
|
+
identity.userId
|
|
68
|
+
]));
|
|
69
|
+
const linksByUser = new Map();
|
|
70
|
+
for (const identity of identities){
|
|
71
|
+
const links = linksByUser.get(identity.userId) ?? [];
|
|
72
|
+
links.push(identity);
|
|
73
|
+
linksByUser.set(identity.userId, links);
|
|
74
|
+
}
|
|
75
|
+
// Last arm credited per user: enough to detect that a user's credited events span arms (a conflict).
|
|
76
|
+
const creditedArms = new Map();
|
|
77
|
+
const noteCredit = (userId, unitId)=>{
|
|
78
|
+
const arm = units.get(unitId)?.arm;
|
|
79
|
+
if (arm === undefined) return;
|
|
80
|
+
const previous = creditedArms.get(userId);
|
|
81
|
+
if (previous !== undefined && previous !== arm) identityConflicts.add(userId);
|
|
82
|
+
creditedArms.set(userId, arm);
|
|
83
|
+
};
|
|
65
84
|
const credit = (events, userKeyed)=>{
|
|
66
85
|
if (candidate.unitType === 'user') return events.map((event)=>({
|
|
67
86
|
...event,
|
|
68
87
|
unitId: event.userId ?? event.unitId
|
|
69
88
|
}));
|
|
70
|
-
|
|
89
|
+
// §8.3's earliest exposed linked visitor applies only when the event has no
|
|
90
|
+
// exposed unit of its own. A visitor-keyed event stays on its own exposed
|
|
91
|
+
// browser even if an earlier linked browser saw another arm or is ineligible.
|
|
92
|
+
const direct = userKeyed ? [] : events.filter((event)=>units.has(event.unitId) || event.userId == null || !linked.has(event.userId));
|
|
93
|
+
const userEvents = events.filter((event)=>userKeyed || !units.has(event.unitId) && event.userId != null && linked.has(event.userId)).map((event)=>({
|
|
71
94
|
userId: String(event.userId ?? event.unitId),
|
|
72
95
|
value: event.value,
|
|
73
96
|
ts: event.ts
|
|
74
97
|
}));
|
|
75
98
|
const credited = creditUserEvents(units, identities, userEvents, (userId)=>identityConflicts.add(userId)).events;
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
for (const event of direct)if (event.userId != null && linked.has(event.userId)) {
|
|
100
|
+
noteCredit(event.userId, event.unitId);
|
|
101
|
+
// Keep the eligible-link diagnostic when direct credit bypasses the helper.
|
|
102
|
+
const arms = new Set(eligibleIdentityLinks(linksByUser.get(event.userId), event.ts).map((link)=>units.get(link.anonymousId).arm));
|
|
103
|
+
arms.add(units.get(event.unitId).arm);
|
|
104
|
+
if (arms.size > 1) identityConflicts.add(event.userId);
|
|
105
|
+
}
|
|
106
|
+
for (const event of credited)noteCredit(userByVisitor.get(event.unitId), event.unitId);
|
|
107
|
+
return [
|
|
108
|
+
...direct,
|
|
78
109
|
...credited
|
|
79
110
|
];
|
|
80
111
|
};
|
|
@@ -111,9 +142,10 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
111
142
|
from: start,
|
|
112
143
|
to
|
|
113
144
|
});
|
|
145
|
+
// The two lookups overlap when a linked user's own browser was exposed.
|
|
114
146
|
return [
|
|
115
147
|
...direct,
|
|
116
|
-
...signedIn
|
|
148
|
+
...signedIn.filter((event)=>!units.has(event.unitId))
|
|
117
149
|
];
|
|
118
150
|
};
|
|
119
151
|
events = credit(await read(phase.analysis.cuped === 'auto' ? preFrom : from), false);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Arm, DocId, ExperimentKind, ExposurePolicy, FlagPoint, MetricSource, ResolvedOptions, Status, TargetingRule, UnitType } from '../types/contracts.js';
|
|
2
2
|
import type { ArmIssueCode, PreflightResult } from '../types/endpoints.js';
|
|
3
3
|
import type { BlockPlacement, FlagPlacement } from './placements.js';
|
|
4
|
-
export type ReadinessCode = ArmIssueCode | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'page-no-targets' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
|
|
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
5
|
export interface StartExperiment {
|
|
6
6
|
id: DocId;
|
|
7
7
|
slug: string;
|
|
@@ -51,6 +51,7 @@ export interface CheckStartInput {
|
|
|
51
51
|
flagPointBusy?: boolean;
|
|
52
52
|
unpublishedPageTargets?: string[];
|
|
53
53
|
pageTargetsUnchecked?: boolean;
|
|
54
|
+
pageTestsInUse?: Pick<StartExperiment, 'id' | 'arms'>[];
|
|
54
55
|
layer?: {
|
|
55
56
|
unitType: UnitType;
|
|
56
57
|
usedSlices: number[];
|
|
@@ -61,5 +62,8 @@ export declare const ARM_CHECK_ORDER: readonly ArmIssueCode[];
|
|
|
61
62
|
export declare const relationshipId: (value: DocId | {
|
|
62
63
|
id: DocId;
|
|
63
64
|
} | null | undefined) => DocId | undefined;
|
|
65
|
+
/** Every path the runtime would match for these arms (pageRewrite, experimentMetadata): any string
|
|
66
|
+
* pageTarget, holdout included, normalised the same way ('' is '/'). Occupancy and lock keys use this. */
|
|
67
|
+
export declare const pageTargetPaths: (arms: readonly Arm[]) => string[];
|
|
64
68
|
/** Pure Review/Start predicates in refusal order. Inapplicable checks pass. */
|
|
65
69
|
export declare function checkStart(input: CheckStartInput): PreflightResult['checks'];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkArms } from '../collections/experiments/armInvariants.js';
|
|
2
|
+
import { normalisePath } from '../runtime/normalisePath.js';
|
|
2
3
|
import { isOpValid } from '../targeting/ops.js';
|
|
3
4
|
export const ARM_CHECK_ORDER = [
|
|
4
5
|
'weights-total',
|
|
@@ -13,6 +14,10 @@ export const ARM_CHECK_ORDER = [
|
|
|
13
14
|
'duplicate-slot'
|
|
14
15
|
];
|
|
15
16
|
export const relationshipId = (value)=>value == null ? undefined : typeof value === 'object' ? value.id : value;
|
|
17
|
+
/** Every path the runtime would match for these arms (pageRewrite, experimentMetadata): any string
|
|
18
|
+
* pageTarget, holdout included, normalised the same way ('' is '/'). Occupancy and lock keys use this. */ export const pageTargetPaths = (arms)=>[
|
|
19
|
+
...new Set(arms.filter((arm)=>typeof arm.pageTarget === 'string').map((arm)=>normalisePath(arm.pageTarget)))
|
|
20
|
+
].sort();
|
|
16
21
|
const positive = (value)=>typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
17
22
|
function invalidParams(arms, point) {
|
|
18
23
|
return arms.flatMap((arm)=>{
|
|
@@ -56,6 +61,11 @@ function invalidParams(arms, point) {
|
|
|
56
61
|
'viewport'
|
|
57
62
|
].includes(e.exposurePolicy) || positive(e.analysis?.reachPerDay));
|
|
58
63
|
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));
|
|
64
|
+
const occupiedPaths = new Set((input.pageTestsInUse ?? []).filter((other)=>String(other.id) !== String(e.id)).flatMap((other)=>pageTargetPaths(other.arms)));
|
|
65
|
+
const sharedPaths = e.kind === 'page' ? pageTargetPaths(e.arms).filter((path)=>occupiedPaths.has(path)) : [];
|
|
66
|
+
add('page-in-use', sharedPaths.length === 0, {
|
|
67
|
+
paths: sharedPaths
|
|
68
|
+
});
|
|
59
69
|
add('page-target-unpublished', !input.unpublishedPageTargets?.length, input.unpublishedPageTargets?.[0]);
|
|
60
70
|
add('page-targets-unchecked', true);
|
|
61
71
|
if (input.pageTargetsUnchecked) checks[checks.length - 1].warning = true;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
|
|
2
2
|
import { reclaimSlices } from '../db/slices.js';
|
|
3
3
|
import { decideGuard } from '../results/decideGuard.js';
|
|
4
|
-
import {
|
|
4
|
+
import { lockPagePaths } from '../db/locks.js';
|
|
5
|
+
import { routedPageTests } from '../db/pageOccupancy.js';
|
|
6
|
+
import { pageTargetPaths, relationshipId } from './checkStart.js';
|
|
5
7
|
import { expectedConfirm, matchesConfirm, storedWinnerLabel } from './confirmPhrases.js';
|
|
6
8
|
import { withTransition } from './kernel.js';
|
|
7
9
|
import { readPhaseRecord, setPhaseEndedAt, writePhaseRecord } from './phaseRecord.js';
|
|
@@ -45,6 +47,19 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
|
|
|
45
47
|
}
|
|
46
48
|
const test = await readPhaseRecord(req, experimentId, current.phase);
|
|
47
49
|
if (test.phaseKind !== 'test') refuse('invalid-status');
|
|
50
|
+
if (current.kind === 'page' && current.status === 'stopped') {
|
|
51
|
+
// Deciding a stopped page test restores its routing: recheck page occupancy under the
|
|
52
|
+
// sorted path locks Start takes, so another test that started on these pages meanwhile wins.
|
|
53
|
+
// The frozen test-round arms are what decide restores; the stopped document's arms may have been edited.
|
|
54
|
+
const mine = pageTargetPaths(test.arms);
|
|
55
|
+
await lockPagePaths(exec, mine);
|
|
56
|
+
// Other tests' routed (frozen-round) arms, as the runtime matches them.
|
|
57
|
+
const occupied = new Set((await routedPageTests(exec)).filter((other)=>String(other.id) !== String(experimentId)).flatMap((other)=>pageTargetPaths(other.arms)));
|
|
58
|
+
const shared = mine.filter((path)=>occupied.has(path));
|
|
59
|
+
if (shared.length) refuse('page-in-use', {
|
|
60
|
+
paths: shared
|
|
61
|
+
});
|
|
62
|
+
}
|
|
48
63
|
const winner = test.arms.find((arm)=>arm.key === winnerArm && arm.kind !== 'holdout');
|
|
49
64
|
if (!winner) refuse('invalid-winner');
|
|
50
65
|
// §7.8: decision snapshot lookup serves the authorised transition.
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import type { PayloadRequest } from 'payload';
|
|
2
|
-
import type { Arm, DocId, FlagPoint, ResolvedOptions } from '../types/contracts.js';
|
|
2
|
+
import type { Arm, DocId, FlagPoint, PreviewPlacement, ResolvedOptions } from '../types/contracts.js';
|
|
3
3
|
import type { ArmSlot } from '../types/puck.js';
|
|
4
|
-
export interface BlockPlacement {
|
|
5
|
-
collection: string;
|
|
6
|
-
id: DocId;
|
|
7
|
-
title: string;
|
|
8
|
-
published: boolean;
|
|
4
|
+
export interface BlockPlacement extends PreviewPlacement {
|
|
9
5
|
slots: Record<ArmSlot, number>;
|
|
10
6
|
locked?: boolean;
|
|
11
7
|
localized?: boolean;
|
|
@@ -24,6 +24,7 @@ export async function scanBlockPlacements(req, args) {
|
|
|
24
24
|
}
|
|
25
25
|
})){
|
|
26
26
|
const publishedIds = new Set();
|
|
27
|
+
const publishedSlugs = new Map();
|
|
27
28
|
const draftsEnabled = req.payload.config.collections?.some((config)=>config.slug === collection && config.versions && config.versions.drafts);
|
|
28
29
|
for (const draft of [
|
|
29
30
|
false,
|
|
@@ -51,7 +52,10 @@ export async function scanBlockPlacements(req, args) {
|
|
|
51
52
|
for (const doc of result.docs){
|
|
52
53
|
if (!draft && doc._status === 'draft') continue;
|
|
53
54
|
if (draft && doc._status !== 'draft' && publishedIds.has(String(doc.id))) continue;
|
|
54
|
-
if (!draft)
|
|
55
|
+
if (!draft) {
|
|
56
|
+
publishedIds.add(String(doc.id));
|
|
57
|
+
if (typeof doc.slug === 'string') publishedSlugs.set(String(doc.id), doc.slug);
|
|
58
|
+
}
|
|
55
59
|
function walk(value) {
|
|
56
60
|
if (!value || typeof value !== 'object') return;
|
|
57
61
|
if (Array.isArray(value)) {
|
|
@@ -68,6 +72,9 @@ export async function scanBlockPlacements(req, args) {
|
|
|
68
72
|
id: doc.id,
|
|
69
73
|
title: String(doc.title ?? doc.name ?? doc.id),
|
|
70
74
|
published: !draft || doc._status === 'published',
|
|
75
|
+
...publishedSlugs.has(String(doc.id)) ? {
|
|
76
|
+
slug: publishedSlugs.get(String(doc.id))
|
|
77
|
+
} : {},
|
|
71
78
|
slots: Object.fromEntries(slots.map((slot)=>[
|
|
72
79
|
slot,
|
|
73
80
|
Array.isArray(props[slot]) ? props[slot].length : 0
|
|
@@ -13,6 +13,14 @@ export interface PreflightDependencies {
|
|
|
13
13
|
export declare function loadStartInputs<TUser extends {
|
|
14
14
|
id: DocId;
|
|
15
15
|
}>(req: PayloadRequest, options: ResolvedOptions<TUser>, experimentId: DocId): Promise<CheckStartInput | null>;
|
|
16
|
+
/** Occupancy from the arms the runtime routes on (frozen rounds), shared by preflight and
|
|
17
|
+
* Start's locked recheck. Raw SQL on the request's transaction when there is one. */
|
|
18
|
+
export declare function loadPageTestsInUse(req: PayloadRequest): Promise<NonNullable<CheckStartInput['pageTestsInUse']>>;
|
|
19
|
+
/** Page targets no registered collection reports as published. A lookup that throws counts as
|
|
20
|
+
* unpublished (logged). Start calls it again under its page-path locks on a detached request. */
|
|
21
|
+
export declare function unpublishedPageTargets<TUser extends {
|
|
22
|
+
id: DocId;
|
|
23
|
+
}>(req: PayloadRequest, options: ResolvedOptions<TUser>, experiment: Pick<StartExperiment, 'id' | 'arms'>): Promise<string[]>;
|
|
16
24
|
/** Shared, request-scoped reads for summary; a failed row is null in tolerant mode.
|
|
17
25
|
* Preflight/Start use the same builder and retain their throwing read behaviour.
|
|
18
26
|
*/
|