@delmaredigital/payload-experiments 0.2.0-rc.7 → 0.3.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -11
- package/dist/admin/PreviewLinks.d.ts +2 -1
- package/dist/admin/PreviewLinks.js +24 -16
- 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/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/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 +19 -0
- package/dist/admin/copy/readiness.js +19 -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 +41 -2
- package/dist/admin/copy/results.js +42 -3
- package/dist/admin/copy/review.d.ts +1 -1
- package/dist/admin/copy/review.js +1 -1
- 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 -4
- package/dist/admin/copy/surface.js +9 -4
- 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/previewLinks.model.d.ts +2 -2
- package/dist/admin/previewLinks.model.js +5 -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/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 +5 -1
- package/dist/admin/results/results.model.js +10 -2
- 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 +183 -112
- 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 +11 -3
- package/dist/admin/surface/goals/goals.css +10 -13
- package/dist/admin/surface/home.model.d.ts +7 -8
- package/dist/admin/surface/home.model.js +23 -12
- package/dist/admin/surface/newTest/NewTest.js +5 -5
- package/dist/admin/surface/pageBlock/parts.js +31 -26
- package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
- 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 +18 -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 +276 -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.js +2 -6
- 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 +62 -47
- package/dist/admin/surface/test/AdvancedSection.js +55 -48
- package/dist/admin/surface/test/GoalSection.js +39 -32
- 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/useTestDocument.js +10 -4
- 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/views/ExperimentsRootView.js +12 -2
- 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/identities.d.ts +8 -7
- package/dist/db/identities.js +9 -6
- 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/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 +23 -7
- 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/index.d.ts +2 -1
- package/dist/exports/index.js +1 -0
- package/dist/exports/puckEditor.d.ts +1 -0
- package/dist/exports/puckEditor.js +1 -0
- package/dist/exports/rsc.d.ts +5 -1
- package/dist/exports/rsc.js +2 -0
- package/dist/hooks/pageGuard.d.ts +5 -0
- package/dist/hooks/pageGuard.js +121 -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 +23 -0
- package/dist/plugin.js +26 -2
- package/dist/puck/copy.d.ts +1 -1
- package/dist/puck/copy.js +1 -1
- package/dist/puck/editor/editor.chrome.css +235 -0
- package/dist/puck/editor/editor.css +241 -43
- 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 +54 -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 +9 -1
- package/dist/server/runResults.d.ts +3 -2
- package/dist/server/runResults.js +121 -31
- 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 +6 -3
- package/dist/transitions/checkStart.js +15 -0
- package/dist/transitions/contentFields.d.ts +7 -0
- package/dist/transitions/contentFields.js +20 -0
- package/dist/transitions/decide.js +6 -1
- 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 +1 -0
- package/dist/transitions/placements.js +4 -0
- package/dist/transitions/preflightData.d.ts +1 -1
- package/dist/transitions/preflightData.js +42 -4
- 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 +30 -5
- package/dist/types/contracts.d.ts +14 -0
- 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 +14 -0
- package/dist/ui/Tabs.js +54 -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 +21 -0
- package/dist/ui/accent.js +81 -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 +1 -1
- 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
|
@@ -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/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{
|
|
@@ -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,10 @@
|
|
|
1
|
+
import type { PayloadRequest } from 'payload';
|
|
2
|
+
import type { DocId } from '../types/contracts.js';
|
|
3
|
+
/** §7.8 make-content: the published main row, so Payload's draft baseline never fills a field (KI-1). */
|
|
4
|
+
export declare function publishedContent(req: PayloadRequest, collection: string, id: DocId): Promise<(Record<string, unknown> & {
|
|
5
|
+
id: DocId;
|
|
6
|
+
}) | null>;
|
|
7
|
+
/** §7.8 make-content: the latest draft version, so restoring the draft never drops fields the manager cannot read (KI-1). */
|
|
8
|
+
export declare function latestDraftContent(req: PayloadRequest, collection: string, id: DocId): Promise<(Record<string, unknown> & {
|
|
9
|
+
id: DocId;
|
|
10
|
+
}) | undefined>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Keep this module adapter-only: the §7.8 exception does not permit Local API calls.
|
|
2
|
+
/** §7.8 make-content: the published main row, so Payload's draft baseline never fills a field (KI-1). */ export async function publishedContent(req, collection, id) {
|
|
3
|
+
return req.payload.db.findOne({
|
|
4
|
+
collection,
|
|
5
|
+
req,
|
|
6
|
+
where: {
|
|
7
|
+
id: {
|
|
8
|
+
equals: id
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
// Static allow-list marker only; adapter reads have no access layer.
|
|
12
|
+
overrideAccess: true
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** §7.8 make-content: the latest draft version, so restoring the draft never drops fields the manager cannot read (KI-1). */ export async function latestDraftContent(req, collection, id) {
|
|
16
|
+
const { docs } = await req.payload.db.findVersions({
|
|
17
|
+
collection,
|
|
18
|
+
req,
|
|
19
|
+
limit: 1,
|
|
20
|
+
pagination: false,
|
|
21
|
+
sort: '-updatedAt',
|
|
22
|
+
where: {
|
|
23
|
+
and: [
|
|
24
|
+
{
|
|
25
|
+
parent: {
|
|
26
|
+
equals: id
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
latest: {
|
|
31
|
+
equals: true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
// Static allow-list marker only; adapter reads have no access layer.
|
|
37
|
+
overrideAccess: true
|
|
38
|
+
});
|
|
39
|
+
return docs[0] ? {
|
|
40
|
+
...docs[0].version,
|
|
41
|
+
id
|
|
42
|
+
} : undefined;
|
|
43
|
+
}
|
package/dist/db/results.d.ts
CHANGED
package/dist/db/results.js
CHANGED
|
@@ -91,12 +91,16 @@ function phaseFilter({ experimentId, phase, endedAt }, type) {
|
|
|
91
91
|
const i = COLUMNS.identities;
|
|
92
92
|
const rows = [];
|
|
93
93
|
for(let offset = 0; offset < ids.length; offset += batchSize){
|
|
94
|
-
|
|
94
|
+
// Ownership is global to the user, including browsers outside these exposure batches.
|
|
95
|
+
const table = qualifiedTable(exec, TABLES.identities);
|
|
96
|
+
const result = await exec.query(`SELECT link.${q(i.anonymousId)} AS "anonymousId", link.${q(i.userId)} AS "userId", round(extract(epoch from link.${q(i.verifiedAt)}) * 1000)::bigint AS "verifiedAt", (SELECT round(extract(epoch from min(owner.${q(i.verifiedAt)})) * 1000)::bigint FROM ${table} owner WHERE owner.${q(i.userId)} = link.${q(i.userId)}) AS "ownedAt" FROM ${table} link WHERE link.${q(i.anonymousId)} = ANY($1::text[]) ORDER BY link.${q(i.anonymousId)}`, [
|
|
95
97
|
ids.slice(offset, offset + batchSize)
|
|
96
98
|
]);
|
|
97
99
|
rows.push(...result.rows.map((row)=>({
|
|
98
100
|
anonymousId: row.anonymousId,
|
|
99
|
-
userId: String(row.userId)
|
|
101
|
+
userId: String(row.userId),
|
|
102
|
+
verifiedAt: row.verifiedAt == null ? null : Number(row.verifiedAt),
|
|
103
|
+
ownedAt: row.ownedAt == null ? null : Number(row.ownedAt)
|
|
100
104
|
})));
|
|
101
105
|
}
|
|
102
106
|
return rows;
|
package/dist/db/snapshots.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Payload } from 'payload';
|
|
2
|
-
import type { DocId, UnitType } from '../types/contracts.js';
|
|
2
|
+
import type { Arm, DocId, ExperimentKind, Status, UnitType } from '../types/contracts.js';
|
|
3
3
|
import type { Snapshot } from '../types/snapshot.js';
|
|
4
4
|
import type { AnalysisPhase, FrozenMetric } from '../results/analyse.js';
|
|
5
5
|
import { type Executor } from './executor.js';
|
|
6
6
|
export interface ResultsPhase extends AnalysisPhase {
|
|
7
|
+
arms: Array<AnalysisPhase['arms'][number] & Pick<Arm, 'pageTarget'>>;
|
|
7
8
|
seed: string;
|
|
8
9
|
allocation: number;
|
|
9
10
|
metricDefinitions: Array<FrozenMetric & {
|
|
@@ -14,11 +15,18 @@ export interface ResultsPhase extends AnalysisPhase {
|
|
|
14
15
|
}
|
|
15
16
|
export interface ResultsCandidate {
|
|
16
17
|
phase: ResultsPhase;
|
|
18
|
+
frozenSnapshot?: Snapshot | null;
|
|
19
|
+
kind?: ExperimentKind;
|
|
20
|
+
currentPhase?: number;
|
|
21
|
+
winnerArm?: string | null;
|
|
17
22
|
unitType: UnitType;
|
|
18
23
|
status: 'running' | 'stopped' | 'decided' | 'archived';
|
|
19
24
|
latestComputedAt: string | null;
|
|
20
25
|
testArms: Array<{
|
|
26
|
+
key: string;
|
|
21
27
|
weight: number;
|
|
28
|
+
kind?: string;
|
|
29
|
+
pageTarget?: string;
|
|
22
30
|
}>;
|
|
23
31
|
layerSlug: string | null;
|
|
24
32
|
layerSlices: number[] | null;
|
|
@@ -39,10 +47,14 @@ export declare function writeSnapshots(exec: Executor, snapshots: Snapshot[]): P
|
|
|
39
47
|
/** Fetch pointers only, then the endpoint reads the bounded set through Payload
|
|
40
48
|
* with req/overrideAccess:false. Called behind the readResults/manage gate. */
|
|
41
49
|
export declare function newestSnapshotIds(exec: Executor, ids: DocId[], per: 'phase' | 'experiment'): Promise<DocId[]>;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
status:
|
|
50
|
+
type ResultsMetadata = {
|
|
51
|
+
name: string | null;
|
|
52
|
+
kind: ExperimentKind;
|
|
53
|
+
status: Status;
|
|
46
54
|
snapshotId: DocId | null;
|
|
47
55
|
winnerArm: string | null;
|
|
48
|
-
}
|
|
56
|
+
};
|
|
57
|
+
/** Called only behind the endpoint's readResults gate. Experiment collection
|
|
58
|
+
* reads require manage, so read only the identity and decision metadata needed by results. */
|
|
59
|
+
export declare function resultsMetadata(exec: Executor, id: DocId): Promise<ResultsMetadata | null>;
|
|
60
|
+
export {};
|
package/dist/db/snapshots.js
CHANGED
|
@@ -31,16 +31,32 @@ const LATE_BEACON_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
|
31
31
|
'startedAt', p.${q(p.startedAt)}, 'endedAt', p.${q(p.endedAt)}, 'seed', p.${q(p.seed)},
|
|
32
32
|
'arms', p.${q(p.arms)}, 'allocation', p.${q(p.allocation)}, 'metricDefinitions', p.${q(p.metricDefinitions)},
|
|
33
33
|
'analysis', p.${q(p.analysis)}, 'contentChanges', p.${q(p.contentChanges)}) AS "phase",
|
|
34
|
-
e.${q(e.unitType)} AS "unitType", e.${q(e.status)} AS "status",
|
|
34
|
+
e.${q(e.kind)} AS "kind", e.${q(e.phase)} AS "currentPhase", e.${q(e.unitType)} AS "unitType", e.${q(e.status)} AS "status", e.${q(e.decisionWinnerArm)} AS "winnerArm",
|
|
35
35
|
(SELECT max(s.${q(s.computedAt)}) FROM ${snapshots} s WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}) AS "latestComputedAt",
|
|
36
36
|
(SELECT t.${q(p.arms)} FROM ${phases} t WHERE t.${q(p.experimentId)} = p.${q(p.experimentId)} AND t.${q(p.phaseKind)} = 'test' AND t.${q(p.phase)} <= p.${q(p.phase)} ORDER BY t.${q(p.phase)} DESC LIMIT 1) AS "testArms",
|
|
37
|
+
frozen."snapshot" AS "frozenSnapshot",
|
|
37
38
|
l.${q(l.slug)} AS "layerSlug", e.${q(e.layerSlices)} AS "layerSlices"
|
|
38
39
|
FROM ${phases} p JOIN ${qualifiedTable(exec, TABLES.experiments)} e ON e.${q(e.id)} = p.${q(p.experimentId)}
|
|
39
40
|
LEFT JOIN ${qualifiedTable(exec, TABLES.layers)} l ON l.${q(l.id)} = e.${q(e.layerId)}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
LEFT JOIN LATERAL (SELECT s.${q(s.snapshot)} AS "snapshot" FROM ${snapshots} s
|
|
42
|
+
WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}
|
|
43
|
+
AND p.${q(p.endedAt)} IS NOT NULL
|
|
44
|
+
AND (s.${q(s.computedAt)} > p.${q(p.endedAt)} + $2 * interval '1 millisecond' OR p.${q(p.endedAt)} < $1)
|
|
45
|
+
ORDER BY s.${q(s.computedAt)} DESC, s.${q(s.id)} DESC LIMIT 1) frozen ON true
|
|
46
|
+
WHERE ((e.${q(e.kind)} = 'page'
|
|
47
|
+
AND (p.${q(p.endedAt)} IS NULL OR p.${q(p.endedAt)} >= $1 OR EXISTS
|
|
48
|
+
(SELECT 1 FROM ${snapshots} existing WHERE existing.${q(s.experimentId)} = p.${q(p.experimentId)} AND existing.${q(s.phase)} = p.${q(p.phase)}))
|
|
49
|
+
AND e.${q(e.status)} IN ('running', 'decided') AND (p.${q(p.phase)} = e.${q(e.phase)}
|
|
50
|
+
OR p.${q(p.phase)} = (SELECT max(active.${q(p.phase)}) FROM ${phases} active WHERE active.${q(p.experimentId)} = e.${q(e.id)} AND active.${q(p.phaseKind)} = 'test')))
|
|
51
|
+
OR ((p.${q(p.endedAt)} IS NULL OR p.${q(p.endedAt)} >= $1)
|
|
52
|
+
AND NOT EXISTS (SELECT 1 FROM ${snapshots} done WHERE done.${q(s.experimentId)} = p.${q(p.experimentId)} AND done.${q(s.phase)} = p.${q(p.phase)}
|
|
53
|
+
AND done.${q(s.computedAt)} > p.${q(p.endedAt)} + $2 * interval '1 millisecond')))
|
|
54
|
+
${experimentId === undefined ? '' : ` AND p.${q(p.experimentId)} = $3`}
|
|
55
|
+
ORDER BY (frozen."snapshot" IS NOT NULL),
|
|
56
|
+
CASE WHEN frozen."snapshot" IS NULL THEN
|
|
57
|
+
(SELECT max(s.${q(s.computedAt)}) FROM ${snapshots} s WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}) END NULLS FIRST,
|
|
58
|
+
CASE WHEN frozen."snapshot" IS NOT NULL THEN random() END,
|
|
59
|
+
p.${q(p.experimentId)}, p.${q(p.phase)}`, params);
|
|
44
60
|
return rows;
|
|
45
61
|
}
|
|
46
62
|
export async function writeSnapshot(exec, snapshot) {
|
|
@@ -80,9 +96,9 @@ export async function writeSnapshot(exec, snapshot) {
|
|
|
80
96
|
return rows.map((row)=>row.id);
|
|
81
97
|
}
|
|
82
98
|
/** Called only behind the endpoint's readResults gate. Experiment collection
|
|
83
|
-
* reads require manage, so read only the decision metadata needed by results. */ export async function resultsMetadata(exec, id) {
|
|
99
|
+
* reads require manage, so read only the identity and decision metadata needed by results. */ export async function resultsMetadata(exec, id) {
|
|
84
100
|
const c = COLUMNS.experiments;
|
|
85
|
-
const { rows } = await exec.query(`SELECT ${q(c.status)} AS "status", ${q(c.decisionSnapshotId)} AS "snapshotId", ${q(c.decisionWinnerArm)} AS "winnerArm" FROM ${qualifiedTable(exec, TABLES.experiments)} WHERE ${q(c.id)} = $1`, [
|
|
101
|
+
const { rows } = await exec.query(`SELECT ${q(c.name)} AS "name", ${q(c.kind)} AS "kind", ${q(c.status)} AS "status", ${q(c.decisionSnapshotId)} AS "snapshotId", ${q(c.decisionWinnerArm)} AS "winnerArm" FROM ${qualifiedTable(exec, TABLES.experiments)} WHERE ${q(c.id)} = $1`, [
|
|
86
102
|
id
|
|
87
103
|
]);
|
|
88
104
|
return rows[0] ?? null;
|
package/dist/db/sweep.js
CHANGED
|
@@ -11,6 +11,13 @@ import { ResultsBudgetError } from './pgExecutor.js';
|
|
|
11
11
|
const events = qualifiedTable(exec, TABLES.events), phases = qualifiedTable(exec, TABLES.phases);
|
|
12
12
|
const revision = `(SELECT ${q(s.revision)} FROM ${qualifiedTable(exec, TABLES.state)} LIMIT 1)`;
|
|
13
13
|
let swept = 0;
|
|
14
|
+
const warnNoProgress = ()=>logger?.warn('Experiment retention sweep made no progress before its budget expired; eligible rows may remain.');
|
|
15
|
+
// Every budget-limited exit with nothing swept is reported, whether the budget ran out mid-statement (the catch
|
|
16
|
+
// below) or before the next batch could start (here).
|
|
17
|
+
const finish = ()=>{
|
|
18
|
+
if (swept === 0 && !shouldContinue()) warnNoProgress();
|
|
19
|
+
return swept;
|
|
20
|
+
};
|
|
14
21
|
// Each statement still uses the caller's budget/race-aware executor.
|
|
15
22
|
async function batches(predicate, order, params = []) {
|
|
16
23
|
while(shouldContinue()){
|
|
@@ -38,7 +45,7 @@ import { ResultsBudgetError } from './pgExecutor.js';
|
|
|
38
45
|
type
|
|
39
46
|
]);
|
|
40
47
|
}
|
|
41
|
-
if (!shouldContinue()) return
|
|
48
|
+
if (!shouldContinue()) return finish();
|
|
42
49
|
// Final-endedAt retention protects every phase while any phase is open.
|
|
43
50
|
const ended = await exec.query(`SELECT ${q(p.experimentId)} AS "experimentId" FROM ${phases}
|
|
44
51
|
WHERE ${q(p.experimentId)} IS NOT NULL GROUP BY ${q(p.experimentId)}
|
|
@@ -54,7 +61,7 @@ import { ResultsBudgetError } from './pgExecutor.js';
|
|
|
54
61
|
cutoff
|
|
55
62
|
]);
|
|
56
63
|
}
|
|
57
|
-
if (!shouldContinue()) return
|
|
64
|
+
if (!shouldContinue()) return finish();
|
|
58
65
|
// Every protected window ends at/after cutoff (or is open). For old
|
|
59
66
|
// conversions their union therefore starts at the earliest lookback start;
|
|
60
67
|
// everything before that single bound is safe. Compute it once, in the same
|
|
@@ -80,11 +87,9 @@ import { ResultsBudgetError } from './pgExecutor.js';
|
|
|
80
87
|
before,
|
|
81
88
|
frozen.revision
|
|
82
89
|
]);
|
|
83
|
-
return
|
|
90
|
+
return finish();
|
|
84
91
|
} catch (error) {
|
|
85
|
-
if (swept === 0 && error instanceof ResultsBudgetError)
|
|
86
|
-
logger?.warn('Experiment retention sweep made no progress before its budget expired; eligible rows may remain.');
|
|
87
|
-
}
|
|
92
|
+
if (swept === 0 && error instanceof ResultsBudgetError) warnNoProgress();
|
|
88
93
|
throw error;
|
|
89
94
|
}
|
|
90
95
|
}
|
package/dist/endpoints/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { XpNotImplemented, XpRefusal } from '../errors.js';
|
|
|
4
4
|
import { readUnitCookie } from '../runtime/cookie.js';
|
|
5
5
|
import { handleManifest } from './manifest.js';
|
|
6
6
|
import { handleEvents } from './events.js';
|
|
7
|
-
import { handleStart, handleAllocate, handleAddMetric, handleStop, handleDecide, handleReleaseHoldout, handleMakeContent, handleArchive } from './transitions.js';
|
|
7
|
+
import { handleStart, handleAllocate, handleAddMetric, handleStop, handleDecide, handleReleaseHoldout, handleReleasePages, handleMakeContent, handleDismissContentEntry, handleRestoreContentEntry, handleArchive } from './transitions.js';
|
|
8
8
|
import { handlePreflight } from './preflight.js';
|
|
9
9
|
import { handlePreviewToken } from './previewToken.js';
|
|
10
10
|
import { handleResults } from './results.js';
|
|
@@ -59,12 +59,30 @@ export const ROUTES = [
|
|
|
59
59
|
access: 'manage',
|
|
60
60
|
handler: handleReleaseHoldout
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
method: 'post',
|
|
64
|
+
path: '/xp/experiments/:id/release-pages',
|
|
65
|
+
access: 'manage',
|
|
66
|
+
handler: handleReleasePages
|
|
67
|
+
},
|
|
62
68
|
{
|
|
63
69
|
method: 'post',
|
|
64
70
|
path: '/xp/experiments/:id/make-content',
|
|
65
71
|
access: 'manage',
|
|
66
72
|
handler: handleMakeContent
|
|
67
73
|
},
|
|
74
|
+
{
|
|
75
|
+
method: 'post',
|
|
76
|
+
path: '/xp/experiments/:id/content-report/dismiss',
|
|
77
|
+
access: 'manage',
|
|
78
|
+
handler: handleDismissContentEntry
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
method: 'post',
|
|
82
|
+
path: '/xp/experiments/:id/content-report/restore',
|
|
83
|
+
access: 'manage',
|
|
84
|
+
handler: handleRestoreContentEntry
|
|
85
|
+
},
|
|
68
86
|
{
|
|
69
87
|
method: 'post',
|
|
70
88
|
path: '/xp/experiments/:id/archive',
|
|
@@ -111,6 +111,11 @@ export async function handleResults(req, _ctx) {
|
|
|
111
111
|
if (selected !== null && !records.some((record)=>record.phase === phase)) return resultsError(404, 'not-found', 'Round not found');
|
|
112
112
|
const snapshot = snapshots.find((snapshot)=>snapshot.phase === phase)?.snapshot ?? null;
|
|
113
113
|
const response = {
|
|
114
|
+
experiment: {
|
|
115
|
+
name: metadata.name ?? '',
|
|
116
|
+
kind: metadata.kind,
|
|
117
|
+
status: metadata.status
|
|
118
|
+
},
|
|
114
119
|
phases,
|
|
115
120
|
snapshot,
|
|
116
121
|
...decision ? {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { sanitizeReadinessDetail } from '../sanitizeReadinessDetail.js';
|
|
@@ -2,6 +2,7 @@ import { checkStart } from '../transitions/checkStart.js';
|
|
|
2
2
|
import { loadStartInputsBatch } from '../transitions/preflightData.js';
|
|
3
3
|
import { readSnapshots, resultsError } from './results.js';
|
|
4
4
|
import { experimentId } from './routeId.js';
|
|
5
|
+
import { sanitizeReadinessDetail } from './sanitizeReadinessDetail.js';
|
|
5
6
|
export async function handleSummary(req, { options }) {
|
|
6
7
|
let ids;
|
|
7
8
|
try {
|
|
@@ -40,7 +41,13 @@ export async function handleSummary(req, { options }) {
|
|
|
40
41
|
verdict: {
|
|
41
42
|
code: snapshot.verdict.code,
|
|
42
43
|
headline: snapshot.verdict.headline
|
|
43
|
-
}
|
|
44
|
+
},
|
|
45
|
+
...snapshot.health ? {
|
|
46
|
+
health: snapshot.health.slice(0, 100).map(({ code, path })=>({
|
|
47
|
+
code,
|
|
48
|
+
path: path.slice(0, 128)
|
|
49
|
+
}))
|
|
50
|
+
} : {}
|
|
44
51
|
} : {
|
|
45
52
|
exposures: 0,
|
|
46
53
|
days: 0,
|
|
@@ -72,7 +79,18 @@ export async function handleSummary(req, { options }) {
|
|
|
72
79
|
if (!experiment) continue;
|
|
73
80
|
const input = inputs.get(String(experiment.id));
|
|
74
81
|
try {
|
|
75
|
-
|
|
82
|
+
const checks = input ? checkStart(input) : [];
|
|
83
|
+
response[key].ready = !!input && checks.every((check)=>check.ok);
|
|
84
|
+
const next = checks.find((check)=>!check.ok && !check.warning);
|
|
85
|
+
if (next) {
|
|
86
|
+
const detail = sanitizeReadinessDetail(next.code, next.detail);
|
|
87
|
+
response[key].next = {
|
|
88
|
+
code: next.code,
|
|
89
|
+
...detail === undefined ? {} : {
|
|
90
|
+
detail
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
76
94
|
} catch {
|
|
77
95
|
response[key].ready = false;
|
|
78
96
|
}
|