@delmaredigital/payload-experiments 0.3.0-rc.1 → 0.3.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +74 -16
  2. package/dist/admin/PreviewLinks.js +45 -24
  3. package/dist/admin/TestsNavLink.d.ts +3 -1
  4. package/dist/admin/TestsNavLink.js +2 -2
  5. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  6. package/dist/admin/arms/arms.model.d.ts +1 -21
  7. package/dist/admin/arms/arms.model.js +1 -153
  8. package/dist/admin/copy/arms.d.ts +1 -6
  9. package/dist/admin/copy/arms.js +1 -16
  10. package/dist/admin/copy/readiness.d.ts +4 -1
  11. package/dist/admin/copy/readiness.js +3 -1
  12. package/dist/admin/copy/results.d.ts +2 -0
  13. package/dist/admin/copy/results.js +3 -1
  14. package/dist/admin/copy/review.d.ts +1 -1
  15. package/dist/admin/copy/review.js +1 -1
  16. package/dist/admin/copy/surface.d.ts +0 -1
  17. package/dist/admin/copy/surface.js +0 -1
  18. package/dist/admin/paths.d.ts +2 -1
  19. package/dist/admin/paths.js +2 -1
  20. package/dist/admin/previewLinks.model.d.ts +2 -0
  21. package/dist/admin/previewLinks.model.js +3 -0
  22. package/dist/admin/results/legacy.d.ts +16 -0
  23. package/dist/admin/results/legacy.js +27 -0
  24. package/dist/admin/results/results.model.d.ts +6 -6
  25. package/dist/admin/results/results.model.js +14 -5
  26. package/dist/admin/results/snapshot.d.ts +14 -0
  27. package/dist/admin/results/snapshot.js +3 -0
  28. package/dist/admin/surface/TestsHome.js +6 -9
  29. package/dist/admin/surface/goals/data.js +16 -16
  30. package/dist/admin/surface/home.model.d.ts +1 -0
  31. package/dist/admin/surface/newTest/NewTest.js +4 -4
  32. package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
  33. package/dist/admin/surface/readiness/readiness.model.js +8 -1
  34. package/dist/admin/surface/results/ResultsBody.js +2 -1
  35. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  36. package/dist/admin/surface/results/recommendation.model.js +10 -3
  37. package/dist/admin/surface/surface.css +0 -1
  38. package/dist/admin/surface/test/GoalSection.js +7 -15
  39. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  40. package/dist/admin/surface/test/testPage.model.js +1 -21
  41. package/dist/admin/surface/useTestDocument.js +2 -1
  42. package/dist/admin/useLayerUsage.js +14 -23
  43. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  44. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  45. package/dist/arms/copy.d.ts +6 -0
  46. package/dist/arms/copy.js +16 -0
  47. package/dist/arms/model.d.ts +23 -0
  48. package/dist/arms/model.js +173 -0
  49. package/dist/db/appends.d.ts +6 -2
  50. package/dist/db/appends.js +10 -4
  51. package/dist/db/locks.d.ts +6 -0
  52. package/dist/db/locks.js +13 -1
  53. package/dist/db/pageOccupancy.d.ts +12 -0
  54. package/dist/db/pageOccupancy.js +45 -0
  55. package/dist/db/snapshots.js +4 -3
  56. package/dist/exports/adminRsc.d.ts +1 -0
  57. package/dist/exports/adminRsc.js +1 -0
  58. package/dist/exports/index.d.ts +1 -1
  59. package/dist/exports/puck.d.ts +2 -1
  60. package/dist/exports/puck.js +1 -1
  61. package/dist/exports/puckEditor.d.ts +3 -1
  62. package/dist/exports/puckEditor.js +2 -1
  63. package/dist/exports/rsc.d.ts +1 -0
  64. package/dist/hooks/pageGuard.js +21 -3
  65. package/dist/lib/fetchAllDocs.d.ts +16 -0
  66. package/dist/lib/fetchAllDocs.js +56 -0
  67. package/dist/options.js +2 -9
  68. package/dist/plugin.js +4 -0
  69. package/dist/puck/ExperimentPicker.js +47 -16
  70. package/dist/puck/ExperimentSlot.d.ts +51 -1
  71. package/dist/puck/ExperimentSlot.js +23 -12
  72. package/dist/puck/copy.d.ts +5 -4
  73. package/dist/puck/copy.js +5 -4
  74. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  75. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  76. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  77. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  78. package/dist/puck/editor/ResultsReadout.js +99 -0
  79. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  80. package/dist/puck/editor/VersionBuilder.js +317 -0
  81. package/dist/puck/editor/armWrites.d.ts +7 -0
  82. package/dist/puck/editor/armWrites.js +71 -0
  83. package/dist/puck/editor/armsCache.d.ts +14 -0
  84. package/dist/puck/editor/armsCache.js +78 -0
  85. package/dist/puck/editor/copy.d.ts +33 -1
  86. package/dist/puck/editor/copy.js +45 -8
  87. package/dist/puck/editor/createTest.d.ts +1 -1
  88. package/dist/puck/editor/createTest.js +25 -14
  89. package/dist/puck/editor/editor.chrome.css +47 -161
  90. package/dist/puck/editor/editor.css +47 -161
  91. package/dist/puck/editor/editor.model.d.ts +4 -1
  92. package/dist/puck/editor/editor.model.js +10 -7
  93. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  94. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  95. package/dist/puck/experimentSlotField.d.ts +2 -0
  96. package/dist/puck/experimentSlotField.js +11 -3
  97. package/dist/puck/hostContext.d.ts +10 -0
  98. package/dist/puck/hostContext.js +12 -0
  99. package/dist/puck/routes.d.ts +12 -0
  100. package/dist/puck/routes.js +11 -0
  101. package/dist/puck/slotConfig.d.ts +1 -1
  102. package/dist/puck/slotConfig.js +2 -2
  103. package/dist/sanitizeReadinessDetail.js +10 -1
  104. package/dist/server/metadata.js +9 -2
  105. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  106. package/dist/server/normaliseContentRelationships.js +62 -0
  107. package/dist/server/recordContentChange.d.ts +1 -1
  108. package/dist/server/recordContentChange.js +17 -8
  109. package/dist/server/runResults.js +36 -4
  110. package/dist/transitions/checkStart.d.ts +5 -1
  111. package/dist/transitions/checkStart.js +10 -0
  112. package/dist/transitions/decide.js +16 -1
  113. package/dist/transitions/placements.d.ts +2 -6
  114. package/dist/transitions/placements.js +8 -1
  115. package/dist/transitions/preflightData.d.ts +8 -0
  116. package/dist/transitions/preflightData.js +116 -36
  117. package/dist/transitions/start.js +33 -3
  118. package/dist/types/contracts.d.ts +19 -13
  119. package/dist/types/endpoints.d.ts +1 -1
  120. package/dist/ui/Tabs.d.ts +4 -2
  121. package/dist/ui/Tabs.js +33 -26
  122. package/dist/ui/accent.d.ts +22 -3
  123. package/dist/ui/accent.js +74 -16
  124. package/package.json +5 -2
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { createUsePuck, walkTree } from '@puckeditor/core';
4
+ import { useEffect, useMemo, useState } from 'react';
5
+ import { EXPERIMENT_SLOT_TYPE } from '../../types/puck.js';
6
+ import { useArms } from './armsCache.js';
7
+ const usePuckSelector = createUsePuck();
8
+ const slots = [
9
+ 'control',
10
+ 'variantA',
11
+ 'variantB',
12
+ 'variantC'
13
+ ];
14
+ // Puck 0.24.0 dist/chunk-DQXMVPVM.mjs:8040 (item attribute), 8126–8135
15
+ // (direct zone containers). Zones have no stable attribute; avoid the CSS-module hash.
16
+ const zone = 'div[class*="Layer-zones"]';
17
+ const chain = (count)=>Array(count).fill(zone).join(' ~ ');
18
+ function SlotRules({ item: [id, testId, emptySlots] }) {
19
+ const known = useArms(testId ?? undefined);
20
+ const [mounted, setMounted] = useState(false);
21
+ useEffect(()=>{
22
+ setMounted(true);
23
+ }, []);
24
+ // CSS.escape is a browser API: SSR and the first hydration render emit no rules.
25
+ if (!mounted) return null;
26
+ const rules = slots.flatMap((slot, index)=>{
27
+ // Original always remains available. Unknown bound tests must reveal every zone.
28
+ if (slot === 'control' || testId !== null && (!known || known.arms.includes(slot))) return [];
29
+ if (!emptySlots[index]) return [];
30
+ // Exactly the nth zone sibling: the positive chain matches n onward and the
31
+ // negated chain excludes n+1 onward. Unlike nth-child, DropLine siblings do not count.
32
+ const selector = `li[data-puck-layer-tree-id="${CSS.escape(id)}"] > ${chain(index + 1)}:not(${chain(index + 2)})`;
33
+ return [
34
+ `${selector} { display: none; }`
35
+ ];
36
+ }).join('\n');
37
+ return /*#__PURE__*/ _jsx("style", {
38
+ "data-xp-outline": id,
39
+ children: rules
40
+ });
41
+ }
42
+ function ExperimentOutline({ children }) {
43
+ // Public selector API in Puck 0.24, rather than the deprecated usePuck hook.
44
+ const key = usePuckSelector((state)=>{
45
+ const { data } = state.appState;
46
+ const items = new Map();
47
+ const collect = (content)=>{
48
+ for (const item of content)if (item.type === EXPERIMENT_SLOT_TYPE) {
49
+ const testId = item.props.experiment?.id;
50
+ items.set(item.props.id, [
51
+ item.props.id,
52
+ testId === undefined ? null : String(testId),
53
+ slots.map((slot)=>{
54
+ const content = item.props[slot];
55
+ return !Array.isArray(content) || content.length === 0;
56
+ })
57
+ ]);
58
+ }
59
+ };
60
+ walkTree(data, state.config, (content)=>{
61
+ collect(content);
62
+ });
63
+ // walkTree visits slots inside legacy zones, but does not call back on the zone itself.
64
+ for (const content of Object.values(data.zones ?? {}))collect(content);
65
+ return JSON.stringify([
66
+ ...items.values()
67
+ ]);
68
+ });
69
+ // Only binding/emptiness changes rebuild the rules, not edits to other page fields.
70
+ const rules = useMemo(()=>JSON.parse(key).map((item)=>/*#__PURE__*/ _jsx(SlotRules, {
71
+ item: item
72
+ }, item[0])), [
73
+ key
74
+ ]);
75
+ return /*#__PURE__*/ _jsxs(_Fragment, {
76
+ children: [
77
+ children,
78
+ rules
79
+ ]
80
+ });
81
+ }
82
+ /** Add to Puck's plugins so its outline wrapper composes with the host's overrides. */ export function experimentOutlinePlugin() {
83
+ return {
84
+ name: 'experiment-outline',
85
+ overrides: {
86
+ outline: ExperimentOutline
87
+ }
88
+ };
89
+ }
@@ -1,5 +1,7 @@
1
1
  import type { CustomField } from '@puckeditor/core';
2
2
  import type { ExperimentRef } from '../types/puck.js';
3
+ import { type PuckRoutes } from './routes.js';
3
4
  export declare function createExperimentSlotField(opts?: {
4
5
  label?: string;
6
+ routes?: PuckRoutes;
5
7
  }): CustomField<ExperimentRef | null>;
@@ -1,13 +1,21 @@
1
1
  import { createElement } from 'react';
2
2
  import { ExperimentPicker } from './ExperimentPicker.js';
3
+ import { resolvePuckRoutes } from './routes.js';
4
+ import { PuckHostProvider } from './hostContext.js';
3
5
  import { pickerCopy } from './copy.js';
4
6
  export function createExperimentSlotField(opts) {
7
+ const routes = resolvePuckRoutes(opts?.routes);
5
8
  return {
6
9
  type: 'custom',
7
10
  label: opts?.label ?? pickerCopy.label,
8
- render: ({ value, onChange })=>createElement(ExperimentPicker, {
9
- value,
10
- onChange
11
+ render: ({ value, onChange })=>createElement(PuckHostProvider, {
12
+ routes,
13
+ children: createElement('div', {
14
+ className: 'xp-puck-field'
15
+ }, createElement(ExperimentPicker, {
16
+ value,
17
+ onChange
18
+ }))
11
19
  })
12
20
  };
13
21
  }
@@ -0,0 +1,10 @@
1
+ import { resolvePuckRoutes } from './routes.js';
2
+ import type { ReactNode } from 'react';
3
+ export declare const usePuckRoutes: () => {
4
+ admin: string;
5
+ api: string;
6
+ };
7
+ export declare function PuckHostProvider({ routes, children }: {
8
+ routes: ReturnType<typeof resolvePuckRoutes>;
9
+ children: ReactNode;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext } from 'react';
4
+ import { resolvePuckRoutes } from './routes.js';
5
+ const PuckHostContext = /*#__PURE__*/ createContext(resolvePuckRoutes());
6
+ export const usePuckRoutes = ()=>useContext(PuckHostContext);
7
+ export function PuckHostProvider({ routes, children }) {
8
+ return /*#__PURE__*/ _jsx(PuckHostContext.Provider, {
9
+ value: routes,
10
+ children: children
11
+ });
12
+ }
@@ -0,0 +1,12 @@
1
+ export interface PuckRoutes {
2
+ admin?: string;
3
+ api?: string;
4
+ }
5
+ export declare const DEFAULT_PUCK_ROUTES: {
6
+ readonly admin: "/admin";
7
+ readonly api: "/api";
8
+ };
9
+ export declare function resolvePuckRoutes(routes?: PuckRoutes): {
10
+ admin: string;
11
+ api: string;
12
+ };
@@ -0,0 +1,11 @@
1
+ // Payload's documented route defaults; hosts pass config.routes when customized.
2
+ export const DEFAULT_PUCK_ROUTES = {
3
+ admin: '/admin',
4
+ api: '/api'
5
+ };
6
+ export function resolvePuckRoutes(routes) {
7
+ return {
8
+ admin: (routes?.admin ?? DEFAULT_PUCK_ROUTES.admin).replace(/\/$/, ''),
9
+ api: (routes?.api ?? DEFAULT_PUCK_ROUTES.api).replace(/\/$/, '')
10
+ };
11
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ComponentConfig } from '@puckeditor/core';
2
2
  import type { ExperimentSlotProps } from '../types/puck.js';
3
- export declare const experimentSlotLabel: "Experiment";
3
+ export declare const experimentSlotLabel = "Experiment block";
4
4
  export declare const experimentSlotFields: NonNullable<ComponentConfig<ExperimentSlotProps>['fields']>;
5
5
  export declare const experimentSlotDefaultProps: NonNullable<ComponentConfig<ExperimentSlotProps>['defaultProps']>;
@@ -1,6 +1,6 @@
1
1
  import { createExperimentSlotField } from './experimentSlotField.js';
2
- import { pickerCopy, slotLabels } from './copy.js';
3
- export const experimentSlotLabel = pickerCopy.label;
2
+ import { slotLabels } from './copy.js';
3
+ export const experimentSlotLabel = 'Experiment block';
4
4
  export const experimentSlotFields = {
5
5
  experiment: createExperimentSlotField(),
6
6
  // Puck 0.24's defaultSlots prepends each slot, reversing declaration order in the outline.
@@ -8,6 +8,7 @@ const arm = (value)=>typeof value === 'string' && [
8
8
  'variantC',
9
9
  'holdout'
10
10
  ].includes(value);
11
+ const path = (value)=>typeof value === 'string' && value.length <= 128 && value.startsWith('/') && !value.includes('//') && !/[^A-Za-z0-9_/-]/.test(value);
11
12
  const failure = (value)=>value === 'placements-failed' || value === 'forbidden' || value === 'not-found';
12
13
  /** Summary details contain identifiers only. Project known shapes rather than
13
14
  * recursively copying host objects: titles, messages and placement detail are text.
@@ -21,9 +22,17 @@ const failure = (value)=>value === 'placements-failed' || value === 'forbidden'
21
22
  } : undefined;
22
23
  case 'no-placements':
23
24
  return failure(detail) ? detail : undefined;
25
+ case 'page-in-use':
26
+ {
27
+ if (!object(detail) || !Array.isArray(detail.paths)) return undefined;
28
+ const paths = detail.paths.filter(path);
29
+ return paths.length ? {
30
+ paths
31
+ } : undefined;
32
+ }
24
33
  case 'page-target-unpublished':
25
34
  // Paths only: no query, fragment, whitespace, URL authority or encoded text.
26
- return typeof detail === 'string' && detail.length <= 128 && detail.startsWith('/') && !detail.includes('//') && !/[^A-Za-z0-9_/-]/.test(detail) ? detail : undefined;
35
+ return path(detail) ? detail : undefined;
27
36
  case 'flag-params-invalid':
28
37
  {
29
38
  if (!Array.isArray(detail) || !detail.length || !detail.every((item)=>object(item) && arm(item.arm) && key(item.field))) return undefined;
@@ -13,14 +13,21 @@ import { serverManifest } from './context.js';
13
13
  const live = manifest?.experiments.find((e)=>e.kind === 'page' && e.arms.some((a)=>a.pageTarget !== undefined && normalisePath(a.pageTarget) === normalisePath(path)));
14
14
  if (live) {
15
15
  const controlPath = live.arms.find((a)=>a.kind === 'control')?.pageTarget;
16
- return controlPath && normalisePath(path) !== normalisePath(controlPath) ? {
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) ? {
17
20
  robots: {
18
21
  index: false
19
22
  },
20
23
  alternates: {
21
24
  canonical: controlPath
22
25
  }
23
- } : {};
26
+ } : {
27
+ alternates: {
28
+ canonical: controlPath
29
+ }
30
+ };
24
31
  }
25
32
  const archivedPath = manifest ? findNormalisedPathKey(manifest.archivedPages, path) : undefined;
26
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 after = bindings(registration ? doc[registration.field] : doc, Boolean(registration));
101
- const changes = (previous)=>{
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
- const changed = changes(drafts ? baseline?.doc : previousDoc);
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);
@@ -62,19 +62,50 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
62
62
  ];
63
63
  const linked = new Set(userIds);
64
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
+ };
65
84
  const credit = (events, userKeyed)=>{
66
85
  if (candidate.unitType === 'user') return events.map((event)=>({
67
86
  ...event,
68
87
  unitId: event.userId ?? event.unitId
69
88
  }));
70
- const userEvents = events.filter((event)=>userKeyed || event.userId != null && linked.has(event.userId)).map((event)=>({
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)=>({
71
94
  userId: String(event.userId ?? event.unitId),
72
95
  value: event.value,
73
96
  ts: event.ts
74
97
  }));
75
98
  const credited = creditUserEvents(units, identities, userEvents, (userId)=>identityConflicts.add(userId)).events;
76
- return userKeyed ? credited : [
77
- ...events.filter((event)=>event.userId == null || !linked.has(event.userId)),
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,
78
109
  ...credited
79
110
  ];
80
111
  };
@@ -111,9 +142,10 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
111
142
  from: start,
112
143
  to
113
144
  });
145
+ // The two lookups overlap when a linked user's own browser was exposed.
114
146
  return [
115
147
  ...direct,
116
- ...signedIn
148
+ ...signedIn.filter((event)=>!units.has(event.unitId))
117
149
  ];
118
150
  };
119
151
  events = credit(await read(phase.analysis.cuped === 'auto' ? preFrom : from), false);
@@ -1,7 +1,7 @@
1
1
  import type { Arm, DocId, ExperimentKind, ExposurePolicy, FlagPoint, MetricSource, ResolvedOptions, Status, TargetingRule, UnitType } from '../types/contracts.js';
2
2
  import type { ArmIssueCode, PreflightResult } from '../types/endpoints.js';
3
3
  import type { BlockPlacement, FlagPlacement } from './placements.js';
4
- export type ReadinessCode = ArmIssueCode | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'page-no-targets' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
4
+ export type ReadinessCode = ArmIssueCode | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'page-no-targets' | 'page-in-use' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
5
5
  export interface StartExperiment {
6
6
  id: DocId;
7
7
  slug: string;
@@ -51,6 +51,7 @@ export interface CheckStartInput {
51
51
  flagPointBusy?: boolean;
52
52
  unpublishedPageTargets?: string[];
53
53
  pageTargetsUnchecked?: boolean;
54
+ pageTestsInUse?: Pick<StartExperiment, 'id' | 'arms'>[];
54
55
  layer?: {
55
56
  unitType: UnitType;
56
57
  usedSlices: number[];
@@ -61,5 +62,8 @@ export declare const ARM_CHECK_ORDER: readonly ArmIssueCode[];
61
62
  export declare const relationshipId: (value: DocId | {
62
63
  id: DocId;
63
64
  } | null | undefined) => DocId | undefined;
65
+ /** Every path the runtime would match for these arms (pageRewrite, experimentMetadata): any string
66
+ * pageTarget, holdout included, normalised the same way ('' is '/'). Occupancy and lock keys use this. */
67
+ export declare const pageTargetPaths: (arms: readonly Arm[]) => string[];
64
68
  /** Pure Review/Start predicates in refusal order. Inapplicable checks pass. */
65
69
  export declare function checkStart(input: CheckStartInput): PreflightResult['checks'];
@@ -1,4 +1,5 @@
1
1
  import { checkArms } from '../collections/experiments/armInvariants.js';
2
+ import { normalisePath } from '../runtime/normalisePath.js';
2
3
  import { isOpValid } from '../targeting/ops.js';
3
4
  export const ARM_CHECK_ORDER = [
4
5
  'weights-total',
@@ -13,6 +14,10 @@ export const ARM_CHECK_ORDER = [
13
14
  'duplicate-slot'
14
15
  ];
15
16
  export const relationshipId = (value)=>value == null ? undefined : typeof value === 'object' ? value.id : value;
17
+ /** Every path the runtime would match for these arms (pageRewrite, experimentMetadata): any string
18
+ * pageTarget, holdout included, normalised the same way ('' is '/'). Occupancy and lock keys use this. */ export const pageTargetPaths = (arms)=>[
19
+ ...new Set(arms.filter((arm)=>typeof arm.pageTarget === 'string').map((arm)=>normalisePath(arm.pageTarget)))
20
+ ].sort();
16
21
  const positive = (value)=>typeof value === 'number' && Number.isFinite(value) && value > 0;
17
22
  function invalidParams(arms, point) {
18
23
  return arms.flatMap((arm)=>{
@@ -56,6 +61,11 @@ function invalidParams(arms, point) {
56
61
  'viewport'
57
62
  ].includes(e.exposurePolicy) || positive(e.analysis?.reachPerDay));
58
63
  add('page-no-targets', e.kind !== 'page' || e.arms.filter((arm)=>arm.kind !== 'holdout').every((arm)=>typeof arm.pageTarget === 'string' && arm.pageTarget.trim().length > 0));
64
+ const occupiedPaths = new Set((input.pageTestsInUse ?? []).filter((other)=>String(other.id) !== String(e.id)).flatMap((other)=>pageTargetPaths(other.arms)));
65
+ const sharedPaths = e.kind === 'page' ? pageTargetPaths(e.arms).filter((path)=>occupiedPaths.has(path)) : [];
66
+ add('page-in-use', sharedPaths.length === 0, {
67
+ paths: sharedPaths
68
+ });
59
69
  add('page-target-unpublished', !input.unpublishedPageTargets?.length, input.unpublishedPageTargets?.[0]);
60
70
  add('page-targets-unchecked', true);
61
71
  if (input.pageTargetsUnchecked) checks[checks.length - 1].warning = true;
@@ -1,7 +1,9 @@
1
1
  import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
2
2
  import { reclaimSlices } from '../db/slices.js';
3
3
  import { decideGuard } from '../results/decideGuard.js';
4
- import { relationshipId } from './checkStart.js';
4
+ import { lockPagePaths } from '../db/locks.js';
5
+ import { routedPageTests } from '../db/pageOccupancy.js';
6
+ import { pageTargetPaths, relationshipId } from './checkStart.js';
5
7
  import { expectedConfirm, matchesConfirm, storedWinnerLabel } from './confirmPhrases.js';
6
8
  import { withTransition } from './kernel.js';
7
9
  import { readPhaseRecord, setPhaseEndedAt, writePhaseRecord } from './phaseRecord.js';
@@ -45,6 +47,19 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
45
47
  }
46
48
  const test = await readPhaseRecord(req, experimentId, current.phase);
47
49
  if (test.phaseKind !== 'test') refuse('invalid-status');
50
+ if (current.kind === 'page' && current.status === 'stopped') {
51
+ // Deciding a stopped page test restores its routing: recheck page occupancy under the
52
+ // sorted path locks Start takes, so another test that started on these pages meanwhile wins.
53
+ // The frozen test-round arms are what decide restores; the stopped document's arms may have been edited.
54
+ const mine = pageTargetPaths(test.arms);
55
+ await lockPagePaths(exec, mine);
56
+ // Other tests' routed (frozen-round) arms, as the runtime matches them.
57
+ const occupied = new Set((await routedPageTests(exec)).filter((other)=>String(other.id) !== String(experimentId)).flatMap((other)=>pageTargetPaths(other.arms)));
58
+ const shared = mine.filter((path)=>occupied.has(path));
59
+ if (shared.length) refuse('page-in-use', {
60
+ paths: shared
61
+ });
62
+ }
48
63
  const winner = test.arms.find((arm)=>arm.key === winnerArm && arm.kind !== 'holdout');
49
64
  if (!winner) refuse('invalid-winner');
50
65
  // §7.8: decision snapshot lookup serves the authorised transition.
@@ -1,11 +1,7 @@
1
1
  import type { PayloadRequest } from 'payload';
2
- import type { Arm, DocId, FlagPoint, ResolvedOptions } from '../types/contracts.js';
2
+ import type { Arm, DocId, FlagPoint, PreviewPlacement, ResolvedOptions } from '../types/contracts.js';
3
3
  import type { ArmSlot } from '../types/puck.js';
4
- export interface BlockPlacement {
5
- collection: string;
6
- id: DocId;
7
- title: string;
8
- published: boolean;
4
+ export interface BlockPlacement extends PreviewPlacement {
9
5
  slots: Record<ArmSlot, number>;
10
6
  locked?: boolean;
11
7
  localized?: boolean;
@@ -24,6 +24,7 @@ export async function scanBlockPlacements(req, args) {
24
24
  }
25
25
  })){
26
26
  const publishedIds = new Set();
27
+ const publishedSlugs = new Map();
27
28
  const draftsEnabled = req.payload.config.collections?.some((config)=>config.slug === collection && config.versions && config.versions.drafts);
28
29
  for (const draft of [
29
30
  false,
@@ -51,7 +52,10 @@ export async function scanBlockPlacements(req, args) {
51
52
  for (const doc of result.docs){
52
53
  if (!draft && doc._status === 'draft') continue;
53
54
  if (draft && doc._status !== 'draft' && publishedIds.has(String(doc.id))) continue;
54
- if (!draft) publishedIds.add(String(doc.id));
55
+ if (!draft) {
56
+ publishedIds.add(String(doc.id));
57
+ if (typeof doc.slug === 'string') publishedSlugs.set(String(doc.id), doc.slug);
58
+ }
55
59
  function walk(value) {
56
60
  if (!value || typeof value !== 'object') return;
57
61
  if (Array.isArray(value)) {
@@ -68,6 +72,9 @@ export async function scanBlockPlacements(req, args) {
68
72
  id: doc.id,
69
73
  title: String(doc.title ?? doc.name ?? doc.id),
70
74
  published: !draft || doc._status === 'published',
75
+ ...publishedSlugs.has(String(doc.id)) ? {
76
+ slug: publishedSlugs.get(String(doc.id))
77
+ } : {},
71
78
  slots: Object.fromEntries(slots.map((slot)=>[
72
79
  slot,
73
80
  Array.isArray(props[slot]) ? props[slot].length : 0
@@ -13,6 +13,14 @@ export interface PreflightDependencies {
13
13
  export declare function loadStartInputs<TUser extends {
14
14
  id: DocId;
15
15
  }>(req: PayloadRequest, options: ResolvedOptions<TUser>, experimentId: DocId): Promise<CheckStartInput | null>;
16
+ /** Occupancy from the arms the runtime routes on (frozen rounds), shared by preflight and
17
+ * Start's locked recheck. Raw SQL on the request's transaction when there is one. */
18
+ export declare function loadPageTestsInUse(req: PayloadRequest): Promise<NonNullable<CheckStartInput['pageTestsInUse']>>;
19
+ /** Page targets no registered collection reports as published. A lookup that throws counts as
20
+ * unpublished (logged). Start calls it again under its page-path locks on a detached request. */
21
+ export declare function unpublishedPageTargets<TUser extends {
22
+ id: DocId;
23
+ }>(req: PayloadRequest, options: ResolvedOptions<TUser>, experiment: Pick<StartExperiment, 'id' | 'arms'>): Promise<string[]>;
16
24
  /** Shared, request-scoped reads for summary; a failed row is null in tolerant mode.
17
25
  * Preflight/Start use the same builder and retain their throwing read behaviour.
18
26
  */