@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.
Files changed (267) hide show
  1. package/README.md +129 -11
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +24 -16
  4. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  5. package/dist/admin/actions/ConfirmDialog.js +37 -82
  6. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  7. package/dist/admin/actions/DecideDialog.js +30 -13
  8. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  9. package/dist/admin/actions/MakeContentResult.js +115 -16
  10. package/dist/admin/actions/actions.model.d.ts +2 -4
  11. package/dist/admin/actions/actions.model.js +2 -3
  12. package/dist/admin/allocation.model.js +3 -5
  13. package/dist/admin/api.d.ts +10 -4
  14. package/dist/admin/api.js +11 -7
  15. package/dist/admin/copy/actions.d.ts +39 -3
  16. package/dist/admin/copy/actions.js +40 -4
  17. package/dist/admin/copy/armSelect.js +5 -5
  18. package/dist/admin/copy/audience.d.ts +2 -1
  19. package/dist/admin/copy/audience.js +3 -2
  20. package/dist/admin/copy/common.d.ts +13 -6
  21. package/dist/admin/copy/common.js +14 -8
  22. package/dist/admin/copy/goals.d.ts +21 -6
  23. package/dist/admin/copy/goals.js +27 -6
  24. package/dist/admin/copy/readiness.d.ts +19 -0
  25. package/dist/admin/copy/readiness.js +19 -0
  26. package/dist/admin/copy/recommendation.d.ts +0 -3
  27. package/dist/admin/copy/recommendation.js +0 -3
  28. package/dist/admin/copy/results.d.ts +41 -2
  29. package/dist/admin/copy/results.js +42 -3
  30. package/dist/admin/copy/review.d.ts +1 -1
  31. package/dist/admin/copy/review.js +1 -1
  32. package/dist/admin/copy/setting.d.ts +2 -0
  33. package/dist/admin/copy/setting.js +2 -0
  34. package/dist/admin/copy/surface.d.ts +7 -4
  35. package/dist/admin/copy/surface.js +9 -4
  36. package/dist/admin/copy/testPage.d.ts +10 -0
  37. package/dist/admin/copy/testPage.js +10 -0
  38. package/dist/admin/format/date.d.ts +2 -0
  39. package/dist/admin/format/date.js +10 -0
  40. package/dist/admin/previewLinks.model.d.ts +2 -2
  41. package/dist/admin/previewLinks.model.js +5 -7
  42. package/dist/admin/results/CiChart.js +11 -21
  43. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  44. package/dist/admin/results/DataQualityPanel.js +30 -17
  45. package/dist/admin/results/DataTable.d.ts +16 -0
  46. package/dist/admin/results/DataTable.js +152 -0
  47. package/dist/admin/results/StatTile.d.ts +7 -0
  48. package/dist/admin/results/StatTile.js +22 -0
  49. package/dist/admin/results/VerdictHero.d.ts +13 -0
  50. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  51. package/dist/admin/results/releasePages.model.d.ts +17 -0
  52. package/dist/admin/results/releasePages.model.js +10 -0
  53. package/dist/admin/results/results.css +64 -0
  54. package/dist/admin/results/results.model.d.ts +5 -1
  55. package/dist/admin/results/results.model.js +10 -2
  56. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  57. package/dist/admin/surface/ResultsRoute.js +59 -34
  58. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  59. package/dist/admin/surface/SurfaceShell.js +49 -14
  60. package/dist/admin/surface/TestsHome.d.ts +6 -1
  61. package/dist/admin/surface/TestsHome.js +183 -112
  62. package/dist/admin/surface/TestsSurface.js +56 -16
  63. package/dist/admin/surface/goals/GoalForm.js +341 -308
  64. package/dist/admin/surface/goals/GoalsList.js +123 -89
  65. package/dist/admin/surface/goals/data.d.ts +8 -2
  66. package/dist/admin/surface/goals/data.js +11 -3
  67. package/dist/admin/surface/goals/goals.css +10 -13
  68. package/dist/admin/surface/home.model.d.ts +7 -8
  69. package/dist/admin/surface/home.model.js +23 -12
  70. package/dist/admin/surface/newTest/NewTest.js +5 -5
  71. package/dist/admin/surface/pageBlock/parts.js +31 -26
  72. package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
  73. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  74. package/dist/admin/surface/readiness/Banner.js +58 -33
  75. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  76. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  77. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  78. package/dist/admin/surface/readiness/readiness.model.js +18 -9
  79. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  80. package/dist/admin/surface/readiness/useTransition.js +2 -1
  81. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  82. package/dist/admin/surface/results/ReleasePages.js +116 -0
  83. package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
  84. package/dist/admin/surface/results/ResultsBody.js +276 -105
  85. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  86. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  87. package/dist/admin/surface/results/recommendation.model.js +2 -6
  88. package/dist/admin/surface/results/useResults.d.ts +21 -3
  89. package/dist/admin/surface/results/useResults.js +2 -1
  90. package/dist/admin/surface/setting/parts.js +75 -88
  91. package/dist/admin/surface/setting/setting.css +3 -15
  92. package/dist/admin/surface/surface.css +62 -47
  93. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  94. package/dist/admin/surface/test/GoalSection.js +39 -32
  95. package/dist/admin/surface/test/LengthSection.js +46 -34
  96. package/dist/admin/surface/test/Rail.d.ts +4 -0
  97. package/dist/admin/surface/test/Rail.js +83 -0
  98. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  99. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  100. package/dist/admin/surface/test/Section.d.ts +1 -1
  101. package/dist/admin/surface/test/Section.js +36 -16
  102. package/dist/admin/surface/test/TargetingSection.js +168 -142
  103. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  104. package/dist/admin/surface/test/TestPage.js +146 -68
  105. package/dist/admin/surface/test/VersionsSection.js +151 -120
  106. package/dist/admin/surface/test/fields.d.ts +11 -4
  107. package/dist/admin/surface/test/fields.js +84 -85
  108. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  109. package/dist/admin/surface/test/jumpToSection.js +10 -0
  110. package/dist/admin/surface/test/pageParts.js +9 -3
  111. package/dist/admin/surface/test/testPage.css +81 -14
  112. package/dist/admin/surface/useTestDocument.js +10 -4
  113. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  114. package/dist/admin/targeting/targeting.model.js +3 -8
  115. package/dist/admin/theme.css +115 -36
  116. package/dist/admin/theme.d.ts +38 -0
  117. package/dist/admin/theme.js +38 -1
  118. package/dist/admin/useApi.d.ts +17 -2
  119. package/dist/admin/views/ExperimentsRootView.js +12 -2
  120. package/dist/collections/experiments/config.js +25 -0
  121. package/dist/collections/experiments/lockHook.js +3 -1
  122. package/dist/collections/identities.js +6 -1
  123. package/dist/collections/metrics.js +7 -0
  124. package/dist/collections/metricsGuards.d.ts +9 -0
  125. package/dist/collections/metricsGuards.js +33 -0
  126. package/dist/contentDone.d.ts +4 -0
  127. package/dist/contentDone.js +5 -0
  128. package/dist/db/identities.d.ts +8 -7
  129. package/dist/db/identities.js +9 -6
  130. package/dist/db/metricUsage.d.ts +19 -0
  131. package/dist/db/metricUsage.js +54 -0
  132. package/dist/db/names.d.ts +5 -0
  133. package/dist/db/names.js +5 -0
  134. package/dist/db/publishedContent.d.ts +10 -0
  135. package/dist/db/publishedContent.js +43 -0
  136. package/dist/db/results.d.ts +2 -0
  137. package/dist/db/results.js +6 -2
  138. package/dist/db/snapshots.d.ts +18 -6
  139. package/dist/db/snapshots.js +23 -7
  140. package/dist/db/sweep.js +11 -6
  141. package/dist/endpoints/index.js +19 -1
  142. package/dist/endpoints/results.js +5 -0
  143. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  144. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  145. package/dist/endpoints/summary.js +20 -2
  146. package/dist/endpoints/transitions.d.ts +15 -0
  147. package/dist/endpoints/transitions.js +36 -0
  148. package/dist/errors.d.ts +8 -0
  149. package/dist/errors.js +28 -0
  150. package/dist/exports/index.d.ts +2 -1
  151. package/dist/exports/index.js +1 -0
  152. package/dist/exports/puckEditor.d.ts +1 -0
  153. package/dist/exports/puckEditor.js +1 -0
  154. package/dist/exports/rsc.d.ts +5 -1
  155. package/dist/exports/rsc.js +2 -0
  156. package/dist/hooks/pageGuard.d.ts +5 -0
  157. package/dist/hooks/pageGuard.js +121 -0
  158. package/dist/log.js +6 -3
  159. package/dist/manifest/compile.d.ts +5 -0
  160. package/dist/manifest/compile.js +5 -1
  161. package/dist/options.js +23 -0
  162. package/dist/plugin.js +26 -2
  163. package/dist/puck/copy.d.ts +1 -1
  164. package/dist/puck/copy.js +1 -1
  165. package/dist/puck/editor/editor.chrome.css +235 -0
  166. package/dist/puck/editor/editor.css +241 -43
  167. package/dist/results/aggregate.js +4 -3
  168. package/dist/results/analyse.d.ts +4 -1
  169. package/dist/results/analyse.js +27 -11
  170. package/dist/results/cuped.js +3 -2
  171. package/dist/results/identityCredit.d.ts +15 -7
  172. package/dist/results/identityCredit.js +32 -33
  173. package/dist/results/rolloutUnits.js +2 -1
  174. package/dist/results/snapshot.js +8 -2
  175. package/dist/results/srm.js +2 -1
  176. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  177. package/dist/sanitizeReadinessDetail.js +54 -0
  178. package/dist/server/linkIdentity.d.ts +10 -1
  179. package/dist/server/linkIdentity.js +17 -15
  180. package/dist/server/metadata.d.ts +5 -0
  181. package/dist/server/metadata.js +9 -1
  182. package/dist/server/runResults.d.ts +3 -2
  183. package/dist/server/runResults.js +121 -31
  184. package/dist/targeting/values.d.ts +4 -0
  185. package/dist/targeting/values.js +10 -0
  186. package/dist/transitions/addMetric.js +8 -0
  187. package/dist/transitions/archive.js +2 -3
  188. package/dist/transitions/checkStart.d.ts +6 -3
  189. package/dist/transitions/checkStart.js +15 -0
  190. package/dist/transitions/contentFields.d.ts +7 -0
  191. package/dist/transitions/contentFields.js +20 -0
  192. package/dist/transitions/decide.js +6 -1
  193. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  194. package/dist/transitions/dismissContentEntry.js +45 -0
  195. package/dist/transitions/makeContent.d.ts +5 -2
  196. package/dist/transitions/makeContent.js +217 -118
  197. package/dist/transitions/phaseRecord.d.ts +8 -2
  198. package/dist/transitions/placements.d.ts +1 -0
  199. package/dist/transitions/placements.js +4 -0
  200. package/dist/transitions/preflightData.d.ts +1 -1
  201. package/dist/transitions/preflightData.js +42 -4
  202. package/dist/transitions/releasePages.d.ts +8 -0
  203. package/dist/transitions/releasePages.js +26 -0
  204. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  205. package/dist/transitions/restoreContentEntry.js +47 -0
  206. package/dist/transitions/start.js +30 -5
  207. package/dist/types/contracts.d.ts +14 -0
  208. package/dist/types/endpoints.d.ts +34 -3
  209. package/dist/types/index.d.ts +1 -0
  210. package/dist/types/index.js +1 -0
  211. package/dist/types/reasons.d.ts +3 -0
  212. package/dist/types/reasons.js +1 -0
  213. package/dist/types/snapshot.d.ts +6 -1
  214. package/dist/ui/Banner.d.ts +2 -0
  215. package/dist/ui/Banner.js +2 -0
  216. package/dist/ui/Breadcrumb.d.ts +7 -0
  217. package/dist/ui/Breadcrumb.js +25 -0
  218. package/dist/ui/Button.d.ts +15 -0
  219. package/dist/ui/Button.js +32 -0
  220. package/dist/ui/Card.d.ts +12 -0
  221. package/dist/ui/Card.js +40 -0
  222. package/dist/ui/Checkbox.d.ts +2 -0
  223. package/dist/ui/Checkbox.js +2 -0
  224. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  225. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  226. package/dist/ui/Dialog.d.ts +13 -0
  227. package/dist/ui/Dialog.js +85 -0
  228. package/dist/ui/EmptyState.d.ts +2 -0
  229. package/dist/ui/EmptyState.js +2 -0
  230. package/dist/ui/Feedback.d.ts +24 -0
  231. package/dist/ui/Feedback.js +40 -0
  232. package/dist/ui/Inputs.d.ts +34 -0
  233. package/dist/ui/Inputs.js +225 -0
  234. package/dist/ui/NumberInput.d.ts +2 -0
  235. package/dist/ui/NumberInput.js +2 -0
  236. package/dist/ui/Range.d.ts +2 -0
  237. package/dist/ui/Range.js +2 -0
  238. package/dist/ui/SearchInput.d.ts +2 -0
  239. package/dist/ui/SearchInput.js +2 -0
  240. package/dist/ui/SectionCard.d.ts +2 -0
  241. package/dist/ui/SectionCard.js +2 -0
  242. package/dist/ui/Select.d.ts +2 -0
  243. package/dist/ui/Select.js +2 -0
  244. package/dist/ui/StatusChip.d.ts +2 -0
  245. package/dist/ui/StatusChip.js +2 -0
  246. package/dist/ui/Tabs.d.ts +14 -0
  247. package/dist/ui/Tabs.js +54 -0
  248. package/dist/ui/TextInput.d.ts +2 -0
  249. package/dist/ui/TextInput.js +2 -0
  250. package/dist/ui/Textarea.d.ts +2 -0
  251. package/dist/ui/Textarea.js +2 -0
  252. package/dist/ui/Toggle.d.ts +2 -0
  253. package/dist/ui/Toggle.js +2 -0
  254. package/dist/ui/accent.d.ts +21 -0
  255. package/dist/ui/accent.js +81 -0
  256. package/dist/ui/index.d.ts +9 -0
  257. package/dist/ui/index.js +9 -0
  258. package/dist/ui/ui.css +92 -0
  259. package/package.json +1 -1
  260. package/dist/admin/components/chipsInput.css +0 -11
  261. package/dist/admin/results/ArmTable.d.ts +0 -8
  262. package/dist/admin/results/ArmTable.js +0 -113
  263. package/dist/admin/results/VerdictCard.d.ts +0 -9
  264. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  265. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  266. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  267. 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
+ }
@@ -1,11 +1,12 @@
1
1
  import type { Payload } from 'payload';
2
2
  import { type Executor } from './executor.js';
3
- export declare function insertIdentity(exec: Executor, { anonymousId, userId }: {
3
+ export type IdentityWriteOutcome = 'linked' | 'verified' | 'skipped';
4
+ type IdentityInput = {
4
5
  anonymousId: string;
5
6
  userId: string;
6
- }): Promise<void>;
7
- /** §5.6 / §7.8: privileged insert, isolated from the caller's sign-in transaction. */
8
- export declare function insertIdentityOnOwnConnection(payload: Payload, identity: {
9
- anonymousId: string;
10
- userId: string;
11
- }): Promise<void>;
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 {};
@@ -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 NOTHING`, [
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
+ }
@@ -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
+ }
@@ -20,6 +20,8 @@ export interface MetricEventRow {
20
20
  export interface IdentityRow {
21
21
  anonymousId: string;
22
22
  userId: string;
23
+ verifiedAt: number | null;
24
+ ownedAt: number | null;
23
25
  }
24
26
  type PhaseQuery = {
25
27
  experimentId: DocId;
@@ -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
- const result = await exec.query(`SELECT ${q(i.anonymousId)} AS "anonymousId", ${q(i.userId)} AS "userId" FROM ${qualifiedTable(exec, TABLES.identities)} WHERE ${q(i.anonymousId)} = ANY($1::text[]) ORDER BY ${q(i.anonymousId)}`, [
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;
@@ -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
- /** Called only behind the endpoint's readResults gate. Experiment collection
43
- * reads require manage, so read only the decision metadata needed by results. */
44
- export declare function resultsMetadata(exec: Executor, id: DocId): Promise<{
45
- status: string;
50
+ type ResultsMetadata = {
51
+ name: string | null;
52
+ kind: ExperimentKind;
53
+ status: Status;
46
54
  snapshotId: DocId | null;
47
55
  winnerArm: string | null;
48
- } | null>;
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 {};
@@ -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
- WHERE (p.${q(p.endedAt)} IS NULL OR p.${q(p.endedAt)} >= $1)
41
- AND NOT EXISTS (SELECT 1 FROM ${snapshots} frozen WHERE frozen.${q(s.experimentId)} = p.${q(p.experimentId)} AND frozen.${q(s.phase)} = p.${q(p.phase)}
42
- AND frozen.${q(s.computedAt)} > p.${q(p.endedAt)} + $2 * interval '1 millisecond')${experimentId === undefined ? '' : ` AND p.${q(p.experimentId)} = $3`}
43
- ORDER BY "latestComputedAt" NULLS FIRST, p.${q(p.experimentId)}, p.${q(p.phase)}`, params);
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 swept;
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 swept;
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 swept;
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
  }
@@ -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';
@@ -0,0 +1,2 @@
1
+ // Shared with the admin home; keep this server import path stable.
2
+ 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
- response[key].ready = !!input && checkStart(input).every((check)=>check.ok);
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
  }