@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
|
@@ -2,33 +2,35 @@ import { safeError } from '../log.js';
|
|
|
2
2
|
import { NextRequest } from 'next/server.js';
|
|
3
3
|
import { insertIdentityOnOwnConnection } from '../db/identities.js';
|
|
4
4
|
import { readUnitCookie } from '../runtime/cookie.js';
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* verified: true means this request proves, in this browser, that the person owns
|
|
7
|
+
* the account: a magic-link or one-time-code callback, password sign-in, or OAuth
|
|
8
|
+
* callback. It does not mean "a session exists" or "the email is verified".
|
|
9
|
+
* Never throws into registration or sign-in.
|
|
10
|
+
*/ export async function linkIdentity(serverRuntime, opts) {
|
|
6
11
|
let payload;
|
|
7
12
|
try {
|
|
8
13
|
const runtime = serverRuntime.proxyRuntime;
|
|
9
14
|
// Consent sees cookies and headers only; the URL is a placeholder.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
})) !== false;
|
|
15
|
-
} catch {
|
|
16
|
-
console.error('[payload-experiments] consent failed; treating consent as false');
|
|
17
|
-
consent = false;
|
|
18
|
-
}
|
|
19
|
-
if (!consent) return;
|
|
15
|
+
const consent = runtime.cookie.consent?.(new NextRequest('http://consent.invalid/', {
|
|
16
|
+
headers: opts.headers
|
|
17
|
+
})) !== false;
|
|
18
|
+
if (!consent) return 'skipped';
|
|
20
19
|
const anonymousId = readUnitCookie(opts.headers, runtime);
|
|
21
|
-
if (!anonymousId) return;
|
|
20
|
+
if (!anonymousId) return 'skipped';
|
|
22
21
|
payload = await serverRuntime.getPayload();
|
|
23
|
-
await insertIdentityOnOwnConnection(payload, {
|
|
22
|
+
return await insertIdentityOnOwnConnection(payload, {
|
|
24
23
|
anonymousId,
|
|
25
|
-
userId: opts.userId
|
|
24
|
+
userId: opts.userId,
|
|
25
|
+
verified: opts.verified
|
|
26
26
|
});
|
|
27
27
|
} catch (error) {
|
|
28
28
|
try {
|
|
29
|
-
payload
|
|
29
|
+
if (payload) payload.logger.error({
|
|
30
30
|
err: safeError(error)
|
|
31
31
|
}, 'Experiments: linkIdentity failed');
|
|
32
|
+
else console.error('[payload-experiments] linkIdentity failed', safeError(error));
|
|
32
33
|
} catch {}
|
|
34
|
+
return 'failed';
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import type { ExperimentsServerRuntime } from '../types/runtime.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reads the signed request context via `headers()`, so it decides from the URL the visitor requested, not the
|
|
5
|
+
* rewritten route. Call it only from a dynamic `generateMetadata`, never inside `'use cache'`. `path` is the
|
|
6
|
+
* fallback when the proxy did not run.
|
|
7
|
+
*/
|
|
3
8
|
export declare function experimentMetadata<TUser extends {
|
|
4
9
|
id: string | number;
|
|
5
10
|
} = {
|
package/dist/server/metadata.js
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
import { findNormalisedPathKey, normalisePath } from '../runtime/normalisePath.js';
|
|
2
|
+
import { headers } from 'next/headers.js';
|
|
3
|
+
import { serverRequest } from './request.js';
|
|
2
4
|
import { serverManifest } from './context.js';
|
|
3
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Reads the signed request context via `headers()`, so it decides from the URL the visitor requested, not the
|
|
7
|
+
* rewritten route. Call it only from a dynamic `generateMetadata`, never inside `'use cache'`. `path` is the
|
|
8
|
+
* fallback when the proxy did not run.
|
|
9
|
+
*/ export async function experimentMetadata(serverRuntime, path) {
|
|
10
|
+
const { request, hasContext } = serverRequest(new Headers(await headers()), serverRuntime.proxyRuntime.secrets);
|
|
11
|
+
if (hasContext) path = request.nextUrl.pathname;
|
|
4
12
|
const manifest = await serverManifest(serverRuntime.proxyRuntime);
|
|
5
13
|
const live = manifest?.experiments.find((e)=>e.kind === 'page' && e.arms.some((a)=>a.pageTarget !== undefined && normalisePath(a.pageTarget) === normalisePath(path)));
|
|
6
14
|
if (live) {
|
|
7
15
|
const controlPath = live.arms.find((a)=>a.kind === 'control')?.pageTarget;
|
|
8
|
-
|
|
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) ? {
|
|
9
20
|
robots: {
|
|
10
21
|
index: false
|
|
11
22
|
},
|
|
12
23
|
alternates: {
|
|
13
24
|
canonical: controlPath
|
|
14
25
|
}
|
|
15
|
-
} : {
|
|
26
|
+
} : {
|
|
27
|
+
alternates: {
|
|
28
|
+
canonical: controlPath
|
|
29
|
+
}
|
|
30
|
+
};
|
|
16
31
|
}
|
|
17
32
|
const archivedPath = manifest ? findNormalisedPathKey(manifest.archivedPages, path) : undefined;
|
|
18
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);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Payload } from 'payload';
|
|
2
|
+
import type { MetricUnavailableReason } from '../types/reasons.js';
|
|
2
3
|
import type { DocId } from '../types/contracts.js';
|
|
3
4
|
type UnavailableMetric = {
|
|
4
5
|
experimentId: DocId;
|
|
5
6
|
phase: number;
|
|
6
7
|
metricId: DocId;
|
|
7
|
-
reason:
|
|
8
|
+
reason: MetricUnavailableReason;
|
|
8
9
|
};
|
|
9
10
|
export declare function runResults(payload: Payload, { budgetMs, experimentId, sweep }?: {
|
|
10
11
|
budgetMs?: number;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { XpAnalysisError } from '../errors.js';
|
|
1
2
|
import { safeError } from '../log.js';
|
|
3
|
+
import { createLocalReq } from 'payload';
|
|
2
4
|
import { resultsCandidates, resultsExecutor, writeSnapshots } from '../db/snapshots.js';
|
|
3
5
|
import { sweepEvents } from '../db/sweep.js';
|
|
4
6
|
import { armMismatchCount, identityRows, metricEventRows, phaseExposureRows, rolloutExposureRows } from '../db/results.js';
|
|
@@ -6,8 +8,8 @@ import { raceDeadline, ResultsBudgetError, RESULTS_TIMEOUT_MARGIN_MS } from '../
|
|
|
6
8
|
import { analysePhase } from '../results/analyse.js';
|
|
7
9
|
import { firstExposures, perUnitValues, timestampMs } from '../results/aggregate.js';
|
|
8
10
|
import { rolloutAnalysisUnits } from '../results/rolloutUnits.js';
|
|
9
|
-
import { creditUserEvents } from '../results/identityCredit.js';
|
|
10
|
-
async function analyseCandidate(exec, candidate, options, now, deadline, unavailableMetrics) {
|
|
11
|
+
import { creditUserEvents, eligibleIdentityLinks } from '../results/identityCredit.js';
|
|
12
|
+
async function analyseCandidate(exec, candidate, options, now, deadline, unavailableMetrics, logger) {
|
|
11
13
|
const { phase } = candidate;
|
|
12
14
|
// Rollout assignment preserves the original test ranges, but its SRM compares
|
|
13
15
|
// only the shipped group against holdout with weights {100-h, h}.
|
|
@@ -59,23 +61,54 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
59
61
|
...new Set(identities.map((identity)=>identity.userId))
|
|
60
62
|
];
|
|
61
63
|
const linked = new Set(userIds);
|
|
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
|
+
};
|
|
62
84
|
const credit = (events, userKeyed)=>{
|
|
63
85
|
if (candidate.unitType === 'user') return events.map((event)=>({
|
|
64
86
|
...event,
|
|
65
87
|
unitId: event.userId ?? event.unitId
|
|
66
88
|
}));
|
|
67
|
-
|
|
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)=>({
|
|
68
94
|
userId: String(event.userId ?? event.unitId),
|
|
69
95
|
value: event.value,
|
|
70
96
|
ts: event.ts
|
|
71
97
|
}));
|
|
72
|
-
const credited = creditUserEvents(units, identities, userEvents).events;
|
|
73
|
-
|
|
74
|
-
|
|
98
|
+
const credited = creditUserEvents(units, identities, userEvents, (userId)=>identityConflicts.add(userId)).events;
|
|
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,
|
|
75
109
|
...credited
|
|
76
110
|
];
|
|
77
111
|
};
|
|
78
|
-
const diagnostics = creditUserEvents(units, identities, []);
|
|
79
112
|
const metricData = [];
|
|
80
113
|
for (const definition of phase.metricDefinitions){
|
|
81
114
|
try {
|
|
@@ -89,7 +122,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
89
122
|
let events, covariates;
|
|
90
123
|
if (definition.source.startsWith('events:')) {
|
|
91
124
|
const eventName = definition.source.slice(7);
|
|
92
|
-
if (!options.eventSources?.some((source)=>source.name === eventName)) throw new
|
|
125
|
+
if (!options.eventSources?.some((source)=>source.name === eventName)) throw new XpAnalysisError('source-unavailable');
|
|
93
126
|
const read = async (start)=>{
|
|
94
127
|
if (candidate.unitType === 'user') return metricEventRows(exec, {
|
|
95
128
|
eventName,
|
|
@@ -109,9 +142,10 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
109
142
|
from: start,
|
|
110
143
|
to
|
|
111
144
|
});
|
|
145
|
+
// The two lookups overlap when a linked user's own browser was exposed.
|
|
112
146
|
return [
|
|
113
147
|
...direct,
|
|
114
|
-
...signedIn
|
|
148
|
+
...signedIn.filter((event)=>!units.has(event.unitId))
|
|
115
149
|
];
|
|
116
150
|
};
|
|
117
151
|
events = credit(await read(phase.analysis.cuped === 'auto' ? preFrom : from), false);
|
|
@@ -132,9 +166,9 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
132
166
|
}
|
|
133
167
|
} else {
|
|
134
168
|
const source = options.metricSources?.find((source)=>source.id === definition.source);
|
|
135
|
-
if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new
|
|
169
|
+
if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new XpAnalysisError('source-unavailable');
|
|
136
170
|
const userKeyed = source.keyedBy === 'user' && candidate.unitType === 'visitor';
|
|
137
|
-
if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new
|
|
171
|
+
if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new XpAnalysisError('unit-type-unavailable');
|
|
138
172
|
const sourceIds = userKeyed ? userIds : ids;
|
|
139
173
|
events = credit(await raceDeadline(deadline, ()=>source.query({
|
|
140
174
|
from,
|
|
@@ -142,35 +176,51 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
142
176
|
unitIds: sourceIds
|
|
143
177
|
})), userKeyed);
|
|
144
178
|
if (phase.analysis.cuped === 'auto' && source.covariate) {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
179
|
+
const userUnits = new Map();
|
|
180
|
+
if (userKeyed) for (const link of identities){
|
|
181
|
+
const unit = units.get(link.anonymousId);
|
|
182
|
+
if (!unit) continue;
|
|
183
|
+
// Hosts aggregate pre-period values in SQL; per-event rows over 14 days would break the one-row-per-unit contract and multiply host traffic.
|
|
184
|
+
// CUPED only reduces variance: mis-credited pre-exposure values add noise, but cannot bias the effect because they are independent of assignment.
|
|
185
|
+
if (!eligibleIdentityLinks([
|
|
186
|
+
link
|
|
187
|
+
], unit.firstTs).length) continue;
|
|
188
|
+
const previous = userUnits.get(link.userId);
|
|
189
|
+
if (!previous || +unit.firstTs < +previous.firstExposureTs || +unit.firstTs === +previous.firstExposureTs && link.anonymousId < previous.unitId) {
|
|
190
|
+
userUnits.set(link.userId, {
|
|
191
|
+
unitId: link.anonymousId,
|
|
192
|
+
firstExposureTs: unit.firstTs
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const sourceUnits = userKeyed ? [
|
|
197
|
+
...userUnits
|
|
198
|
+
].map(([unitId, unit])=>({
|
|
199
|
+
unitId,
|
|
200
|
+
firstExposureTs: unit.firstExposureTs
|
|
152
201
|
})) : [
|
|
153
202
|
...units
|
|
154
203
|
].map(([unitId, unit])=>({
|
|
155
204
|
unitId,
|
|
156
|
-
firstExposureTs: unit.firstTs
|
|
205
|
+
firstExposureTs: new Date(timestampMs(unit.firstTs))
|
|
157
206
|
}));
|
|
158
207
|
const pre = await raceDeadline(deadline, ()=>source.covariate({
|
|
159
208
|
units: sourceUnits,
|
|
160
209
|
lookbackDays: 14
|
|
161
210
|
}));
|
|
162
|
-
if (pre.some((row)=>!Number.isFinite(row.value))) throw new
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
]
|
|
211
|
+
if (pre.some((row)=>!Number.isFinite(row.value))) throw new XpAnalysisError('non-finite-covariates');
|
|
212
|
+
covariates = new Map(pre.flatMap((row)=>{
|
|
213
|
+
const unitId = userKeyed ? userUnits.get(row.unitId)?.unitId : row.unitId;
|
|
214
|
+
return unitId === undefined ? [] : [
|
|
215
|
+
[
|
|
216
|
+
unitId,
|
|
217
|
+
row.value
|
|
218
|
+
]
|
|
219
|
+
];
|
|
220
|
+
}));
|
|
171
221
|
}
|
|
172
222
|
}
|
|
173
|
-
if (events.some((event)=>!Number.isFinite(event.value))) throw new
|
|
223
|
+
if (events.some((event)=>!Number.isFinite(event.value))) throw new XpAnalysisError('non-finite-values');
|
|
174
224
|
metricData.push({
|
|
175
225
|
metricId: definition.metricId,
|
|
176
226
|
values: perUnitValues(units, events, {
|
|
@@ -183,7 +233,22 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
183
233
|
} : {}
|
|
184
234
|
});
|
|
185
235
|
} catch (error) {
|
|
186
|
-
|
|
236
|
+
let reason = error instanceof ResultsBudgetError ? 'budget' : 'source-failed';
|
|
237
|
+
if (error instanceof XpAnalysisError) {
|
|
238
|
+
switch(error.code){
|
|
239
|
+
case 'source-unavailable':
|
|
240
|
+
case 'unit-type-unavailable':
|
|
241
|
+
case 'non-finite-values':
|
|
242
|
+
case 'non-finite-covariates':
|
|
243
|
+
reason = error.code;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
logger.error({
|
|
248
|
+
err: safeError(error),
|
|
249
|
+
experimentId: phase.experiment,
|
|
250
|
+
metricId: definition.metricId
|
|
251
|
+
}, 'Experiment metric source failed');
|
|
187
252
|
metricData.push({
|
|
188
253
|
metricId: definition.metricId,
|
|
189
254
|
values: new Map(),
|
|
@@ -205,9 +270,14 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
205
270
|
units,
|
|
206
271
|
metricData,
|
|
207
272
|
status,
|
|
273
|
+
onMetricError: (error, metricId)=>logger.error({
|
|
274
|
+
err: safeError(error),
|
|
275
|
+
experimentId: phase.experiment,
|
|
276
|
+
metricId
|
|
277
|
+
}, 'Experiment metric analysis failed'),
|
|
208
278
|
excludedMultiArm: exposed.excludedMultiArm,
|
|
209
279
|
armMismatch: await armMismatchCount(exec, query),
|
|
210
|
-
identityConflictShare: userIds.length ?
|
|
280
|
+
identityConflictShare: userIds.length ? identityConflicts.size / userIds.length : 0,
|
|
211
281
|
...phase.phaseKind === 'rollout' ? {
|
|
212
282
|
winnerArm: phase.arms.find((arm)=>arm.kind !== 'holdout').key
|
|
213
283
|
} : {}
|
|
@@ -252,8 +322,8 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
252
322
|
experimentId
|
|
253
323
|
}, 'Experiment scheduling failed');
|
|
254
324
|
}
|
|
255
|
-
//
|
|
256
|
-
// phases together
|
|
325
|
+
// Analysis precedes health-only refreshes, then oldest phase snapshots first;
|
|
326
|
+
// grouping keeps each experiment's phases together with that priority.
|
|
257
327
|
const experiments = new Map();
|
|
258
328
|
for (const candidate of candidates){
|
|
259
329
|
const id = candidate.phase.experiment;
|
|
@@ -272,10 +342,62 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
|
|
|
272
342
|
const snapshots = [];
|
|
273
343
|
for (const phase of phases){
|
|
274
344
|
if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
|
|
275
|
-
|
|
345
|
+
// Health refreshes must not reopen inference after the late-beacon grace.
|
|
346
|
+
snapshots.push(phase.frozenSnapshot ? {
|
|
347
|
+
...phase.frozenSnapshot,
|
|
348
|
+
computedAt: now.toISOString()
|
|
349
|
+
} : await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics, payload.logger));
|
|
350
|
+
}
|
|
351
|
+
if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
|
|
352
|
+
const current = phases.find((candidate)=>candidate.kind === 'page' && candidate.phase.phase === candidate.currentPhase && (candidate.status === 'running' || candidate.status === 'decided'));
|
|
353
|
+
if (current) {
|
|
354
|
+
const req = await raceDeadline(analysisDeadline, ()=>createLocalReq({}, payload));
|
|
355
|
+
const health = [];
|
|
356
|
+
// Decided tests serve only the winner and Original, including rollouts.
|
|
357
|
+
const arms = current.phase.phaseKind === 'rollout' ? [
|
|
358
|
+
...current.phase.arms.filter((arm)=>arm.kind !== 'holdout'),
|
|
359
|
+
...current.testArms.filter((arm)=>arm.kind === 'control')
|
|
360
|
+
] : current.testArms.filter((arm)=>arm.kind !== 'holdout' && (current.status !== 'decided' || arm.kind === 'control' || arm.key === current.winnerArm));
|
|
361
|
+
const targets = new Set(arms.flatMap((arm)=>arm.pageTarget ? [
|
|
362
|
+
arm.pageTarget
|
|
363
|
+
] : []));
|
|
364
|
+
for (const path of targets){
|
|
365
|
+
let published = false, found = false, unchecked = false;
|
|
366
|
+
for (const collection of options.pages?.collections ?? []){
|
|
367
|
+
try {
|
|
368
|
+
const page = await raceDeadline(analysisDeadline, ()=>collection.findByPath(path, req));
|
|
369
|
+
found ||= page != null;
|
|
370
|
+
published ||= page?.published === true;
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error instanceof ResultsBudgetError) throw error;
|
|
373
|
+
payload.logger.error({
|
|
374
|
+
err: safeError(error),
|
|
375
|
+
experimentId: id
|
|
376
|
+
}, 'Experiment version page lookup failed');
|
|
377
|
+
unchecked = true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!published) health.push({
|
|
381
|
+
code: !found && (unchecked || !options.pages?.collections.length) ? 'version-page-unchecked' : 'version-page-unavailable',
|
|
382
|
+
path
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
for (const snapshot of snapshots)snapshot.health = health;
|
|
276
386
|
}
|
|
277
387
|
if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
|
|
278
|
-
|
|
388
|
+
// Order and optional empty health are semantically identical. Frozen
|
|
389
|
+
// inference gets a new row only when its page availability changes.
|
|
390
|
+
const canonicalHealth = (snapshot)=>JSON.stringify((snapshot.health ?? []).map(({ code, path })=>[
|
|
391
|
+
code,
|
|
392
|
+
path
|
|
393
|
+
]).map((entry)=>JSON.stringify(entry)).sort((a, b)=>a < b ? -1 : a > b ? 1 : 0));
|
|
394
|
+
const changed = snapshots.filter((snapshot, index)=>!phases[index].frozenSnapshot || canonicalHealth(snapshot) !== canonicalHealth(phases[index].frozenSnapshot));
|
|
395
|
+
// Unchanged health is a successful refresh with nothing to write.
|
|
396
|
+
if (!changed.length) {
|
|
397
|
+
summary.ran++;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
await writeSnapshots(exec, changed);
|
|
279
401
|
summary.ran++;
|
|
280
402
|
if (summary.processedIds.length < 100) summary.processedIds.push(id);
|
|
281
403
|
} catch (error) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Trim and deduplicate list inputs while preserving commas inside array items. */
|
|
2
|
+
export declare function coerceListValue(raw: unknown): string[];
|
|
3
|
+
/** Only the read boundary accepts legacy comma-separated document values. */
|
|
4
|
+
export declare function readListValue(raw: unknown): string[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Trim and deduplicate list inputs while preserving commas inside array items. */ export function coerceListValue(raw) {
|
|
2
|
+
return [
|
|
3
|
+
...new Set((Array.isArray(raw) ? raw.map(String) : [
|
|
4
|
+
String(raw ?? '')
|
|
5
|
+
]).map((value)=>value.trim()).filter(Boolean))
|
|
6
|
+
];
|
|
7
|
+
}
|
|
8
|
+
/** Only the read boundary accepts legacy comma-separated document values. */ export function readListValue(raw) {
|
|
9
|
+
return coerceListValue(typeof raw === 'string' ? raw.split(',') : raw);
|
|
10
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lockMetricsForFreeze } from '../db/metricUsage.js';
|
|
1
2
|
import { readState } from '../db/state.js';
|
|
2
3
|
import { XpRefusal } from '../errors.js';
|
|
3
4
|
import { relationshipId } from './checkStart.js';
|
|
@@ -44,7 +45,14 @@ export async function addMetric(req, { experimentId, metric, role, options }) {
|
|
|
44
45
|
if (ids.some((id)=>id !== undefined && String(id) === String(metric))) refuse('duplicate-metric', {
|
|
45
46
|
message: 'This metric is already part of the experiment.'
|
|
46
47
|
});
|
|
48
|
+
const lockedMetrics = await lockMetricsForFreeze(exec, caps.definitions.map((metric)=>metric.id));
|
|
49
|
+
if (!lockedMetrics) {
|
|
50
|
+
throw new XpRefusal('stale-draft', 'This goal was deleted. Choose another goal.', undefined, 409);
|
|
51
|
+
}
|
|
47
52
|
const definition = caps.definitions[0];
|
|
53
|
+
if (lockedMetrics[0].slug !== definition.slug || lockedMetrics[0].source !== definition.source) {
|
|
54
|
+
throw new XpRefusal('stale-draft', 'This goal changed. Review it again before adding it.', undefined, 409);
|
|
55
|
+
}
|
|
48
56
|
if (current.unitType === 'user' && definition.keyedBy === 'visitor' && !definition.source.startsWith('events:')) refuse('unit-source-mismatch');
|
|
49
57
|
// §7.8: phase lookup serves the access-authorised transition write.
|
|
50
58
|
const phases = await req.payload.find({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contentDone } from '../contentDone.js';
|
|
1
2
|
import { relationshipId } from './checkStart.js';
|
|
2
3
|
import { expectedConfirm, matchesConfirm, storedWinnerLabel } from './confirmPhrases.js';
|
|
3
4
|
import { withTransition } from './kernel.js';
|
|
@@ -14,9 +15,7 @@ export async function archive(req, { experimentId, mode, confirmation }) {
|
|
|
14
15
|
if (mode === 'stopped' ? current.status !== 'stopped' : current.status !== 'decided') refuse('invalid-status');
|
|
15
16
|
if (mode === 'hard-coded' && current.kind !== 'code') refuse('invalid-status');
|
|
16
17
|
if (mode === 'after-content') {
|
|
17
|
-
|
|
18
|
-
const docs = Array.isArray(report) ? report : report?.docs;
|
|
19
|
-
if (!Array.isArray(docs) || !docs.every((doc)=>doc?.ok === true)) refuse('invalid-status');
|
|
18
|
+
if (!contentDone(decision?.contentReport)) refuse('invalid-status');
|
|
20
19
|
}
|
|
21
20
|
if (mode === 'discard' && !matchesConfirm(expectedConfirm('discard', ''), confirmation)) refuse('confirmation-required');
|
|
22
21
|
if (mode === 'hard-coded') {
|