@delmaredigital/payload-experiments 0.2.0-rc.7 → 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 +202 -26
- package/dist/admin/PreviewLinks.d.ts +2 -1
- package/dist/admin/PreviewLinks.js +50 -21
- package/dist/admin/TestsNavLink.d.ts +3 -1
- package/dist/admin/TestsNavLink.js +2 -2
- package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
- package/dist/admin/actions/ConfirmDialog.js +37 -82
- package/dist/admin/actions/DecideDialog.d.ts +3 -1
- package/dist/admin/actions/DecideDialog.js +30 -13
- package/dist/admin/actions/MakeContentResult.d.ts +13 -2
- package/dist/admin/actions/MakeContentResult.js +115 -16
- package/dist/admin/actions/actions.model.d.ts +2 -4
- package/dist/admin/actions/actions.model.js +2 -3
- package/dist/admin/allocation.model.js +3 -5
- package/dist/admin/api.d.ts +10 -4
- package/dist/admin/api.js +11 -7
- 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/actions.d.ts +39 -3
- package/dist/admin/copy/actions.js +40 -4
- package/dist/admin/copy/armSelect.js +5 -5
- package/dist/admin/copy/arms.d.ts +1 -6
- package/dist/admin/copy/arms.js +1 -16
- package/dist/admin/copy/audience.d.ts +2 -1
- package/dist/admin/copy/audience.js +3 -2
- package/dist/admin/copy/common.d.ts +13 -6
- package/dist/admin/copy/common.js +14 -8
- package/dist/admin/copy/goals.d.ts +21 -6
- package/dist/admin/copy/goals.js +27 -6
- package/dist/admin/copy/readiness.d.ts +22 -0
- package/dist/admin/copy/readiness.js +21 -0
- package/dist/admin/copy/recommendation.d.ts +0 -3
- package/dist/admin/copy/recommendation.js +0 -3
- package/dist/admin/copy/results.d.ts +43 -2
- package/dist/admin/copy/results.js +44 -3
- package/dist/admin/copy/review.d.ts +2 -2
- package/dist/admin/copy/review.js +2 -2
- package/dist/admin/copy/setting.d.ts +2 -0
- package/dist/admin/copy/setting.js +2 -0
- package/dist/admin/copy/surface.d.ts +7 -5
- package/dist/admin/copy/surface.js +9 -5
- package/dist/admin/copy/testPage.d.ts +10 -0
- package/dist/admin/copy/testPage.js +10 -0
- package/dist/admin/format/date.d.ts +2 -0
- package/dist/admin/format/date.js +10 -0
- package/dist/admin/paths.d.ts +2 -1
- package/dist/admin/paths.js +2 -1
- package/dist/admin/previewLinks.model.d.ts +4 -2
- package/dist/admin/previewLinks.model.js +8 -7
- package/dist/admin/results/CiChart.js +11 -21
- package/dist/admin/results/DataQualityPanel.d.ts +4 -2
- package/dist/admin/results/DataQualityPanel.js +30 -17
- package/dist/admin/results/DataTable.d.ts +16 -0
- package/dist/admin/results/DataTable.js +152 -0
- package/dist/admin/results/StatTile.d.ts +7 -0
- package/dist/admin/results/StatTile.js +22 -0
- package/dist/admin/results/VerdictHero.d.ts +13 -0
- package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
- package/dist/admin/results/legacy.d.ts +16 -0
- package/dist/admin/results/legacy.js +27 -0
- package/dist/admin/results/releasePages.model.d.ts +17 -0
- package/dist/admin/results/releasePages.model.js +10 -0
- package/dist/admin/results/results.css +64 -0
- package/dist/admin/results/results.model.d.ts +11 -7
- package/dist/admin/results/results.model.js +24 -7
- package/dist/admin/results/snapshot.d.ts +14 -0
- package/dist/admin/results/snapshot.js +3 -0
- package/dist/admin/surface/ResultsRoute.d.ts +1 -1
- package/dist/admin/surface/ResultsRoute.js +59 -34
- package/dist/admin/surface/SurfaceShell.d.ts +11 -2
- package/dist/admin/surface/SurfaceShell.js +49 -14
- package/dist/admin/surface/TestsHome.d.ts +6 -1
- package/dist/admin/surface/TestsHome.js +182 -114
- package/dist/admin/surface/TestsSurface.js +56 -16
- package/dist/admin/surface/goals/GoalForm.js +341 -308
- package/dist/admin/surface/goals/GoalsList.js +123 -89
- package/dist/admin/surface/goals/data.d.ts +8 -2
- package/dist/admin/surface/goals/data.js +24 -16
- package/dist/admin/surface/goals/goals.css +10 -13
- package/dist/admin/surface/home.model.d.ts +8 -8
- package/dist/admin/surface/home.model.js +23 -12
- package/dist/admin/surface/newTest/NewTest.js +9 -9
- package/dist/admin/surface/pageBlock/parts.js +31 -26
- package/dist/admin/surface/readiness/ActionDialogs.js +47 -19
- package/dist/admin/surface/readiness/Banner.d.ts +3 -0
- package/dist/admin/surface/readiness/Banner.js +58 -33
- package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
- package/dist/admin/surface/readiness/SaveStatus.js +6 -3
- package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
- package/dist/admin/surface/readiness/readiness.model.js +25 -9
- package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
- package/dist/admin/surface/readiness/useTransition.js +2 -1
- package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
- package/dist/admin/surface/results/ReleasePages.js +116 -0
- package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
- package/dist/admin/surface/results/ResultsBody.js +277 -105
- package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
- package/dist/admin/surface/results/ResultsHeader.js +177 -0
- package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
- package/dist/admin/surface/results/recommendation.model.js +12 -9
- package/dist/admin/surface/results/useResults.d.ts +21 -3
- package/dist/admin/surface/results/useResults.js +2 -1
- package/dist/admin/surface/setting/parts.js +75 -88
- package/dist/admin/surface/setting/setting.css +3 -15
- package/dist/admin/surface/surface.css +61 -47
- package/dist/admin/surface/test/AdvancedSection.js +55 -48
- package/dist/admin/surface/test/GoalSection.js +46 -47
- package/dist/admin/surface/test/LengthSection.js +46 -34
- package/dist/admin/surface/test/Rail.d.ts +4 -0
- package/dist/admin/surface/test/Rail.js +83 -0
- package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
- package/dist/admin/surface/test/ReadinessBar.js +33 -0
- package/dist/admin/surface/test/Section.d.ts +1 -1
- package/dist/admin/surface/test/Section.js +36 -16
- package/dist/admin/surface/test/TargetingSection.js +168 -142
- package/dist/admin/surface/test/TestPage.d.ts +2 -1
- package/dist/admin/surface/test/TestPage.js +146 -68
- package/dist/admin/surface/test/VersionsSection.js +151 -120
- package/dist/admin/surface/test/fields.d.ts +11 -4
- package/dist/admin/surface/test/fields.js +84 -85
- package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
- package/dist/admin/surface/test/jumpToSection.js +10 -0
- package/dist/admin/surface/test/pageParts.js +9 -3
- package/dist/admin/surface/test/testPage.css +81 -14
- 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 +12 -5
- package/dist/admin/targeting/targeting.model.d.ts +1 -3
- package/dist/admin/targeting/targeting.model.js +3 -8
- package/dist/admin/theme.css +115 -36
- package/dist/admin/theme.d.ts +38 -0
- package/dist/admin/theme.js +38 -1
- package/dist/admin/useApi.d.ts +17 -2
- package/dist/admin/useLayerUsage.js +14 -23
- package/dist/admin/views/ExperimentsRootView.js +12 -2
- 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/collections/experiments/config.js +25 -0
- package/dist/collections/experiments/lockHook.js +3 -1
- package/dist/collections/identities.js +6 -1
- package/dist/collections/metrics.js +7 -0
- package/dist/collections/metricsGuards.d.ts +9 -0
- package/dist/collections/metricsGuards.js +33 -0
- package/dist/contentDone.d.ts +4 -0
- package/dist/contentDone.js +5 -0
- package/dist/db/appends.d.ts +6 -2
- package/dist/db/appends.js +10 -4
- package/dist/db/identities.d.ts +8 -7
- package/dist/db/identities.js +9 -6
- package/dist/db/locks.d.ts +6 -0
- package/dist/db/locks.js +13 -1
- package/dist/db/metricUsage.d.ts +19 -0
- package/dist/db/metricUsage.js +54 -0
- package/dist/db/names.d.ts +5 -0
- package/dist/db/names.js +5 -0
- package/dist/db/pageOccupancy.d.ts +12 -0
- package/dist/db/pageOccupancy.js +45 -0
- package/dist/db/publishedContent.d.ts +10 -0
- package/dist/db/publishedContent.js +43 -0
- package/dist/db/results.d.ts +2 -0
- package/dist/db/results.js +6 -2
- package/dist/db/snapshots.d.ts +18 -6
- package/dist/db/snapshots.js +25 -8
- package/dist/db/sweep.js +11 -6
- package/dist/endpoints/index.js +19 -1
- package/dist/endpoints/results.js +5 -0
- package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
- package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
- package/dist/endpoints/summary.js +20 -2
- package/dist/endpoints/transitions.d.ts +15 -0
- package/dist/endpoints/transitions.js +36 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +28 -0
- package/dist/exports/adminRsc.d.ts +1 -0
- package/dist/exports/adminRsc.js +1 -0
- package/dist/exports/index.d.ts +2 -1
- package/dist/exports/index.js +1 -0
- package/dist/exports/puck.d.ts +2 -1
- package/dist/exports/puck.js +1 -1
- package/dist/exports/puckEditor.d.ts +4 -1
- package/dist/exports/puckEditor.js +3 -1
- package/dist/exports/rsc.d.ts +6 -1
- package/dist/exports/rsc.js +2 -0
- package/dist/hooks/pageGuard.d.ts +5 -0
- package/dist/hooks/pageGuard.js +139 -0
- package/dist/lib/fetchAllDocs.d.ts +16 -0
- package/dist/lib/fetchAllDocs.js +56 -0
- package/dist/log.js +6 -3
- package/dist/manifest/compile.d.ts +5 -0
- package/dist/manifest/compile.js +5 -1
- package/dist/options.js +16 -0
- package/dist/plugin.js +30 -2
- 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 -5
- package/dist/puck/copy.js +6 -5
- 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 +121 -0
- package/dist/puck/editor/editor.css +288 -204
- 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/results/aggregate.js +4 -3
- package/dist/results/analyse.d.ts +4 -1
- package/dist/results/analyse.js +27 -11
- package/dist/results/cuped.js +3 -2
- package/dist/results/identityCredit.d.ts +15 -7
- package/dist/results/identityCredit.js +32 -33
- package/dist/results/rolloutUnits.js +2 -1
- package/dist/results/snapshot.js +8 -2
- package/dist/results/srm.js +2 -1
- package/dist/sanitizeReadinessDetail.d.ts +7 -0
- package/dist/sanitizeReadinessDetail.js +63 -0
- package/dist/server/linkIdentity.d.ts +10 -1
- package/dist/server/linkIdentity.js +17 -15
- package/dist/server/metadata.d.ts +5 -0
- package/dist/server/metadata.js +18 -3
- 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.d.ts +3 -2
- package/dist/server/runResults.js +157 -35
- package/dist/targeting/values.d.ts +4 -0
- package/dist/targeting/values.js +10 -0
- package/dist/transitions/addMetric.js +8 -0
- package/dist/transitions/archive.js +2 -3
- package/dist/transitions/checkStart.d.ts +10 -3
- package/dist/transitions/checkStart.js +25 -0
- package/dist/transitions/contentFields.d.ts +7 -0
- package/dist/transitions/contentFields.js +20 -0
- package/dist/transitions/decide.js +22 -2
- package/dist/transitions/dismissContentEntry.d.ts +8 -0
- package/dist/transitions/dismissContentEntry.js +45 -0
- package/dist/transitions/makeContent.d.ts +5 -2
- package/dist/transitions/makeContent.js +217 -118
- package/dist/transitions/phaseRecord.d.ts +8 -2
- package/dist/transitions/placements.d.ts +3 -6
- package/dist/transitions/placements.js +12 -1
- package/dist/transitions/preflightData.d.ts +9 -1
- package/dist/transitions/preflightData.js +138 -20
- package/dist/transitions/releasePages.d.ts +8 -0
- package/dist/transitions/releasePages.js +26 -0
- package/dist/transitions/restoreContentEntry.d.ts +8 -0
- package/dist/transitions/restoreContentEntry.js +47 -0
- package/dist/transitions/start.js +62 -7
- package/dist/types/contracts.d.ts +24 -4
- package/dist/types/endpoints.d.ts +34 -3
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/reasons.d.ts +3 -0
- package/dist/types/reasons.js +1 -0
- package/dist/types/snapshot.d.ts +6 -1
- package/dist/ui/Banner.d.ts +2 -0
- package/dist/ui/Banner.js +2 -0
- package/dist/ui/Breadcrumb.d.ts +7 -0
- package/dist/ui/Breadcrumb.js +25 -0
- package/dist/ui/Button.d.ts +15 -0
- package/dist/ui/Button.js +32 -0
- package/dist/ui/Card.d.ts +12 -0
- package/dist/ui/Card.js +40 -0
- package/dist/ui/Checkbox.d.ts +2 -0
- package/dist/ui/Checkbox.js +2 -0
- package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
- package/dist/{admin/components → ui}/ChipsInput.js +20 -12
- package/dist/ui/Dialog.d.ts +13 -0
- package/dist/ui/Dialog.js +85 -0
- package/dist/ui/EmptyState.d.ts +2 -0
- package/dist/ui/EmptyState.js +2 -0
- package/dist/ui/Feedback.d.ts +24 -0
- package/dist/ui/Feedback.js +40 -0
- package/dist/ui/Inputs.d.ts +34 -0
- package/dist/ui/Inputs.js +225 -0
- package/dist/ui/NumberInput.d.ts +2 -0
- package/dist/ui/NumberInput.js +2 -0
- package/dist/ui/Range.d.ts +2 -0
- package/dist/ui/Range.js +2 -0
- package/dist/ui/SearchInput.d.ts +2 -0
- package/dist/ui/SearchInput.js +2 -0
- package/dist/ui/SectionCard.d.ts +2 -0
- package/dist/ui/SectionCard.js +2 -0
- package/dist/ui/Select.d.ts +2 -0
- package/dist/ui/Select.js +2 -0
- package/dist/ui/StatusChip.d.ts +2 -0
- package/dist/ui/StatusChip.js +2 -0
- package/dist/ui/Tabs.d.ts +16 -0
- package/dist/ui/Tabs.js +61 -0
- package/dist/ui/TextInput.d.ts +2 -0
- package/dist/ui/TextInput.js +2 -0
- package/dist/ui/Textarea.d.ts +2 -0
- package/dist/ui/Textarea.js +2 -0
- package/dist/ui/Toggle.d.ts +2 -0
- package/dist/ui/Toggle.js +2 -0
- package/dist/ui/accent.d.ts +40 -0
- package/dist/ui/accent.js +139 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.js +9 -0
- package/dist/ui/ui.css +92 -0
- package/package.json +5 -2
- package/dist/admin/components/chipsInput.css +0 -11
- package/dist/admin/results/ArmTable.d.ts +0 -8
- package/dist/admin/results/ArmTable.js +0 -113
- package/dist/admin/results/VerdictCard.d.ts +0 -9
- package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
- package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
- package/dist/admin/surface/test/TestHeader.d.ts +0 -4
- package/dist/admin/surface/test/TestHeader.js +0 -70
|
@@ -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
|
+
}
|
|
@@ -2,12 +2,14 @@ import * as paths from '../../admin/paths.js';
|
|
|
2
2
|
import { managedAccess } from '../access.js';
|
|
3
3
|
import { serializeAttributeKeys, serializeFlagPoints } from '../serialize.js';
|
|
4
4
|
import { TARGETING_OPS, isOpValid } from '../../targeting/ops.js';
|
|
5
|
+
import { readListValue } from '../../targeting/values.js';
|
|
5
6
|
import { createDefaults } from './createDefaults.js';
|
|
6
7
|
import { flagControlParams } from './flagControlParams.js';
|
|
7
8
|
import { lockHook } from './lockHook.js';
|
|
8
9
|
import { deleteGuard, deleteGuardAfter } from './deleteGuard.js';
|
|
9
10
|
import { checkArms } from './armInvariants.js';
|
|
10
11
|
import { XpNotImplemented, XpRefusal } from '../../errors.js';
|
|
12
|
+
const targetingValue = (rule)=>rule.op === 'in' || rule.op === 'nin' ? readListValue(rule.value) : rule.value;
|
|
11
13
|
const positiveInteger = (value)=>value != null && Number.isInteger(value) && value > 0 || 'Enter a positive whole number.';
|
|
12
14
|
export function experimentsCollection(options) {
|
|
13
15
|
const flagPoints = serializeFlagPoints(options);
|
|
@@ -224,6 +226,21 @@ export function experimentsCollection(options) {
|
|
|
224
226
|
{
|
|
225
227
|
name: 'targeting',
|
|
226
228
|
type: 'array',
|
|
229
|
+
validate: (value, { data, previousValue })=>{
|
|
230
|
+
// Payload 3.90.2 beforeChange/promise.js passes merged document data
|
|
231
|
+
// and this document's stored array as previousValue, including in bulk updates.
|
|
232
|
+
const status = data?.status ?? 'draft';
|
|
233
|
+
if (data?.kind === 'block' && (status === 'draft' || status === 'stopped')) {
|
|
234
|
+
for (const rule of value ?? []){
|
|
235
|
+
if (rule.attribute !== 'path') continue;
|
|
236
|
+
const previous = rule.id == null ? undefined : previousValue?.find((stored)=>stored.id != null && String(stored.id) === String(rule.id));
|
|
237
|
+
if (!previous || previous.attribute !== rule.attribute || previous.op !== rule.op || JSON.stringify(targetingValue(previous)) !== JSON.stringify(targetingValue(rule))) {
|
|
238
|
+
return 'Page block tests run on their page; remove the page-path rule.';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
},
|
|
227
244
|
fields: [
|
|
228
245
|
{
|
|
229
246
|
name: 'attribute',
|
|
@@ -477,6 +494,14 @@ export function experimentsCollection(options) {
|
|
|
477
494
|
{
|
|
478
495
|
name: 'holdoutReleasedAt',
|
|
479
496
|
type: 'date'
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: 'pagesReleasedAt',
|
|
500
|
+
type: 'date'
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
name: 'pagesReleasedBy',
|
|
504
|
+
type: 'text'
|
|
480
505
|
}
|
|
481
506
|
]
|
|
482
507
|
}
|
|
@@ -12,6 +12,7 @@ const relationships = new Set([
|
|
|
12
12
|
'metrics.secondary',
|
|
13
13
|
'metrics.guardrails',
|
|
14
14
|
'decision.decidedBy',
|
|
15
|
+
'decision.pagesReleasedBy',
|
|
15
16
|
'decision.snapshotId'
|
|
16
17
|
]);
|
|
17
18
|
const dates = new Set([
|
|
@@ -20,7 +21,8 @@ const dates = new Set([
|
|
|
20
21
|
'decidedAt',
|
|
21
22
|
'archivedAt',
|
|
22
23
|
'decision.discardedAt',
|
|
23
|
-
'decision.holdoutReleasedAt'
|
|
24
|
+
'decision.holdoutReleasedAt',
|
|
25
|
+
'decision.pagesReleasedAt'
|
|
24
26
|
]);
|
|
25
27
|
/** Schema-aware: JSON retains its own ids and date-like strings. */ function normalize(value, path, json = false) {
|
|
26
28
|
json ||= [
|
|
@@ -19,12 +19,17 @@ export function identitiesCollection(options) {
|
|
|
19
19
|
{
|
|
20
20
|
name: 'userId',
|
|
21
21
|
type: 'text',
|
|
22
|
-
required: true
|
|
22
|
+
required: true,
|
|
23
|
+
index: true
|
|
23
24
|
},
|
|
24
25
|
{
|
|
25
26
|
name: 'linkedAt',
|
|
26
27
|
type: 'date',
|
|
27
28
|
required: true
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'verifiedAt',
|
|
32
|
+
type: 'date'
|
|
28
33
|
}
|
|
29
34
|
]
|
|
30
35
|
};
|
|
@@ -2,6 +2,7 @@ import { XP_METRIC_SOURCE_SELECT } from '../admin/paths.js';
|
|
|
2
2
|
import { managedAccess } from './access.js';
|
|
3
3
|
import { serializeMetricSources } from './serialize.js';
|
|
4
4
|
import { validateCappingType, validateCappingValue } from './metricValidation.js';
|
|
5
|
+
import { metricChangeGuard, metricDeleteGuard } from './metricsGuards.js';
|
|
5
6
|
export function metricsCollection(options) {
|
|
6
7
|
const sources = serializeMetricSources(options);
|
|
7
8
|
return {
|
|
@@ -20,6 +21,12 @@ export function metricsCollection(options) {
|
|
|
20
21
|
},
|
|
21
22
|
access: managedAccess(options),
|
|
22
23
|
hooks: {
|
|
24
|
+
beforeDelete: [
|
|
25
|
+
metricDeleteGuard
|
|
26
|
+
],
|
|
27
|
+
beforeChange: [
|
|
28
|
+
metricChangeGuard
|
|
29
|
+
],
|
|
23
30
|
beforeValidate: [
|
|
24
31
|
({ data, originalDoc })=>{
|
|
25
32
|
if (!data) return data;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CollectionBeforeChangeHook, CollectionBeforeDeleteHook } from 'payload';
|
|
2
|
+
/** Payload's REST formatter preserves public plugin errors and their 4xx status. */
|
|
3
|
+
export declare class XpGoalInUseError extends Error {
|
|
4
|
+
name: string;
|
|
5
|
+
readonly status = 400;
|
|
6
|
+
readonly isPublic = true;
|
|
7
|
+
}
|
|
8
|
+
export declare const metricDeleteGuard: CollectionBeforeDeleteHook;
|
|
9
|
+
export declare const metricChangeGuard: CollectionBeforeChangeHook;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { pooledExecutor, txExecutor } from '../db/executor.js';
|
|
2
|
+
import { findMetricUsage, lockMetricRow } from '../db/metricUsage.js';
|
|
3
|
+
/** Payload's REST formatter preserves public plugin errors and their 4xx status. */ export class XpGoalInUseError extends Error {
|
|
4
|
+
name = 'XpGoalInUseError';
|
|
5
|
+
status = 400;
|
|
6
|
+
isPublic = true;
|
|
7
|
+
}
|
|
8
|
+
async function executor(req, id, mode) {
|
|
9
|
+
const transactionID = await req.transactionID;
|
|
10
|
+
if (transactionID != null) {
|
|
11
|
+
const exec = txExecutor(req);
|
|
12
|
+
await lockMetricRow(exec, id, mode);
|
|
13
|
+
return exec;
|
|
14
|
+
}
|
|
15
|
+
// With no transaction a pooled row lock would expire before the usage read;
|
|
16
|
+
// retain the existing best-effort usage check without taking a lock.
|
|
17
|
+
return pooledExecutor(req.payload);
|
|
18
|
+
}
|
|
19
|
+
export const metricDeleteGuard = async ({ req, id })=>{
|
|
20
|
+
const usage = await findMetricUsage(await executor(req, id, 'delete'), id);
|
|
21
|
+
if (usage) throw new XpGoalInUseError(`This goal is used by the test "${usage.name}". Remove it from the test first.`);
|
|
22
|
+
};
|
|
23
|
+
export const metricChangeGuard = async ({ req, data, originalDoc, operation })=>{
|
|
24
|
+
if (operation !== 'update') return data;
|
|
25
|
+
const changed = [
|
|
26
|
+
'slug',
|
|
27
|
+
'source'
|
|
28
|
+
].some((field)=>field in data && data[field] !== originalDoc[field]);
|
|
29
|
+
if (changed && await findMetricUsage(await executor(req, originalDoc.id, 'change'), originalDoc.id)) {
|
|
30
|
+
throw new XpGoalInUseError("This goal is used by a test, so its short name and what it measures can't change. Create a new goal instead.");
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ContentReport } from './types/endpoints.js';
|
|
2
|
+
/** Every recorded content entry is updated or handled by the manager. A present but empty report is done:
|
|
3
|
+
* page tests and flag tests without a host callback record no entries. An absent report is not done. */
|
|
4
|
+
export declare function contentDone(report: ContentReport | null | undefined): boolean;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Every recorded content entry is updated or handled by the manager. A present but empty report is done:
|
|
2
|
+
* page tests and flag tests without a host callback record no entries. An absent report is not done. */ export function contentDone(report) {
|
|
3
|
+
const docs = Array.isArray(report) ? report : report?.docs;
|
|
4
|
+
return Array.isArray(docs) && docs.every((doc)=>doc?.ok === true || Boolean(doc?.dismissedAt));
|
|
5
|
+
}
|
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/identities.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Payload } from 'payload';
|
|
2
2
|
import { type Executor } from './executor.js';
|
|
3
|
-
export
|
|
3
|
+
export type IdentityWriteOutcome = 'linked' | 'verified' | 'skipped';
|
|
4
|
+
type IdentityInput = {
|
|
4
5
|
anonymousId: string;
|
|
5
6
|
userId: string;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export declare function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
7
|
+
verified: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function insertIdentity(exec: Executor, { anonymousId, userId, verified }: IdentityInput): Promise<IdentityWriteOutcome>;
|
|
10
|
+
/** §5.6 / §7.8: privileged insert/upgrade, isolated from the caller's sign-in transaction. */
|
|
11
|
+
export declare function insertIdentityOnOwnConnection(payload: Payload, identity: IdentityInput): Promise<IdentityWriteOutcome>;
|
|
12
|
+
export {};
|
package/dist/db/identities.js
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
import { ownConnection } from './executor.js';
|
|
2
2
|
import { COLUMNS, TABLES } from './names.js';
|
|
3
3
|
const quote = (name)=>`"${name.replaceAll('"', '""')}"`;
|
|
4
|
-
export async function insertIdentity(exec, { anonymousId, userId }) {
|
|
4
|
+
export async function insertIdentity(exec, { anonymousId, userId, verified }) {
|
|
5
5
|
const schema = exec.schemaName;
|
|
6
6
|
const table = `${schema ? `${quote(schema)}.` : ''}${quote(TABLES.identities)}`;
|
|
7
7
|
const c = COLUMNS.identities;
|
|
8
|
-
await exec.query(`INSERT INTO ${table} (${[
|
|
8
|
+
const { rows } = await exec.query(`INSERT INTO ${table} AS identity (${[
|
|
9
9
|
c.anonymousId,
|
|
10
10
|
c.userId,
|
|
11
11
|
c.linkedAt,
|
|
12
|
+
c.verifiedAt,
|
|
12
13
|
c.createdAt,
|
|
13
14
|
c.updatedAt
|
|
14
|
-
].map(quote).join(', ')}) VALUES ($1, $2, now(), now(), now()) ON CONFLICT (${quote(c.anonymousId)}) DO
|
|
15
|
+
].map(quote).join(', ')}) VALUES ($1, $2, now(), CASE WHEN $3 THEN now() END, now(), now()) ON CONFLICT (${quote(c.anonymousId)}) DO UPDATE SET ${quote(c.verifiedAt)} = now(), ${quote(c.updatedAt)} = now() WHERE identity.${quote(c.userId)} = EXCLUDED.${quote(c.userId)} AND identity.${quote(c.verifiedAt)} IS NULL AND $3 RETURNING (xmax = 0) AS inserted`, [
|
|
15
16
|
anonymousId,
|
|
16
|
-
userId
|
|
17
|
+
userId,
|
|
18
|
+
verified
|
|
17
19
|
]);
|
|
20
|
+
return rows.length === 0 ? 'skipped' : rows[0].inserted ? 'linked' : 'verified';
|
|
18
21
|
}
|
|
19
|
-
/** §5.6 / §7.8: privileged insert, isolated from the caller's sign-in transaction. */ export async function insertIdentityOnOwnConnection(payload, identity) {
|
|
22
|
+
/** §5.6 / §7.8: privileged insert/upgrade, isolated from the caller's sign-in transaction. */ export async function insertIdentityOnOwnConnection(payload, identity) {
|
|
20
23
|
const db = payload.db;
|
|
21
24
|
const exec = await ownConnection(db.pool);
|
|
22
25
|
try {
|
|
23
|
-
await insertIdentity(Object.assign(exec, {
|
|
26
|
+
return await insertIdentity(Object.assign(exec, {
|
|
24
27
|
schemaName: db.schemaName
|
|
25
28
|
}), identity);
|
|
26
29
|
} finally{
|
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,19 @@
|
|
|
1
|
+
import type { Executor } from './executor.js';
|
|
2
|
+
import type { DocId } from '../types/contracts.js';
|
|
3
|
+
/** Request transaction only: serialize the guard with FK checks and freezes. */
|
|
4
|
+
export declare function lockMetricRow(exec: Executor, id: DocId, mode: 'delete' | 'change'): Promise<void>;
|
|
5
|
+
/** Transition transaction only, after advisory locks. SQL orders by the native
|
|
6
|
+
* id type so overlapping freezes always acquire row locks in the same order.
|
|
7
|
+
* Return the locked identities so callers can reject a change that committed
|
|
8
|
+
* after preflight. Missing rows mean a goal was deleted; never freeze that copy.
|
|
9
|
+
* txExecutor throws if the request no longer has a transaction session.
|
|
10
|
+
*/
|
|
11
|
+
export declare function lockMetricsForFreeze(exec: Executor, ids: DocId[]): Promise<Array<{
|
|
12
|
+
id: DocId;
|
|
13
|
+
slug: string;
|
|
14
|
+
source: string;
|
|
15
|
+
}> | null>;
|
|
16
|
+
/** Live selections cover drafts; frozen definitions keep historical goals in use. */
|
|
17
|
+
export declare function findMetricUsage(exec: Executor, id: DocId): Promise<{
|
|
18
|
+
name: string;
|
|
19
|
+
} | null>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { COLUMNS, TABLES } from './names.js';
|
|
2
|
+
import { qualifiedTable, quoteIdentifier as q } from './sql.js';
|
|
3
|
+
/** Request transaction only: serialize the guard with FK checks and freezes. */ export async function lockMetricRow(exec, id, mode) {
|
|
4
|
+
const lock = mode === 'delete' ? 'FOR UPDATE' : 'FOR NO KEY UPDATE';
|
|
5
|
+
await exec.query(`SELECT 1 FROM ${qualifiedTable(exec, TABLES.metrics)} WHERE ${q(COLUMNS.metrics.id)} = $1 ${lock}`, [
|
|
6
|
+
id
|
|
7
|
+
]);
|
|
8
|
+
}
|
|
9
|
+
/** Transition transaction only, after advisory locks. SQL orders by the native
|
|
10
|
+
* id type so overlapping freezes always acquire row locks in the same order.
|
|
11
|
+
* Return the locked identities so callers can reject a change that committed
|
|
12
|
+
* after preflight. Missing rows mean a goal was deleted; never freeze that copy.
|
|
13
|
+
* txExecutor throws if the request no longer has a transaction session.
|
|
14
|
+
*/ export async function lockMetricsForFreeze(exec, ids) {
|
|
15
|
+
// Deduplicate for the row-count check; SQL ORDER BY sets the lock order.
|
|
16
|
+
const sorted = [
|
|
17
|
+
...new Map(ids.map((id)=>[
|
|
18
|
+
String(id),
|
|
19
|
+
id
|
|
20
|
+
])).values()
|
|
21
|
+
].sort((a, b)=>typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)));
|
|
22
|
+
const m = COLUMNS.metrics, id = q(m.id);
|
|
23
|
+
const result = await exec.query(`SELECT ${id}, ${q(m.slug)}, ${q(m.source)} FROM ${qualifiedTable(exec, TABLES.metrics)} WHERE ${id} = ANY($1) ORDER BY ${id} FOR SHARE`, [
|
|
24
|
+
sorted
|
|
25
|
+
]);
|
|
26
|
+
return result.rows.length === sorted.length ? result.rows : null;
|
|
27
|
+
}
|
|
28
|
+
/** Live selections cover drafts; frozen definitions keep historical goals in use. */ export async function findMetricUsage(exec, id) {
|
|
29
|
+
const e = COLUMNS.experiments, p = COLUMNS.phases, r = COLUMNS.experimentsRels;
|
|
30
|
+
const result = await exec.query(`
|
|
31
|
+
SELECT COALESCE(usage.name, 'Deleted test') AS name FROM (
|
|
32
|
+
SELECT e.${q(e.name)} AS name, e.${q(e.status)} AS status
|
|
33
|
+
FROM ${qualifiedTable(exec, TABLES.phases)} p
|
|
34
|
+
LEFT JOIN ${qualifiedTable(exec, TABLES.experiments)} e ON e.${q(e.id)} = p.${q(p.experimentId)}
|
|
35
|
+
WHERE jsonb_typeof(p.${q(p.metricDefinitions)}) = 'array' AND EXISTS (
|
|
36
|
+
SELECT 1 FROM jsonb_array_elements(p.${q(p.metricDefinitions)}) definition
|
|
37
|
+
WHERE definition->>'metricId' = $1::text
|
|
38
|
+
)
|
|
39
|
+
UNION ALL
|
|
40
|
+
SELECT e.${q(e.name)} AS name, e.${q(e.status)} AS status
|
|
41
|
+
FROM ${qualifiedTable(exec, TABLES.experiments)} e
|
|
42
|
+
WHERE e.${q(e.metricsPrimaryId)}::text = $1::text OR EXISTS (
|
|
43
|
+
SELECT 1 FROM ${qualifiedTable(exec, TABLES.experimentsRels)} r
|
|
44
|
+
WHERE r.${q(r.parentId)} = e.${q(e.id)}
|
|
45
|
+
AND r.${q(r.path)} IN ('metrics.secondary', 'metrics.guardrails')
|
|
46
|
+
AND r.${q(r.xpMetricsId)}::text = $1::text
|
|
47
|
+
)
|
|
48
|
+
) usage
|
|
49
|
+
ORDER BY CASE WHEN usage.status IN ('running', 'decided') THEN 0 ELSE 1 END, usage.name
|
|
50
|
+
LIMIT 1`, [
|
|
51
|
+
String(id)
|
|
52
|
+
]);
|
|
53
|
+
return result.rows[0] ?? null;
|
|
54
|
+
}
|
package/dist/db/names.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export declare const COLUMNS: {
|
|
|
83
83
|
readonly decisionDiscardedAt: "decision_discarded_at";
|
|
84
84
|
readonly decisionContentReport: "decision_content_report";
|
|
85
85
|
readonly decisionHoldoutReleasedAt: "decision_holdout_released_at";
|
|
86
|
+
readonly decisionPagesReleasedAt: "decision_pages_released_at";
|
|
87
|
+
readonly decisionPagesReleasedBy: "decision_pages_released_by";
|
|
86
88
|
readonly updatedAt: "updated_at";
|
|
87
89
|
readonly createdAt: "created_at";
|
|
88
90
|
};
|
|
@@ -151,6 +153,8 @@ export declare const COLUMNS: {
|
|
|
151
153
|
readonly versionDecisionDiscardedAt: "version_decision_discarded_at";
|
|
152
154
|
readonly versionDecisionContentReport: "version_decision_content_report";
|
|
153
155
|
readonly versionDecisionHoldoutReleasedAt: "version_decision_holdout_released_at";
|
|
156
|
+
readonly versionDecisionPagesReleasedAt: "version_decision_pages_released_at";
|
|
157
|
+
readonly versionDecisionPagesReleasedBy: "version_decision_pages_released_by";
|
|
154
158
|
readonly versionUpdatedAt: "version_updated_at";
|
|
155
159
|
readonly versionCreatedAt: "version_created_at";
|
|
156
160
|
readonly createdAt: "created_at";
|
|
@@ -227,6 +231,7 @@ export declare const COLUMNS: {
|
|
|
227
231
|
readonly anonymousId: "anonymous_id";
|
|
228
232
|
readonly userId: "user_id";
|
|
229
233
|
readonly linkedAt: "linked_at";
|
|
234
|
+
readonly verifiedAt: "verified_at";
|
|
230
235
|
readonly updatedAt: "updated_at";
|
|
231
236
|
readonly createdAt: "created_at";
|
|
232
237
|
};
|
package/dist/db/names.js
CHANGED
|
@@ -82,6 +82,8 @@ export const COLUMNS = {
|
|
|
82
82
|
decisionDiscardedAt: 'decision_discarded_at',
|
|
83
83
|
decisionContentReport: 'decision_content_report',
|
|
84
84
|
decisionHoldoutReleasedAt: 'decision_holdout_released_at',
|
|
85
|
+
decisionPagesReleasedAt: 'decision_pages_released_at',
|
|
86
|
+
decisionPagesReleasedBy: 'decision_pages_released_by',
|
|
85
87
|
updatedAt: 'updated_at',
|
|
86
88
|
createdAt: 'created_at'
|
|
87
89
|
},
|
|
@@ -150,6 +152,8 @@ export const COLUMNS = {
|
|
|
150
152
|
versionDecisionDiscardedAt: 'version_decision_discarded_at',
|
|
151
153
|
versionDecisionContentReport: 'version_decision_content_report',
|
|
152
154
|
versionDecisionHoldoutReleasedAt: 'version_decision_holdout_released_at',
|
|
155
|
+
versionDecisionPagesReleasedAt: 'version_decision_pages_released_at',
|
|
156
|
+
versionDecisionPagesReleasedBy: 'version_decision_pages_released_by',
|
|
153
157
|
versionUpdatedAt: 'version_updated_at',
|
|
154
158
|
versionCreatedAt: 'version_created_at',
|
|
155
159
|
createdAt: 'created_at',
|
|
@@ -226,6 +230,7 @@ export const COLUMNS = {
|
|
|
226
230
|
anonymousId: 'anonymous_id',
|
|
227
231
|
userId: 'user_id',
|
|
228
232
|
linkedAt: 'linked_at',
|
|
233
|
+
verifiedAt: 'verified_at',
|
|
229
234
|
updatedAt: 'updated_at',
|
|
230
235
|
createdAt: 'created_at'
|
|
231
236
|
},
|
|
@@ -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
|
+
}>>;
|