@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
+
import { fetchAllDocs } from '../lib/fetchAllDocs.js';
|
|
2
3
|
import { useConfig } from '@payloadcms/ui';
|
|
3
4
|
import { useEffect, useState } from 'react';
|
|
4
5
|
import { layerUsage } from './layerUsage.model.js';
|
|
@@ -13,33 +14,23 @@ export function useLayerExperiments(layerId) {
|
|
|
13
14
|
if (id == null) return;
|
|
14
15
|
const controller = new AbortController();
|
|
15
16
|
async function load() {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
signal: controller.signal
|
|
29
|
-
});
|
|
30
|
-
if (!response.ok) {
|
|
31
|
-
if (!controller.signal.aborted) setState(null);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const data = await response.json();
|
|
35
|
-
docs.push(...data.docs);
|
|
36
|
-
if (!data.hasNextPage) break;
|
|
37
|
-
page++;
|
|
17
|
+
const query = new URLSearchParams({
|
|
18
|
+
depth: '0',
|
|
19
|
+
'where[layer][equals]': id,
|
|
20
|
+
'where[status][in]': 'running,decided'
|
|
21
|
+
});
|
|
22
|
+
const response = await fetchAllDocs(`${api}/xp-experiments?${query}`, {
|
|
23
|
+
credentials: 'include',
|
|
24
|
+
signal: controller.signal
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
if (!controller.signal.aborted) setState(null);
|
|
28
|
+
return;
|
|
38
29
|
}
|
|
39
30
|
if (!controller.signal.aborted) setState({
|
|
40
31
|
api,
|
|
41
32
|
id: id,
|
|
42
|
-
usage: layerUsage(docs)
|
|
33
|
+
usage: layerUsage(response.docs)
|
|
43
34
|
});
|
|
44
35
|
}
|
|
45
36
|
void load().catch(()=>{
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { ServerProps } from 'payload';
|
|
2
|
+
export declare function TestsNavLinkServer({ payload, user, permissions }: Pick<ServerProps, 'payload' | 'user' | 'permissions'>): Promise<string | number | bigint | boolean | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<import("react").ReactNode> | null | undefined>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent';
|
|
2
|
+
import { SURFACE_COPY } from '../copy/surface.js';
|
|
3
|
+
import { XP_TESTS_NAV_CLIENT } from '../paths.js';
|
|
4
|
+
export async function TestsNavLinkServer({ payload, user, permissions }) {
|
|
5
|
+
const testPermissions = permissions?.collections?.['xp-experiments'];
|
|
6
|
+
if (!user?.collection || testPermissions?.update !== true) return null;
|
|
7
|
+
const result = await payload.find({
|
|
8
|
+
collection: 'payload-preferences',
|
|
9
|
+
where: {
|
|
10
|
+
and: [
|
|
11
|
+
{
|
|
12
|
+
key: {
|
|
13
|
+
equals: 'nav'
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
'user.relationTo': {
|
|
18
|
+
equals: user.collection
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
'user.value': {
|
|
23
|
+
equals: user.id
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
limit: 1,
|
|
29
|
+
pagination: false,
|
|
30
|
+
depth: 0,
|
|
31
|
+
user,
|
|
32
|
+
overrideAccess: false
|
|
33
|
+
});
|
|
34
|
+
const value = result.docs[0]?.value;
|
|
35
|
+
// Resolve the CSS-bearing client boundary through Payload's public import map.
|
|
36
|
+
return RenderServerComponent({
|
|
37
|
+
Component: XP_TESTS_NAV_CLIENT,
|
|
38
|
+
importMap: payload.importMap,
|
|
39
|
+
clientProps: {
|
|
40
|
+
isOpen: value?.groups?.[SURFACE_COPY.title]?.open ?? true
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const ISSUE_COPY = {
|
|
2
|
+
'weights-total': 'Weights must add up to 100%. They add up to {total}% now.',
|
|
3
|
+
'control-count': 'Include exactly one Original.',
|
|
4
|
+
'control-first': 'Put the Original first.',
|
|
5
|
+
'holdout-last': 'Put the holdout last.',
|
|
6
|
+
'holdout-count': 'Include only one holdout.',
|
|
7
|
+
'variant-count': 'Include up to three variants.',
|
|
8
|
+
'duplicate-key': 'Each group must have a different standard name.',
|
|
9
|
+
'duplicate-slot': 'Each group must have a different content slot.',
|
|
10
|
+
'slot-required': 'Choose the matching content slot for this group.',
|
|
11
|
+
'invalid-key': 'Use the standard group names: Original, Variant A–C, Holdout.'
|
|
12
|
+
};
|
|
13
|
+
export function issueMessage(issue, total) {
|
|
14
|
+
const copy = Object.hasOwn(ISSUE_COPY, issue.code) ? ISSUE_COPY[issue.code] : issue.message;
|
|
15
|
+
return copy.replace('{total}', String(total));
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ArmIssue } from '../collections/experiments/armInvariants.js';
|
|
2
|
+
import type { Arm, ArmKey, ExperimentKind } from '../types/contracts.js';
|
|
3
|
+
export declare function defaultArms(kind?: ExperimentKind): Arm[];
|
|
4
|
+
/** Keep content slots in step with the experiment kind, including an unset kind. */
|
|
5
|
+
export declare function withSlotsFor(arms: Arm[], kind?: ExperimentKind): Arm[];
|
|
6
|
+
export declare function totalWeight(arms: Arm[]): number;
|
|
7
|
+
export declare function addVariant(arms: Arm[], kind?: ExperimentKind): Arm[];
|
|
8
|
+
export declare function removeArm(arms: Arm[], key: ArmKey): Arm[];
|
|
9
|
+
export declare function addHoldoutArm(arms: Arm[]): Arm[];
|
|
10
|
+
export declare function removeHoldout(arms: Arm[]): Arm[];
|
|
11
|
+
export declare function splitEvenly(arms: Arm[]): Arm[];
|
|
12
|
+
export declare function setWeight(arms: Arm[], key: ArmKey, weight: number): Arm[];
|
|
13
|
+
export declare function issuesByRow(arms: Arm[], kind?: ExperimentKind): {
|
|
14
|
+
rows: ArmIssue[][];
|
|
15
|
+
form: ArmIssue[];
|
|
16
|
+
};
|
|
17
|
+
export declare const ARM_FIELDS: readonly ["key", "label", "weight", "kind", "slot", "params", "pageTarget"];
|
|
18
|
+
/** Payload arrays store each row's values at separate field paths. */
|
|
19
|
+
export declare function readArms(fields: Record<string, {
|
|
20
|
+
value?: unknown;
|
|
21
|
+
}>, path?: string): Arm[];
|
|
22
|
+
/** Keep the edited share exact and distribute the remaining whole percentages proportionally. */
|
|
23
|
+
export declare function balanceWeight(arms: Arm[], key: ArmKey, weight: number): Arm[];
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { addHoldout, checkArms } from '../collections/experiments/armInvariants.js';
|
|
2
|
+
import { ARM_LABELS as GROUP_LABELS } from '../labels/arms.js';
|
|
3
|
+
const VARIANTS = [
|
|
4
|
+
'variantA',
|
|
5
|
+
'variantB',
|
|
6
|
+
'variantC'
|
|
7
|
+
];
|
|
8
|
+
export function defaultArms(kind) {
|
|
9
|
+
return withSlotsFor([
|
|
10
|
+
{
|
|
11
|
+
key: 'control',
|
|
12
|
+
kind: 'control',
|
|
13
|
+
label: GROUP_LABELS.control,
|
|
14
|
+
weight: 50
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
key: 'variantA',
|
|
18
|
+
kind: 'variant',
|
|
19
|
+
label: GROUP_LABELS.variantA,
|
|
20
|
+
weight: 50
|
|
21
|
+
}
|
|
22
|
+
], kind);
|
|
23
|
+
}
|
|
24
|
+
/** Keep content slots in step with the experiment kind, including an unset kind. */ export function withSlotsFor(arms, kind) {
|
|
25
|
+
const next = arms.map((arm)=>{
|
|
26
|
+
if (kind === 'block' && arm.kind !== 'holdout') return arm.slot === arm.key ? arm : {
|
|
27
|
+
...arm,
|
|
28
|
+
slot: arm.key
|
|
29
|
+
};
|
|
30
|
+
// Postgres returns null for unset columns; null counts as "no slot" so a loaded doc is not rewritten.
|
|
31
|
+
if (arm.slot == null) return arm;
|
|
32
|
+
const { slot: _slot, ...rest } = arm;
|
|
33
|
+
return rest;
|
|
34
|
+
});
|
|
35
|
+
return next.every((arm, index)=>arm === arms[index]) ? arms : next;
|
|
36
|
+
}
|
|
37
|
+
const finiteWeight = (weight)=>Number.isFinite(weight) ? weight : 0;
|
|
38
|
+
export function totalWeight(arms) {
|
|
39
|
+
return arms.reduce((sum, arm)=>sum + finiteWeight(arm.weight), 0);
|
|
40
|
+
}
|
|
41
|
+
export function addVariant(arms, kind) {
|
|
42
|
+
if (!arms.some((arm)=>arm.kind === 'control')) return arms;
|
|
43
|
+
const key = VARIANTS.find((candidate)=>!arms.some((arm)=>arm.key === candidate));
|
|
44
|
+
if (!key || arms.filter((arm)=>arm.kind === 'variant').length >= 3) return arms;
|
|
45
|
+
const block = kind === 'block' || kind === undefined && arms.some((arm)=>arm.slot !== undefined);
|
|
46
|
+
const variant = {
|
|
47
|
+
key,
|
|
48
|
+
kind: 'variant',
|
|
49
|
+
label: GROUP_LABELS[key],
|
|
50
|
+
weight: 0,
|
|
51
|
+
...block ? {
|
|
52
|
+
slot: key
|
|
53
|
+
} : {}
|
|
54
|
+
};
|
|
55
|
+
const index = arms.findIndex((arm)=>arm.kind === 'holdout');
|
|
56
|
+
const result = [
|
|
57
|
+
...arms
|
|
58
|
+
];
|
|
59
|
+
result.splice(index < 0 ? arms.length : index, 0, variant);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
export function removeArm(arms, key) {
|
|
63
|
+
if (arms.some((arm)=>arm.key === key && arm.kind === 'control')) return arms;
|
|
64
|
+
if (key === 'holdout') return removeHoldout(arms);
|
|
65
|
+
return arms.filter((arm)=>arm.key !== key);
|
|
66
|
+
}
|
|
67
|
+
export function addHoldoutArm(arms) {
|
|
68
|
+
if (!arms.some((arm)=>arm.kind === 'control') || arms.some((arm)=>arm.kind === 'holdout' || arm.key === 'holdout')) return arms;
|
|
69
|
+
return addHoldout(arms.map((arm)=>Number.isFinite(arm.weight) ? arm : {
|
|
70
|
+
...arm,
|
|
71
|
+
weight: 0
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
// Largest remainder, with equal fractional remainders awarded to Original first.
|
|
75
|
+
function distribute(arms, total, proportional) {
|
|
76
|
+
if (!arms.length) return arms;
|
|
77
|
+
const weightTotal = totalWeight(arms);
|
|
78
|
+
const useWeights = proportional && weightTotal > 0;
|
|
79
|
+
const denominator = useWeights ? weightTotal : arms.length;
|
|
80
|
+
const numerators = arms.map((arm)=>(useWeights ? finiteWeight(arm.weight) : 1) * total);
|
|
81
|
+
const weights = numerators.map((numerator)=>Math.floor(numerator / denominator));
|
|
82
|
+
const order = arms.map((_, i)=>i).sort((a, b)=>numerators[b] % denominator - numerators[a] % denominator || Number(arms[b].kind === 'control') - Number(arms[a].kind === 'control') || a - b);
|
|
83
|
+
const remainder = total - weights.reduce((sum, weight)=>sum + weight, 0);
|
|
84
|
+
for(let i = 0; i < remainder; i++)weights[order[i % order.length]]++;
|
|
85
|
+
return arms.map((arm, i)=>({
|
|
86
|
+
...arm,
|
|
87
|
+
weight: weights[i]
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
export function removeHoldout(arms) {
|
|
91
|
+
if (!arms.some((arm)=>arm.kind === 'holdout')) return arms;
|
|
92
|
+
return distribute(arms.filter((arm)=>arm.kind !== 'holdout'), totalWeight(arms), true);
|
|
93
|
+
}
|
|
94
|
+
export function splitEvenly(arms) {
|
|
95
|
+
const holdout = arms.find((arm)=>arm.kind === 'holdout');
|
|
96
|
+
const weight = finiteWeight(holdout?.weight ?? 0);
|
|
97
|
+
return [
|
|
98
|
+
...distribute(arms.filter((arm)=>arm.kind !== 'holdout'), 100 - weight, false),
|
|
99
|
+
...holdout ? [
|
|
100
|
+
{
|
|
101
|
+
...holdout,
|
|
102
|
+
weight
|
|
103
|
+
}
|
|
104
|
+
] : []
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
export function setWeight(arms, key, weight) {
|
|
108
|
+
return arms.map((arm)=>arm.key === key ? {
|
|
109
|
+
...arm,
|
|
110
|
+
weight
|
|
111
|
+
} : arm);
|
|
112
|
+
}
|
|
113
|
+
export function issuesByRow(arms, kind) {
|
|
114
|
+
const rows = arms.map(()=>[]);
|
|
115
|
+
const form = [];
|
|
116
|
+
for (const issue of checkArms(arms, kind)){
|
|
117
|
+
const index = issue.armIndex ?? arms.findIndex((arm)=>arm.key === issue.armKey);
|
|
118
|
+
if (index >= 0 && index < arms.length) rows[index].push(issue);
|
|
119
|
+
else form.push(issue);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
rows,
|
|
123
|
+
form
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export const ARM_FIELDS = [
|
|
127
|
+
'key',
|
|
128
|
+
'label',
|
|
129
|
+
'weight',
|
|
130
|
+
'kind',
|
|
131
|
+
'slot',
|
|
132
|
+
'params',
|
|
133
|
+
'pageTarget'
|
|
134
|
+
];
|
|
135
|
+
/** Payload arrays store each row's values at separate field paths. */ export function readArms(fields, path = 'arms') {
|
|
136
|
+
const indices = new Set();
|
|
137
|
+
for (const name of Object.keys(fields)){
|
|
138
|
+
if (!name.startsWith(`${path}.`)) continue;
|
|
139
|
+
const [index, field] = name.slice(path.length + 1).split('.');
|
|
140
|
+
if (/^\d+$/.test(index) && field === 'key') indices.add(Number(index));
|
|
141
|
+
}
|
|
142
|
+
return [
|
|
143
|
+
...indices
|
|
144
|
+
].sort((a, b)=>a - b).map((index)=>Object.fromEntries(ARM_FIELDS.flatMap((field)=>{
|
|
145
|
+
const value = fields[`${path}.${index}.${field}`]?.value;
|
|
146
|
+
return value === undefined ? [] : [
|
|
147
|
+
[
|
|
148
|
+
field,
|
|
149
|
+
value
|
|
150
|
+
]
|
|
151
|
+
];
|
|
152
|
+
})));
|
|
153
|
+
}
|
|
154
|
+
/** Keep the edited share exact and distribute the remaining whole percentages proportionally. */ export function balanceWeight(arms, key, weight) {
|
|
155
|
+
if (!arms.some((arm)=>arm.key === key) || !Number.isInteger(weight) || weight < 0 || weight > 100) return arms;
|
|
156
|
+
const others = arms.filter((arm)=>arm.key !== key);
|
|
157
|
+
if (!others.length) return arms;
|
|
158
|
+
const total = others.reduce((sum, arm)=>sum + Math.max(0, Number.isFinite(arm.weight) ? arm.weight : 0), 0);
|
|
159
|
+
const denominator = total || others.length;
|
|
160
|
+
const raw = others.map((arm)=>(total ? Math.max(0, Number.isFinite(arm.weight) ? arm.weight : 0) : 1) * (100 - weight) / denominator);
|
|
161
|
+
const whole = raw.map(Math.floor);
|
|
162
|
+
const order = raw.map((_, i)=>i).sort((a, b)=>raw[b] - whole[b] - (raw[a] - whole[a]) || a - b);
|
|
163
|
+
const remainder = 100 - weight - whole.reduce((sum, value)=>sum + value, 0);
|
|
164
|
+
for(let i = 0; i < remainder; i++)whole[order[i]]++;
|
|
165
|
+
const shares = new Map(others.map((arm, i)=>[
|
|
166
|
+
arm.key,
|
|
167
|
+
whole[i]
|
|
168
|
+
]));
|
|
169
|
+
return setWeight(arms, key, weight).map((arm)=>shares.has(arm.key) ? {
|
|
170
|
+
...arm,
|
|
171
|
+
weight: shares.get(arm.key)
|
|
172
|
+
} : arm);
|
|
173
|
+
}
|
package/dist/db/appends.d.ts
CHANGED
|
@@ -19,9 +19,13 @@ type AppendArgs<T> = {
|
|
|
19
19
|
phase: number;
|
|
20
20
|
entry: T;
|
|
21
21
|
};
|
|
22
|
+
/** Raw main-row read on the host transaction, without afterRead expansion.
|
|
23
|
+
* Adapter reads avoid Local API error handling killing the host transaction
|
|
24
|
+
* before the audit savepoint can recover.
|
|
25
|
+
*/
|
|
26
|
+
export declare function storedContent(req: PayloadRequest, collection: string, id: DocId): Promise<Record<string, unknown> | undefined>;
|
|
22
27
|
/** Read the main collection row BEFORE the host write. Draft version retention
|
|
23
|
-
* and unpublishing cannot remove this baseline.
|
|
24
|
-
* error handling killing the host transaction before savepoint recovery.
|
|
28
|
+
* and unpublishing cannot remove this baseline.
|
|
25
29
|
*/
|
|
26
30
|
export declare function previousPublishedContent(req: PayloadRequest, collection: string, id: DocId): Promise<Record<string, unknown> | undefined>;
|
|
27
31
|
/** Serialize flagDefaults read/modify/write before the manifest builder bumps. */
|
package/dist/db/appends.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { COLUMNS, TABLES } from './names.js';
|
|
2
2
|
import { XpRefusal } from '../errors.js';
|
|
3
3
|
import { qualifiedTable, quoteIdentifier as quote } from './sql.js';
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*/ export async function
|
|
4
|
+
/** Raw main-row read on the host transaction, without afterRead expansion.
|
|
5
|
+
* Adapter reads avoid Local API error handling killing the host transaction
|
|
6
|
+
* before the audit savepoint can recover.
|
|
7
|
+
*/ export async function storedContent(req, collection, id) {
|
|
8
8
|
const doc = await req.payload.db.findOne({
|
|
9
9
|
collection,
|
|
10
10
|
req,
|
|
@@ -16,6 +16,12 @@ import { qualifiedTable, quoteIdentifier as quote } from './sql.js';
|
|
|
16
16
|
// Adapter reads have no access layer; document the audit's access posture.
|
|
17
17
|
overrideAccess: false
|
|
18
18
|
});
|
|
19
|
+
return doc ?? undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Read the main collection row BEFORE the host write. Draft version retention
|
|
22
|
+
* and unpublishing cannot remove this baseline.
|
|
23
|
+
*/ export async function previousPublishedContent(req, collection, id) {
|
|
24
|
+
const doc = await storedContent(req, collection, id);
|
|
19
25
|
if (!doc) return undefined;
|
|
20
26
|
// A draft created with content is already in the main table. Only the
|
|
21
27
|
// existence of a published version matters here; never use its content as
|
package/dist/db/locks.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare const LOCK_NAMESPACES: {
|
|
|
4
4
|
readonly layer: "xp:layer";
|
|
5
5
|
readonly flagPoint: "xp:flagpoint";
|
|
6
6
|
readonly experiment: "xp:experiment";
|
|
7
|
+
readonly pagePath: "xp:pagepath";
|
|
7
8
|
};
|
|
8
9
|
/** Transaction executor only. Call in layer → flag point → experiment order;
|
|
9
10
|
* the manifest builder takes the state lock last, after transition writes.
|
|
@@ -13,3 +14,8 @@ export declare function lockTransitionScope(exec: Executor, scope: {
|
|
|
13
14
|
layerId?: DocId | null;
|
|
14
15
|
flagPointId?: string | null;
|
|
15
16
|
}): Promise<void>;
|
|
17
|
+
/** After layer → flag point → experiment, before metric/state locks. Paths must
|
|
18
|
+
* already be normalised with the runtime's rules. Sorted keys avoid deadlocks
|
|
19
|
+
* when different tests share several paths in different arm orders.
|
|
20
|
+
*/
|
|
21
|
+
export declare function lockPagePaths(exec: Executor, paths: readonly string[]): Promise<void>;
|
package/dist/db/locks.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export const LOCK_NAMESPACES = {
|
|
2
2
|
layer: 'xp:layer',
|
|
3
3
|
flagPoint: 'xp:flagpoint',
|
|
4
|
-
experiment: 'xp:experiment'
|
|
4
|
+
experiment: 'xp:experiment',
|
|
5
|
+
pagePath: 'xp:pagepath'
|
|
5
6
|
};
|
|
6
7
|
const LOCK_STATEMENTS = {
|
|
7
8
|
layer: `SELECT pg_advisory_xact_lock(hashtext('${LOCK_NAMESPACES.layer}'), hashtext($1))`,
|
|
8
9
|
flagPoint: `SELECT pg_advisory_xact_lock(hashtext('${LOCK_NAMESPACES.flagPoint}'), hashtext($1))`,
|
|
10
|
+
pagePath: `SELECT pg_advisory_xact_lock(hashtext('${LOCK_NAMESPACES.pagePath}'), hashtext($1))`,
|
|
9
11
|
experiment: `SELECT pg_advisory_xact_lock(hashtext('${LOCK_NAMESPACES.experiment}'), hashtext($1))`
|
|
10
12
|
};
|
|
11
13
|
/** Transaction executor only. Call in layer → flag point → experiment order;
|
|
@@ -21,3 +23,13 @@ const LOCK_STATEMENTS = {
|
|
|
21
23
|
String(scope.experimentId)
|
|
22
24
|
]);
|
|
23
25
|
}
|
|
26
|
+
/** After layer → flag point → experiment, before metric/state locks. Paths must
|
|
27
|
+
* already be normalised with the runtime's rules. Sorted keys avoid deadlocks
|
|
28
|
+
* when different tests share several paths in different arm orders.
|
|
29
|
+
*/ export async function lockPagePaths(exec, paths) {
|
|
30
|
+
for (const path of [
|
|
31
|
+
...new Set(paths)
|
|
32
|
+
].sort())await exec.query(LOCK_STATEMENTS.pagePath, [
|
|
33
|
+
path
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Arm, DocId } from '../types/contracts.js';
|
|
2
|
+
import type { Executor } from './executor.js';
|
|
3
|
+
/** Page tests that occupy paths, with the arms the runtime routes on (`compileManifest`):
|
|
4
|
+
* - running or decided: the latest round's arms, or for a rollout the latest test round's;
|
|
5
|
+
* - archived with a standing result (winner, not discarded, pages not released): the latest test
|
|
6
|
+
* round's arms whose paths are rewritten to the winner (archivedPages), so a new test cannot
|
|
7
|
+
* silently take them over.
|
|
8
|
+
* Documents' arms may have been edited while stopped, so occupancy never reads them. */
|
|
9
|
+
export declare function routedPageTests(exec: Executor): Promise<Array<{
|
|
10
|
+
id: DocId;
|
|
11
|
+
arms: Arm[];
|
|
12
|
+
}>>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { COLUMNS, TABLES } from './names.js';
|
|
2
|
+
import { qualifiedTable, quoteIdentifier as q } from './sql.js';
|
|
3
|
+
/** Page tests that occupy paths, with the arms the runtime routes on (`compileManifest`):
|
|
4
|
+
* - running or decided: the latest round's arms, or for a rollout the latest test round's;
|
|
5
|
+
* - archived with a standing result (winner, not discarded, pages not released): the latest test
|
|
6
|
+
* round's arms whose paths are rewritten to the winner (archivedPages), so a new test cannot
|
|
7
|
+
* silently take them over.
|
|
8
|
+
* Documents' arms may have been edited while stopped, so occupancy never reads them. */ export async function routedPageTests(exec) {
|
|
9
|
+
const e = COLUMNS.experiments, p = COLUMNS.phases;
|
|
10
|
+
const experiments = qualifiedTable(exec, TABLES.experiments), phases = qualifiedTable(exec, TABLES.phases);
|
|
11
|
+
const testRound = `LEFT JOIN LATERAL (SELECT ${q(p.arms)} AS arms FROM ${phases}
|
|
12
|
+
WHERE ${q(p.experimentId)} = x.${q(e.id)} AND ${q(p.phaseKind)} = 'test' ORDER BY ${q(p.phase)} DESC LIMIT 1) test ON true`;
|
|
13
|
+
const { rows } = await exec.query(`
|
|
14
|
+
SELECT x.${q(e.id)} AS "id", CASE WHEN latest.kind = 'rollout' THEN test.arms ELSE latest.arms END AS "arms", NULL::text AS "winnerArm"
|
|
15
|
+
FROM ${experiments} x
|
|
16
|
+
JOIN LATERAL (SELECT ${q(p.phaseKind)} AS kind, ${q(p.arms)} AS arms FROM ${phases}
|
|
17
|
+
WHERE ${q(p.experimentId)} = x.${q(e.id)} ORDER BY ${q(p.phase)} DESC LIMIT 1) latest ON true
|
|
18
|
+
${testRound}
|
|
19
|
+
WHERE x.${q(e.kind)} = 'page' AND x.${q(e.status)} IN ('running', 'decided')
|
|
20
|
+
UNION ALL
|
|
21
|
+
SELECT x.${q(e.id)} AS "id", test.arms AS "arms", x.${q(e.decisionWinnerArm)} AS "winnerArm"
|
|
22
|
+
FROM ${experiments} x
|
|
23
|
+
${testRound}
|
|
24
|
+
WHERE x.${q(e.kind)} = 'page' AND x.${q(e.status)} = 'archived' AND x.${q(e.decisionWinnerArm)} IS NOT NULL
|
|
25
|
+
AND x.${q(e.decisionDiscardedAt)} IS NULL AND x.${q(e.decisionPagesReleasedAt)} IS NULL
|
|
26
|
+
ORDER BY "id"`, []);
|
|
27
|
+
const occupied = [];
|
|
28
|
+
for (const row of rows){
|
|
29
|
+
const arms = Array.isArray(row.arms) ? row.arms : [];
|
|
30
|
+
if (row.winnerArm == null) {
|
|
31
|
+
occupied.push({
|
|
32
|
+
id: row.id,
|
|
33
|
+
arms
|
|
34
|
+
});
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// compileManifest's archivedPages rule: every arm path other than the winner's.
|
|
38
|
+
const winnerPath = arms.find((arm)=>arm.key === row.winnerArm)?.pageTarget;
|
|
39
|
+
if (winnerPath) occupied.push({
|
|
40
|
+
id: row.id,
|
|
41
|
+
arms: arms.filter((arm)=>arm.pageTarget && arm.pageTarget !== winnerPath)
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return occupied;
|
|
45
|
+
}
|
package/dist/db/snapshots.js
CHANGED
|
@@ -32,12 +32,14 @@ const LATE_BEACON_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
|
32
32
|
'arms', p.${q(p.arms)}, 'allocation', p.${q(p.allocation)}, 'metricDefinitions', p.${q(p.metricDefinitions)},
|
|
33
33
|
'analysis', p.${q(p.analysis)}, 'contentChanges', p.${q(p.contentChanges)}) AS "phase",
|
|
34
34
|
e.${q(e.kind)} AS "kind", e.${q(e.phase)} AS "currentPhase", e.${q(e.unitType)} AS "unitType", e.${q(e.status)} AS "status", e.${q(e.decisionWinnerArm)} AS "winnerArm",
|
|
35
|
-
|
|
35
|
+
latest."computedAt" AS "latestComputedAt",
|
|
36
36
|
(SELECT t.${q(p.arms)} FROM ${phases} t WHERE t.${q(p.experimentId)} = p.${q(p.experimentId)} AND t.${q(p.phaseKind)} = 'test' AND t.${q(p.phase)} <= p.${q(p.phase)} ORDER BY t.${q(p.phase)} DESC LIMIT 1) AS "testArms",
|
|
37
37
|
frozen."snapshot" AS "frozenSnapshot",
|
|
38
38
|
l.${q(l.slug)} AS "layerSlug", e.${q(e.layerSlices)} AS "layerSlices"
|
|
39
39
|
FROM ${phases} p JOIN ${qualifiedTable(exec, TABLES.experiments)} e ON e.${q(e.id)} = p.${q(p.experimentId)}
|
|
40
40
|
LEFT JOIN ${qualifiedTable(exec, TABLES.layers)} l ON l.${q(l.id)} = e.${q(e.layerId)}
|
|
41
|
+
LEFT JOIN LATERAL (SELECT max(s.${q(s.computedAt)}) AS "computedAt" FROM ${snapshots} s
|
|
42
|
+
WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}) latest ON true
|
|
41
43
|
LEFT JOIN LATERAL (SELECT s.${q(s.snapshot)} AS "snapshot" FROM ${snapshots} s
|
|
42
44
|
WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}
|
|
43
45
|
AND p.${q(p.endedAt)} IS NOT NULL
|
|
@@ -53,8 +55,7 @@ const LATE_BEACON_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
|
53
55
|
AND done.${q(s.computedAt)} > p.${q(p.endedAt)} + $2 * interval '1 millisecond')))
|
|
54
56
|
${experimentId === undefined ? '' : ` AND p.${q(p.experimentId)} = $3`}
|
|
55
57
|
ORDER BY (frozen."snapshot" IS NOT NULL),
|
|
56
|
-
CASE WHEN frozen."snapshot" IS NULL THEN
|
|
57
|
-
(SELECT max(s.${q(s.computedAt)}) FROM ${snapshots} s WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}) END NULLS FIRST,
|
|
58
|
+
CASE WHEN frozen."snapshot" IS NULL THEN latest."computedAt" END NULLS FIRST,
|
|
58
59
|
CASE WHEN frozen."snapshot" IS NOT NULL THEN random() END,
|
|
59
60
|
p.${q(p.experimentId)}, p.${q(p.phase)}`, params);
|
|
60
61
|
return rows;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { ExperimentsRootView } from '../admin/views/ExperimentsRootView.js';
|
|
2
2
|
export { CollectionListRedirect } from '../admin/views/CollectionListRedirect.js';
|
|
3
3
|
export { CollectionEditRedirect } from '../admin/views/CollectionEditRedirect.js';
|
|
4
|
+
export { TestsNavLinkServer } from '../admin/views/TestsNavLinkServer.js';
|
package/dist/exports/adminRsc.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { ExperimentsRootView } from '../admin/views/ExperimentsRootView.js';
|
|
2
2
|
export { CollectionListRedirect } from '../admin/views/CollectionListRedirect.js';
|
|
3
3
|
export { CollectionEditRedirect } from '../admin/views/CollectionEditRedirect.js';
|
|
4
|
+
export { TestsNavLinkServer } from '../admin/views/TestsNavLinkServer.js';
|
package/dist/exports/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { createExperimentsPlugin } from '../plugin.js';
|
|
|
2
2
|
export { defineFlagPoint, defineMetricSource } from '../define.js';
|
|
3
3
|
export { contentDone } from '../contentDone.js';
|
|
4
4
|
export { createArmSelectField } from '../fields/armSelect.js';
|
|
5
|
-
export type { SerializedFlagPoint, SerializedMetricSource, SerializedAttributeKeys, EditableField, Assignment, Assignments, FlagPointResult, ClientAssignments, RenderedExposure, DocId, Attributes, ExposurePolicy, ExperimentKind, Status, UnitType, ArmKey, ArmKind, TargetingRule, Arm, ParamField, MetricSource, FlagPoint, RateLimiter, ExperimentsAccess, ExperimentsPluginOptions, ArmIssueCode, RefusalCode, ReadinessCode, ErrorEnvelope, TransitionSuccess, AllocateBody, AddMetricBody, DecideBody, ArchiveBody, MakeContentResult, DismissContentEntryBody, DismissContentEntryResult, RestoreContentEntryBody, RestoreContentEntryResult, ContentReport, PreflightResult, PreviewTokenResult, PhaseSummary, ResultsResponse, SummaryResponse, EventBody, FlagDefault, Manifest, ManifestExperiment, ArmSlot, ExperimentRef, ExperimentSlotProps, ExperimentsProxyRuntimeOptions, ExperimentsProxyRuntime, ExperimentsServerRuntimeOptions, ExperimentsServerRuntime, VerdictCode, Snapshot, MetricUnavailableReason, ContentReportReason, } from '../types/index.js';
|
|
5
|
+
export type { SerializedFlagPoint, SerializedMetricSource, SerializedAttributeKeys, EditableField, Assignment, Assignments, FlagPointResult, ClientAssignments, RenderedExposure, DocId, PreviewPlacement, Attributes, ExposurePolicy, ExperimentKind, Status, UnitType, ArmKey, ArmKind, TargetingRule, Arm, ParamField, MetricSource, FlagPoint, RateLimiter, ExperimentsAccess, ExperimentsPluginOptions, ExperimentsPageCollection, ArmIssueCode, RefusalCode, ReadinessCode, ErrorEnvelope, TransitionSuccess, AllocateBody, AddMetricBody, DecideBody, ArchiveBody, MakeContentResult, DismissContentEntryBody, DismissContentEntryResult, RestoreContentEntryBody, RestoreContentEntryResult, ContentReport, PreflightResult, PreviewTokenResult, PhaseSummary, ResultsResponse, SummaryResponse, EventBody, FlagDefault, Manifest, ManifestExperiment, ArmSlot, ExperimentRef, ExperimentSlotProps, ExperimentsProxyRuntimeOptions, ExperimentsProxyRuntime, ExperimentsServerRuntimeOptions, ExperimentsServerRuntime, VerdictCode, Snapshot, MetricUnavailableReason, ContentReportReason, } from '../types/index.js';
|
package/dist/exports/puck.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { ExperimentSlot } from '../puck/ExperimentSlot.js';
|
|
1
|
+
export { ExperimentSlot, createExperimentSlot } from '../puck/ExperimentSlot.js';
|
|
2
2
|
export { createExperimentSlotField } from '../puck/experimentSlotField.js';
|
|
3
3
|
export { EXPERIMENT_SLOT_TYPE } from '../types/puck.js';
|
|
4
4
|
export type { ArmSlot, ExperimentRef, ExperimentSlotProps } from '../types/puck.js';
|
|
5
|
+
export type { PuckRoutes } from '../puck/routes.js';
|
package/dist/exports/puck.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { ExperimentSlot } from '../puck/ExperimentSlot.js';
|
|
1
|
+
export { ExperimentSlot, createExperimentSlot } from '../puck/ExperimentSlot.js';
|
|
2
2
|
export { createExperimentSlotField } from '../puck/experimentSlotField.js';
|
|
3
3
|
export { EXPERIMENT_SLOT_TYPE } from '../types/puck.js';
|
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export { ExperimentSlotEditor } from '../puck/editor/ExperimentSlotEditor.js';
|
|
1
|
+
export { ExperimentSlotEditor, createExperimentSlotEditor } from '../puck/editor/ExperimentSlotEditor.js';
|
|
2
|
+
export { experimentOutlinePlugin } from '../puck/editor/experimentOutlinePlugin.js';
|
|
2
3
|
export { deriveAccent } from '../ui/accent.js';
|
|
4
|
+
export type { PuckRoutes } from '../puck/routes.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
export { ExperimentSlotEditor } from '../puck/editor/ExperimentSlotEditor.js';
|
|
2
|
+
export { ExperimentSlotEditor, createExperimentSlotEditor } from '../puck/editor/ExperimentSlotEditor.js';
|
|
3
|
+
export { experimentOutlinePlugin } from '../puck/editor/experimentOutlinePlugin.js';
|
|
3
4
|
export { deriveAccent } from '../ui/accent.js';
|
package/dist/exports/rsc.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { linkIdentity, type LinkIdentityOutcome } from '../server/linkIdentity.j
|
|
|
6
6
|
export { trackConversion } from '../server/trackConversion.js';
|
|
7
7
|
export { eraseUser } from '../server/eraseUser.js';
|
|
8
8
|
export type { MetricUnavailableReason, ContentReportReason } from '../types/reasons.js';
|
|
9
|
+
export type { PreviewPlacement } from '../types/contracts.js';
|
|
9
10
|
export { runResults } from '../server/runResults.js';
|
|
10
11
|
export { recordContentChange } from '../server/recordContentChange.js';
|
|
11
12
|
export { experimentSitemapFilter } from '../server/sitemap.js';
|
package/dist/hooks/pageGuard.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { previousPublishedContent } from '../db/appends.js';
|
|
2
|
-
import {
|
|
2
|
+
import { lockPagePaths } from '../db/locks.js';
|
|
3
|
+
import { txExecutor } from '../db/executor.js';
|
|
4
|
+
import { normalisePath } from '../runtime/normalisePath.js';
|
|
5
|
+
import { XpNoTransactionError, XpRefusal } from '../errors.js';
|
|
3
6
|
import { pageGuardCopy, READINESS_COPY } from '../admin/copy/readiness.js';
|
|
4
7
|
// createLocalReq replaces context with a shallow copy on nested calls. Keep
|
|
5
8
|
// the shared stack in that context so both the outer req and its proxies see it.
|
|
@@ -28,6 +31,19 @@ export const pageGuardAfterOperation = ({ req, collection, operation, result })=
|
|
|
28
31
|
return result;
|
|
29
32
|
};
|
|
30
33
|
async function protect(req, path) {
|
|
34
|
+
// Serialise with Start (CORE-52 path locks, taken there after the experiment lock): a concurrent Start
|
|
35
|
+
// either commits first, so this check sees it, or waits and then sees this change. Raw SQL on the
|
|
36
|
+
// host's transaction; without one there is nothing to hold the lock for.
|
|
37
|
+
if (await req.transactionID != null) {
|
|
38
|
+
try {
|
|
39
|
+
await lockPagePaths(txExecutor(req), [
|
|
40
|
+
normalisePath(path)
|
|
41
|
+
]);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// A request id without a database session (e.g. a transaction already killed) has no lock to take.
|
|
44
|
+
if (!(error instanceof XpNoTransactionError)) throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
31
47
|
// page guard: the hook must see test placements regardless of the editor's access
|
|
32
48
|
const tests = await req.payload.find({
|
|
33
49
|
collection: 'xp-experiments',
|
|
@@ -81,8 +97,10 @@ async function protect(req, path) {
|
|
|
81
97
|
}, 409);
|
|
82
98
|
const arms = phase.arms;
|
|
83
99
|
const protectedArms = archived ? arms.filter((arm)=>arm.kind === 'control' || arm.key === decision.winnerArm) : arms;
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
const matched = protectedArms.find((arm)=>arm.pageTarget !== undefined && normalisePath(arm.pageTarget) === normalisePath(path));
|
|
101
|
+
if (matched) {
|
|
102
|
+
const role = archived && matched.key !== decision.winnerArm ? 'original' : 'winner';
|
|
103
|
+
throw new XpRefusal('locked-placement', pageGuardCopy(String(test.name), test.status, role), {
|
|
86
104
|
experimentId: test.id
|
|
87
105
|
}, 409);
|
|
88
106
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type DocsResult<T> = {
|
|
2
|
+
ok: true;
|
|
3
|
+
docs: T[];
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
status: number;
|
|
7
|
+
body: unknown;
|
|
8
|
+
reason?: 'page-limit-exceeded';
|
|
9
|
+
};
|
|
10
|
+
/** A successful REST response did not contain a readable document list. */
|
|
11
|
+
export declare class DocsResponseError extends Error {
|
|
12
|
+
}
|
|
13
|
+
/** Read every Payload REST page with the caller's credentials and cancellation. Transport,
|
|
14
|
+
* invalid successful responses and aborts throw; HTTP refusals retain their status/body. Keeps the first document per id; refuses
|
|
15
|
+
* incomplete lists after 1000 pages with page-limit-exceeded. */
|
|
16
|
+
export declare function fetchAllDocs<T>(url: string, options?: Pick<RequestInit, 'credentials' | 'signal'>): Promise<DocsResult<T>>;
|