@delmaredigital/payload-experiments 0.1.0-rc.7 → 0.2.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 (207) hide show
  1. package/README.md +28 -0
  2. package/dist/admin/LayerUsage.d.ts +1 -1
  3. package/dist/admin/LayerUsage.js +23 -11
  4. package/dist/admin/MetricSourceSelect.js +2 -1
  5. package/dist/admin/TestsNavLink.d.ts +1 -0
  6. package/dist/admin/TestsNavLink.js +30 -0
  7. package/dist/admin/allocation.model.js +1 -1
  8. package/dist/admin/copy/actions.d.ts +0 -2
  9. package/dist/admin/copy/actions.js +0 -2
  10. package/dist/admin/copy/advanced.d.ts +55 -0
  11. package/dist/admin/copy/advanced.js +55 -0
  12. package/dist/admin/copy/armSelect.js +1 -1
  13. package/dist/admin/copy/arms.d.ts +0 -13
  14. package/dist/admin/copy/arms.js +0 -13
  15. package/dist/admin/copy/{who.d.ts → audience.d.ts} +1 -9
  16. package/dist/admin/copy/{who.js → audience.js} +1 -9
  17. package/dist/admin/copy/history.d.ts +0 -4
  18. package/dist/admin/copy/history.js +3 -7
  19. package/dist/admin/copy/metrics.d.ts +2 -2
  20. package/dist/admin/copy/metrics.js +2 -2
  21. package/dist/admin/copy/newTest.d.ts +32 -0
  22. package/dist/admin/copy/newTest.js +32 -0
  23. package/dist/admin/copy/readiness.d.ts +45 -0
  24. package/dist/admin/copy/readiness.js +50 -0
  25. package/dist/admin/copy/recommendation.d.ts +26 -0
  26. package/dist/admin/copy/recommendation.js +25 -0
  27. package/dist/admin/copy/results.d.ts +0 -1
  28. package/dist/admin/copy/results.js +0 -1
  29. package/dist/admin/copy/review.d.ts +0 -17
  30. package/dist/admin/copy/review.js +1 -112
  31. package/dist/admin/copy/setting.d.ts +10 -0
  32. package/dist/admin/copy/setting.js +10 -0
  33. package/dist/admin/copy/surface.d.ts +36 -0
  34. package/dist/admin/copy/surface.js +47 -0
  35. package/dist/admin/copy/testPage.d.ts +88 -0
  36. package/dist/admin/copy/testPage.js +88 -0
  37. package/dist/admin/paths.d.ts +5 -13
  38. package/dist/admin/paths.js +5 -13
  39. package/dist/admin/review/review.model.d.ts +3 -26
  40. package/dist/admin/review/review.model.js +3 -30
  41. package/dist/admin/surface/ResultsRoute.d.ts +7 -0
  42. package/dist/admin/surface/ResultsRoute.js +58 -0
  43. package/dist/admin/surface/SurfaceShell.d.ts +6 -0
  44. package/dist/admin/surface/SurfaceShell.js +35 -0
  45. package/dist/admin/surface/TestsHome.d.ts +4 -0
  46. package/dist/admin/surface/TestsHome.js +212 -0
  47. package/dist/admin/surface/TestsSurface.d.ts +2 -0
  48. package/dist/admin/surface/TestsSurface.js +36 -0
  49. package/dist/admin/surface/contracts.d.ts +48 -0
  50. package/dist/admin/surface/contracts.js +8 -0
  51. package/dist/admin/surface/home.model.d.ts +41 -0
  52. package/dist/admin/surface/home.model.js +59 -0
  53. package/dist/admin/surface/newTest/NewTest.d.ts +7 -0
  54. package/dist/admin/surface/newTest/NewTest.js +355 -0
  55. package/dist/admin/surface/newTest/newTest.css +9 -0
  56. package/dist/admin/surface/pageBlock/pageBlock.model.d.ts +6 -0
  57. package/dist/admin/surface/pageBlock/pageBlock.model.js +8 -0
  58. package/dist/admin/surface/pageBlock/parts.d.ts +2 -0
  59. package/dist/admin/surface/pageBlock/parts.js +64 -0
  60. package/dist/admin/surface/readiness/ActionDialogs.d.ts +15 -0
  61. package/dist/admin/surface/readiness/ActionDialogs.js +274 -0
  62. package/dist/admin/surface/readiness/Banner.d.ts +2 -0
  63. package/dist/admin/surface/readiness/Banner.js +138 -0
  64. package/dist/admin/surface/readiness/SaveStatus.d.ts +4 -0
  65. package/dist/admin/surface/readiness/SaveStatus.js +50 -0
  66. package/dist/admin/surface/readiness/parts.d.ts +2 -0
  67. package/dist/admin/surface/readiness/parts.js +9 -0
  68. package/dist/admin/surface/readiness/readiness.model.d.ts +22 -0
  69. package/dist/admin/surface/readiness/readiness.model.js +107 -0
  70. package/dist/admin/surface/readiness/useTransition.d.ts +10 -0
  71. package/dist/admin/surface/readiness/useTransition.js +34 -0
  72. package/dist/admin/surface/results/ResultsBody.d.ts +7 -0
  73. package/dist/admin/surface/results/ResultsBody.js +262 -0
  74. package/dist/admin/surface/results/recommendation.model.d.ts +12 -0
  75. package/dist/admin/surface/results/recommendation.model.js +59 -0
  76. package/dist/admin/surface/results/useResults.d.ts +56 -0
  77. package/dist/admin/surface/results/useResults.js +183 -0
  78. package/dist/admin/surface/setting/parts.d.ts +3 -0
  79. package/dist/admin/surface/setting/parts.js +263 -0
  80. package/dist/admin/surface/setting/setting.css +29 -0
  81. package/dist/admin/surface/setting/setting.model.d.ts +8 -0
  82. package/dist/admin/surface/setting/setting.model.js +25 -0
  83. package/dist/admin/surface/surface.css +50 -0
  84. package/dist/admin/surface/test/AdvancedSection.d.ts +2 -0
  85. package/dist/admin/surface/test/AdvancedSection.js +153 -0
  86. package/dist/admin/surface/test/GoalSection.d.ts +2 -0
  87. package/dist/admin/surface/test/GoalSection.js +198 -0
  88. package/dist/admin/surface/test/LengthSection.d.ts +2 -0
  89. package/dist/admin/surface/test/LengthSection.js +87 -0
  90. package/dist/admin/surface/test/Section.d.ts +7 -0
  91. package/dist/admin/surface/test/Section.js +27 -0
  92. package/dist/admin/surface/test/TargetingSection.d.ts +2 -0
  93. package/dist/admin/surface/test/TargetingSection.js +164 -0
  94. package/dist/admin/surface/test/TestHeader.d.ts +4 -0
  95. package/dist/admin/surface/test/TestHeader.js +69 -0
  96. package/dist/admin/surface/test/TestPage.d.ts +7 -0
  97. package/dist/admin/surface/test/TestPage.js +84 -0
  98. package/dist/admin/surface/test/VersionsSection.d.ts +4 -0
  99. package/dist/admin/surface/test/VersionsSection.js +208 -0
  100. package/dist/admin/surface/test/fields.d.ts +55 -0
  101. package/dist/admin/surface/test/fields.js +185 -0
  102. package/dist/admin/surface/test/pageParts.d.ts +2 -0
  103. package/dist/admin/surface/test/pageParts.js +39 -0
  104. package/dist/admin/surface/test/testPage.css +23 -0
  105. package/dist/admin/surface/test/testPage.model.d.ts +17 -0
  106. package/dist/admin/surface/test/testPage.model.js +53 -0
  107. package/dist/admin/surface/testDocument.model.d.ts +39 -0
  108. package/dist/admin/surface/testDocument.model.js +61 -0
  109. package/dist/admin/surface/useTestDocument.d.ts +22 -0
  110. package/dist/admin/surface/useTestDocument.js +345 -0
  111. package/dist/admin/targeting/targeting.model.js +2 -2
  112. package/dist/admin/useLayerUsage.d.ts +0 -5
  113. package/dist/admin/useLayerUsage.js +1 -39
  114. package/dist/admin/views/CollectionEditRedirect.d.ts +4 -0
  115. package/dist/admin/views/CollectionEditRedirect.js +14 -0
  116. package/dist/admin/views/CollectionListRedirect.d.ts +2 -0
  117. package/dist/admin/views/CollectionListRedirect.js +8 -0
  118. package/dist/admin/views/ExperimentsRootView.d.ts +2 -0
  119. package/dist/admin/views/ExperimentsRootView.js +175 -0
  120. package/dist/admin/views/access.model.d.ts +7 -0
  121. package/dist/admin/views/access.model.js +5 -0
  122. package/dist/collections/experiments/config.js +18 -125
  123. package/dist/collections/experiments/createDefaults.js +6 -0
  124. package/dist/collections/layers.js +43 -12
  125. package/dist/collections/metrics.js +70 -14
  126. package/dist/collections/serialize.js +4 -1
  127. package/dist/db/snapshots.d.ts +2 -1
  128. package/dist/db/snapshots.js +2 -2
  129. package/dist/endpoints/results.js +7 -0
  130. package/dist/endpoints/summary.d.ts +1 -1
  131. package/dist/endpoints/summary.js +37 -1
  132. package/dist/exports/adminClient.d.ts +2 -13
  133. package/dist/exports/adminClient.js +2 -13
  134. package/dist/exports/index.d.ts +1 -1
  135. package/dist/exports/rsc.d.ts +3 -0
  136. package/dist/exports/rsc.js +3 -0
  137. package/dist/options.js +29 -0
  138. package/dist/plugin.js +25 -0
  139. package/dist/puck/editor/CreateExperimentDrawer.d.ts +2 -1
  140. package/dist/puck/editor/CreateExperimentDrawer.js +17 -57
  141. package/dist/puck/editor/ExperimentSlotEditor.js +6 -0
  142. package/dist/puck/editor/copy.d.ts +10 -9
  143. package/dist/puck/editor/copy.js +11 -10
  144. package/dist/puck/editor/createTest.d.ts +7 -0
  145. package/dist/puck/editor/createTest.js +50 -0
  146. package/dist/puck/editor/editor.model.d.ts +28 -1
  147. package/dist/puck/editor/editor.model.js +61 -0
  148. package/dist/results/analyse.js +4 -1
  149. package/dist/results/verdict.d.ts +1 -0
  150. package/dist/results/verdict.js +7 -4
  151. package/dist/transitions/checkStart.d.ts +1 -0
  152. package/dist/transitions/placements.d.ts +5 -1
  153. package/dist/transitions/placements.js +14 -7
  154. package/dist/transitions/preflightData.d.ts +7 -1
  155. package/dist/transitions/preflightData.js +199 -148
  156. package/dist/transitions/start.js +2 -2
  157. package/dist/types/admin.d.ts +1 -0
  158. package/dist/types/contracts.d.ts +17 -0
  159. package/dist/types/endpoints.d.ts +11 -1
  160. package/dist/types/snapshot.d.ts +1 -0
  161. package/dist/types/surface.d.ts +29 -0
  162. package/dist/types/surface.js +1 -0
  163. package/package.json +1 -1
  164. package/dist/access.d.ts +0 -1
  165. package/dist/access.js +0 -2
  166. package/dist/admin/AllocationField.d.ts +0 -2
  167. package/dist/admin/AllocationField.js +0 -80
  168. package/dist/admin/FlagPointSelect.d.ts +0 -2
  169. package/dist/admin/FlagPointSelect.js +0 -54
  170. package/dist/admin/LockedFieldNotice.d.ts +0 -2
  171. package/dist/admin/LockedFieldNotice.js +0 -26
  172. package/dist/admin/PhaseHistory.d.ts +0 -2
  173. package/dist/admin/PhaseHistory.js +0 -76
  174. package/dist/admin/StatusCell.d.ts +0 -3
  175. package/dist/admin/StatusCell.js +0 -19
  176. package/dist/admin/SummaryCell.d.ts +0 -2
  177. package/dist/admin/SummaryCell.js +0 -32
  178. package/dist/admin/actions/AddMetricDialog.d.ts +0 -6
  179. package/dist/admin/actions/AddMetricDialog.js +0 -138
  180. package/dist/admin/actions/AllocateDialog.d.ts +0 -6
  181. package/dist/admin/actions/AllocateDialog.js +0 -81
  182. package/dist/admin/actions/ExperimentActions.d.ts +0 -3
  183. package/dist/admin/actions/ExperimentActions.js +0 -110
  184. package/dist/admin/arms/ArmsField.d.ts +0 -2
  185. package/dist/admin/arms/ArmsField.js +0 -310
  186. package/dist/admin/arms/ParamsEditor.d.ts +0 -16
  187. package/dist/admin/arms/ParamsEditor.js +0 -79
  188. package/dist/admin/copy/tabs.d.ts +0 -7
  189. package/dist/admin/copy/tabs.js +0 -7
  190. package/dist/admin/permissions.model.d.ts +0 -10
  191. package/dist/admin/permissions.model.js +0 -4
  192. package/dist/admin/phaseHistory.model.d.ts +0 -19
  193. package/dist/admin/phaseHistory.model.js +0 -19
  194. package/dist/admin/results/ResultsView.d.ts +0 -3
  195. package/dist/admin/results/ResultsView.js +0 -312
  196. package/dist/admin/review/ReviewPanel.d.ts +0 -2
  197. package/dist/admin/review/ReviewPanel.js +0 -268
  198. package/dist/admin/summaryBatcher.d.ts +0 -8
  199. package/dist/admin/summaryBatcher.js +0 -33
  200. package/dist/admin/targeting/TargetingAttributeField.d.ts +0 -2
  201. package/dist/admin/targeting/TargetingAttributeField.js +0 -31
  202. package/dist/admin/targeting/TargetingOpField.d.ts +0 -2
  203. package/dist/admin/targeting/TargetingOpField.js +0 -45
  204. package/dist/admin/targeting/TargetingValueField.d.ts +0 -2
  205. package/dist/admin/targeting/TargetingValueField.js +0 -142
  206. package/dist/index.d.ts +0 -1
  207. package/dist/index.js +0 -4
@@ -19,164 +19,216 @@ const failureDetail = (cause)=>cause instanceof Error ? cause.message : 'Placeme
19
19
  });
20
20
  if (!found.docs[0]) return null;
21
21
  const experiment = found.docs[0];
22
- const ids = [
22
+ return (await loadStartInputsBatch(req, options, [
23
+ experiment
24
+ ])).get(String(experiment.id)) ?? null;
25
+ }
26
+ const metricIds = (experiment)=>[
23
27
  experiment.metrics?.primary,
24
28
  ...experiment.metrics?.secondary ?? [],
25
29
  ...experiment.metrics?.guardrails ?? []
26
30
  ].map(relationshipId).filter((id)=>id !== undefined);
27
- const metrics = [];
28
- if (ids.length) {
29
- let page = 1;
30
- while(true){
31
- const result = await req.payload.find({
32
- collection: 'xp-metrics',
33
- req,
34
- overrideAccess: false,
35
- depth: 0,
36
- limit: 100,
37
- page,
38
- where: {
39
- id: {
40
- in: ids
41
- }
42
- }
43
- });
44
- metrics.push(...result.docs);
45
- if (!result.hasNextPage) break;
46
- page++;
47
- }
48
- }
49
- const input = {
50
- experiment,
51
- metrics,
52
- metricSources: options.metricSources,
53
- flagPoints: options.flagPoints,
54
- attributeKeys: options.attributeKeys
55
- };
56
- if (experiment.kind === 'block') {
57
- try {
58
- input.placements = await scanBlockPlacements(req, {
59
- slug: experiment.slug,
60
- puckCollections: options.puck?.collections ?? []
61
- });
62
- } catch (cause) {
63
- input.placementError = failureDetail(cause);
64
- }
65
- }
66
- if (experiment.kind === 'flag') {
67
- const point = options.flagPoints?.find((point)=>point.id === experiment.flagPoint);
68
- if (point) {
69
- // Match the save hook: the control always uses effective point defaults.
70
- let params = point.defaultParams;
71
- try {
72
- const state = await req.payload.findGlobal({
73
- slug: 'xp-state',
74
- req,
75
- overrideAccess: false,
76
- depth: 0
77
- });
78
- const defaults = state.flagDefaults;
79
- params = defaults?.[point.id]?.params ?? params;
80
- } catch (cause) {
81
- const error = cause;
82
- if (error?.status !== 403 && error?.name !== 'Forbidden') throw cause;
83
- }
84
- experiment.arms = experiment.arms.map((arm)=>arm.kind === 'control' ? {
85
- ...arm,
86
- params
87
- } : arm);
88
- }
89
- if (point?.placements) {
90
- try {
91
- const placements = await flagPlacements(options, experiment, req);
92
- if (Array.isArray(placements) && placements.every((p)=>p && typeof p === 'object' && typeof p.arm === 'string')) input.flagPlacements = placements;
93
- else input.flagPlacementError = 'Placement lookup returned an invalid result.';
94
- } catch (cause) {
95
- input.flagPlacementError = failureDetail(cause);
96
- }
97
- }
98
- if (experiment.flagPoint) {
99
- // At most one row can be this experiment; two rows suffice to find a peer.
100
- const busy = await req.payload.find({
101
- collection: 'xp-experiments',
102
- req,
103
- overrideAccess: false,
104
- depth: 0,
105
- limit: 2,
106
- where: {
107
- and: [
108
- {
109
- flagPoint: {
110
- equals: experiment.flagPoint
111
- }
112
- },
113
- {
114
- status: {
115
- in: [
116
- 'running',
117
- 'decided'
118
- ]
119
- }
120
- }
121
- ]
122
- }
123
- });
124
- input.flagPointBusy = busy.docs.some((doc)=>String(doc.id) !== String(experiment.id));
125
- }
126
- }
127
- const layerId = relationshipId(experiment.layer);
128
- if (layerId !== undefined) {
129
- const layers = await req.payload.find({
130
- collection: 'xp-layers',
31
+ /** Shared, request-scoped reads for summary; a failed row is null in tolerant mode.
32
+ * Preflight/Start use the same builder and retain their throwing read behaviour.
33
+ */ export async function loadStartInputsBatch(req, options, experiments, tolerateErrors = false) {
34
+ const ids = [
35
+ ...new Set(experiments.flatMap(metricIds))
36
+ ];
37
+ const metrics = (async ()=>{
38
+ if (!ids.length) return [];
39
+ const result = await req.payload.find({
40
+ collection: 'xp-metrics',
131
41
  req,
132
42
  overrideAccess: false,
133
43
  depth: 0,
134
- limit: 1,
44
+ pagination: false,
45
+ limit: ids.length,
135
46
  where: {
136
47
  id: {
137
- equals: layerId
48
+ in: ids
138
49
  }
139
50
  }
140
51
  });
141
- const layer = layers.docs[0];
142
- const usedSlices = [];
143
- let page = 1;
144
- while(true){
145
- const result = await req.payload.find({
146
- collection: 'xp-experiments',
147
- req,
148
- overrideAccess: false,
149
- depth: 0,
150
- limit: 100,
151
- page,
152
- where: {
153
- and: [
154
- {
155
- layer: {
52
+ return result.docs;
53
+ })();
54
+ const blockSlugs = new Set(experiments.filter((e)=>e.kind === 'block').map((e)=>e.slug));
55
+ // Lazy promises prevent unhandled rejections and share failed reads as well as successful ones.
56
+ let blocks;
57
+ let state;
58
+ const busyPoints = new Map();
59
+ const layers = new Map();
60
+ const inputs = new Map();
61
+ for (const experiment of experiments){
62
+ try {
63
+ const ownIds = new Set(metricIds(experiment).map(String));
64
+ const input = {
65
+ experiment,
66
+ metrics: (await metrics).filter((metric)=>ownIds.has(String(metric.id))),
67
+ metricSources: options.metricSources,
68
+ flagPoints: options.flagPoints,
69
+ attributeKeys: options.attributeKeys
70
+ };
71
+ if (experiment.kind === 'block') {
72
+ try {
73
+ blocks ??= scanBlockPlacements(req, {
74
+ slugs: blockSlugs,
75
+ puckCollections: options.puck?.collections ?? []
76
+ });
77
+ input.placements = (await blocks).get(experiment.slug) ?? [];
78
+ } catch (cause) {
79
+ input.placementError = failureDetail(cause);
80
+ }
81
+ }
82
+ if (experiment.kind === 'flag') {
83
+ const point = options.flagPoints?.find((point)=>point.id === experiment.flagPoint);
84
+ if (point) {
85
+ state ??= (async ()=>{
86
+ try {
87
+ const doc = await req.payload.findGlobal({
88
+ slug: 'xp-state',
89
+ req,
90
+ overrideAccess: false,
91
+ depth: 0
92
+ });
93
+ return doc.flagDefaults;
94
+ } catch (cause) {
95
+ const error = cause;
96
+ if (error?.status !== 403 && error?.name !== 'Forbidden') throw cause;
97
+ // Match the existing preflight fallback when the global is access-filtered.
98
+ return undefined;
99
+ }
100
+ })();
101
+ const defaults = await state;
102
+ experiment.arms = experiment.arms.map((arm)=>arm.kind === 'control' ? {
103
+ ...arm,
104
+ params: defaults?.[point.id]?.params ?? point.defaultParams
105
+ } : arm);
106
+ }
107
+ if (point?.placements) {
108
+ try {
109
+ const placements = await flagPlacements(options, experiment, req);
110
+ if (Array.isArray(placements) && placements.every((p)=>p && typeof p === 'object' && typeof p.arm === 'string')) input.flagPlacements = placements;
111
+ else input.flagPlacementError = 'Placement lookup returned an invalid result.';
112
+ } catch (cause) {
113
+ input.flagPlacementError = failureDetail(cause);
114
+ }
115
+ }
116
+ if (experiment.flagPoint) {
117
+ const key = experiment.flagPoint;
118
+ if (!busyPoints.has(key)) busyPoints.set(key, (async ()=>{
119
+ // Two rows suffice to find a peer, including when the batch contains that peer.
120
+ const busy = await req.payload.find({
121
+ collection: 'xp-experiments',
122
+ req,
123
+ overrideAccess: false,
124
+ depth: 0,
125
+ limit: 2,
126
+ where: {
127
+ and: [
128
+ {
129
+ flagPoint: {
130
+ equals: key
131
+ }
132
+ },
133
+ {
134
+ status: {
135
+ in: [
136
+ 'running',
137
+ 'decided'
138
+ ]
139
+ }
140
+ }
141
+ ]
142
+ }
143
+ });
144
+ return busy.docs;
145
+ })());
146
+ input.flagPointBusy = (await busyPoints.get(key)).some((doc)=>String(doc.id) !== String(experiment.id));
147
+ }
148
+ }
149
+ const layerId = relationshipId(experiment.layer);
150
+ if (layerId !== undefined) {
151
+ const key = String(layerId);
152
+ if (!layers.has(key)) layers.set(key, (async ()=>{
153
+ const result = await req.payload.find({
154
+ collection: 'xp-layers',
155
+ req,
156
+ overrideAccess: false,
157
+ depth: 0,
158
+ limit: 1,
159
+ where: {
160
+ id: {
156
161
  equals: layerId
157
162
  }
158
- },
159
- {
160
- status: {
161
- in: [
162
- 'running',
163
- 'decided'
163
+ }
164
+ });
165
+ const layer = result.docs[0];
166
+ const usedSlices = [];
167
+ let page = 1;
168
+ while(true){
169
+ const usage = await req.payload.find({
170
+ collection: 'xp-experiments',
171
+ req,
172
+ overrideAccess: false,
173
+ depth: 0,
174
+ limit: 100,
175
+ page,
176
+ where: {
177
+ and: [
178
+ {
179
+ layer: {
180
+ equals: layerId
181
+ }
182
+ },
183
+ {
184
+ status: {
185
+ in: [
186
+ 'running',
187
+ 'decided'
188
+ ]
189
+ }
190
+ }
164
191
  ]
165
192
  }
166
- }
167
- ]
168
- }
169
- });
170
- for (const doc of result.docs)if (Array.isArray(doc.layerSlices)) usedSlices.push(...doc.layerSlices);
171
- if (!result.hasNextPage) break;
172
- page++;
193
+ });
194
+ for (const doc of usage.docs)if (Array.isArray(doc.layerSlices)) usedSlices.push(...doc.layerSlices);
195
+ if (!usage.hasNextPage) break;
196
+ page++;
197
+ }
198
+ return layer ? {
199
+ unitType: layer.unitType,
200
+ usedSlices
201
+ } : null;
202
+ })());
203
+ input.layer = await layers.get(key);
204
+ }
205
+ inputs.set(String(experiment.id), input);
206
+ } catch (cause) {
207
+ if (!tolerateErrors) throw cause;
208
+ inputs.set(String(experiment.id), null);
173
209
  }
174
- input.layer = layer ? {
175
- unitType: layer.unitType,
176
- usedSlices
177
- } : null;
178
210
  }
179
- return input;
211
+ return inputs;
212
+ }
213
+ function placementUrls(options, placement) {
214
+ const result = {};
215
+ const { collection, id, published } = placement;
216
+ try {
217
+ const url = options.admin?.previewUrl?.({
218
+ collection,
219
+ id
220
+ });
221
+ if (typeof url === 'string') result.url = url;
222
+ } catch {}
223
+ try {
224
+ const url = options.admin?.editorUrl?.({
225
+ collection,
226
+ id,
227
+ draft: !published
228
+ });
229
+ if (typeof url === 'string') result.editorUrl = url;
230
+ } catch {}
231
+ return result;
180
232
  }
181
233
  async function primaryHistory(source, now) {
182
234
  if (!source) return null;
@@ -230,8 +282,7 @@ export async function preflightData(req, { experimentId, options }, { now = new
230
282
  const compared = e.arms.filter((arm)=>arm.kind !== 'holdout');
231
283
  const control = compared.find((arm)=>arm.kind === 'control');
232
284
  const variant = compared.find((arm)=>arm.kind === 'variant');
233
- const savedDuration = e.analysis?.plannedDurationDays && e.analysis.plannedDurationDays > 0 ? e.analysis.plannedDurationDays : undefined;
234
- const planned = savedDuration ?? 14;
285
+ const planned = 14;
235
286
  const enrolled = trafficPerDay * e.allocation / 100;
236
287
  const pairShare = ((control?.weight ?? 0) + (variant?.weight ?? 0)) / 100;
237
288
  const tuning = enrolled * planned * pairShare;
@@ -274,13 +325,11 @@ export async function preflightData(req, { experimentId, options }, { now = new
274
325
  });
275
326
  let result = await estimateAt(tuning);
276
327
  let weeks = result.sampleSizeAndRuntime.primary?.weeks ?? result.weekThreshold ?? 13;
277
- const plannedDurationDays = savedDuration ?? Math.ceil(weeks * 7);
328
+ const plannedDurationDays = Math.ceil(weeks * 7);
278
329
  const sequentialTuningParameter = enrolled * plannedDurationDays * pairShare;
279
- if (savedDuration === undefined) {
280
- // Re-evaluate using exactly the duration and tuning Review will prefill.
281
- result = await estimateAt(sequentialTuningParameter);
282
- weeks = result.sampleSizeAndRuntime.primary?.weeks ?? result.weekThreshold ?? 13;
283
- }
330
+ // Re-evaluate using exactly the power-derived duration and tuning Apply will save.
331
+ result = await estimateAt(sequentialTuningParameter);
332
+ weeks = result.sampleSizeAndRuntime.primary?.weeks ?? result.weekThreshold ?? 13;
284
333
  power = {
285
334
  weeks,
286
335
  plannedDurationDays,
@@ -295,6 +344,8 @@ export async function preflightData(req, { experimentId, options }, { now = new
295
344
  collection: placement.collection,
296
345
  id: placement.id,
297
346
  title: placement.title,
347
+ slots: placement.slots,
348
+ ...placementUrls(options, placement),
298
349
  ok: requiredSlots.every((slot)=>slot && placement.slots[slot] > 0),
299
350
  detail: placement.published ? 'Published' : 'Draft'
300
351
  }));
@@ -1,7 +1,7 @@
1
- import { randomBytes } from 'node:crypto';
2
1
  import { claimSlices } from '../db/slices.js';
3
2
  import { XpRefusal } from '../errors.js';
4
3
  import { isOpValid } from '../targeting/ops.js';
4
+ import { newSeed } from '../runtime/seed.js';
5
5
  import { checkStart, relationshipId } from './checkStart.js';
6
6
  import { withTransition } from './kernel.js';
7
7
  import { writePhaseRecord } from './phaseRecord.js';
@@ -160,7 +160,7 @@ export async function start(req, { experimentId, options }) {
160
160
  count: current.allocation
161
161
  });
162
162
  const phase = (current.phase ?? 0) + 1;
163
- const seed = `${randomBytes(16).toString('hex')}|`;
163
+ const seed = newSeed();
164
164
  const startedAt = at.toISOString();
165
165
  const definitions = caps.definitions.map((metric)=>({
166
166
  ...metric,
@@ -2,6 +2,7 @@ import type { MetricSource, ParamField } from './contracts.js';
2
2
  export type SerializedFlagPoint = {
3
3
  id: string;
4
4
  label: string;
5
+ description?: string;
5
6
  params: Record<string, ParamField>;
6
7
  defaultParams: Record<string, unknown>;
7
8
  effectiveDefaults: Record<string, unknown>;
@@ -64,6 +64,7 @@ export interface MetricSource {
64
64
  export interface FlagPoint {
65
65
  id: string;
66
66
  label: string;
67
+ description?: string;
67
68
  params: Record<string, ParamField>;
68
69
  defaultParams: Record<string, unknown>;
69
70
  placements?(args: {
@@ -104,6 +105,22 @@ export interface ExperimentsPluginOptions<TUser extends {
104
105
  readResults?: ExperimentsAccess;
105
106
  readIdentities?: ExperimentsAccess;
106
107
  };
108
+ admin?: {
109
+ editorUrl?: (args: {
110
+ collection: string;
111
+ id: DocId;
112
+ draft?: boolean;
113
+ }) => string;
114
+ previewUrl?: (args: {
115
+ collection: string;
116
+ id: DocId;
117
+ }) => string;
118
+ descriptions?: {
119
+ block?: string;
120
+ document?: string;
121
+ setting?: string;
122
+ };
123
+ };
107
124
  metricSources?: MetricSource[];
108
125
  eventSources?: Array<string | {
109
126
  name: string;
@@ -1,4 +1,7 @@
1
1
  import type { ArmKind, DocId, ExposurePolicy, MetricSource, ResolvedOptions } from './contracts.js';
2
+ import type { ArmSlot } from './puck.js';
3
+ import type { ReadinessCode } from '../transitions/checkStart.js';
4
+ export type { ReadinessCode } from '../transitions/checkStart.js';
2
5
  import type { Snapshot, VerdictCode } from './snapshot.js';
3
6
  export type ArmIssueCode = 'weights-total' | 'control-count' | 'control-first' | 'holdout-last' | 'holdout-count' | 'variant-count' | 'duplicate-key' | 'duplicate-slot' | 'slot-required' | 'invalid-key';
4
7
  export type RefusalCode = ArmIssueCode | 'not-found' | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-no-targets' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'duplicate-metric';
@@ -39,7 +42,7 @@ export type MakeContentResult = TransitionSuccess & {
39
42
  };
40
43
  export type PreflightResult = {
41
44
  checks: Array<{
42
- code: RefusalCode;
45
+ code: ReadinessCode;
43
46
  ok: boolean;
44
47
  detail?: unknown;
45
48
  }>;
@@ -48,6 +51,8 @@ export type PreflightResult = {
48
51
  id: DocId;
49
52
  title: string;
50
53
  url?: string;
54
+ editorUrl?: string;
55
+ slots?: Record<ArmSlot, number>;
51
56
  ok: boolean;
52
57
  detail?: string;
53
58
  }>;
@@ -91,6 +96,10 @@ export type ResultsResponse = {
91
96
  phases: PhaseSummary[];
92
97
  snapshot: Snapshot | null;
93
98
  decisionSnapshot?: Snapshot;
99
+ decision?: {
100
+ phase: number;
101
+ winnerArm: string;
102
+ };
94
103
  series?: Array<{
95
104
  computedAt: string;
96
105
  metricId: DocId;
@@ -100,6 +109,7 @@ export type ResultsResponse = {
100
109
  }>;
101
110
  };
102
111
  export type SummaryResponse = Record<DocId, {
112
+ ready?: boolean;
103
113
  exposures: number;
104
114
  days: number;
105
115
  verdict: {
@@ -105,6 +105,7 @@ export interface Snapshot {
105
105
  detail: string;
106
106
  recommendedArm: string | null;
107
107
  guardrailLine?: string;
108
+ worstArm?: string;
108
109
  };
109
110
  contentChanges: Array<{
110
111
  at: string;
@@ -0,0 +1,29 @@
1
+ import type { DocId } from './contracts.js';
2
+ import type { SerializedAttributeKeys, SerializedFlagPoint, SerializedMetricSource } from './admin.js';
3
+ export type TestsRoute = 'home' | 'new' | 'test' | 'results';
4
+ export interface TestsSurfaceProps {
5
+ route: TestsRoute;
6
+ id?: string;
7
+ adminRoute: string;
8
+ apiRoute: string;
9
+ isAdmin: boolean;
10
+ pages?: Array<{
11
+ collection: string;
12
+ collectionLabel: string;
13
+ id: DocId;
14
+ title: string;
15
+ editorUrl: string;
16
+ }>;
17
+ canManage: boolean;
18
+ canReadResults: boolean;
19
+ flagPoints: SerializedFlagPoint[];
20
+ attributeKeys: SerializedAttributeKeys;
21
+ metricSources: SerializedMetricSource[];
22
+ admin?: {
23
+ descriptions?: {
24
+ block?: string;
25
+ document?: string;
26
+ setting?: string;
27
+ };
28
+ };
29
+ }
@@ -0,0 +1 @@
1
+ export { };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-experiments",
3
- "version": "0.1.0-rc.7",
3
+ "version": "0.2.0-rc.1",
4
4
  "description": "No-code A/B testing for Payload CMS and Next.js: block, page and flag experiments with holdouts, layers, exposure logging and honest statistics inside the admin — no vendor",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/dist/access.d.ts DELETED
@@ -1 +0,0 @@
1
- export { deny, isTrue, managedAccess, readOnlyAccess } from './collections/access.js';
package/dist/access.js DELETED
@@ -1,2 +0,0 @@
1
- // Keep CORE-02a's helpers canonical without changing its collection files.
2
- export { deny, isTrue, managedAccess, readOnlyAccess } from './collections/access.js';
@@ -1,2 +0,0 @@
1
- import type { NumberFieldClientProps } from 'payload';
2
- export declare function AllocationField({ path, field, readOnly }: NumberFieldClientProps): import("react").JSX.Element;
@@ -1,80 +0,0 @@
1
- 'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { FieldError, useField, useFormFields } from '@payloadcms/ui';
4
- import { EDITABLE_BY_STATUS, isEditable } from '../collections/experiments/editability.js';
5
- import { lockedReason } from './lockedNotice.model.js';
6
- import { allocationGroups, allocationLayer, allocationSentence } from './allocation.model.js';
7
- import { WHO_COPY } from './copy/who.js';
8
- import { useLayerUsage } from './useLayerUsage.js';
9
- export function AllocationField({ path, field, readOnly }) {
10
- const { value, setValue } = useField({
11
- path
12
- });
13
- const form = useFormFields(([fields])=>({
14
- status: fields.status?.value ?? 'draft',
15
- unitType: fields.unitType?.value ?? 'visitor',
16
- arms: allocationGroups(fields),
17
- layerId: fields.layer?.value,
18
- layer: allocationLayer(fields.layer?.value) ?? allocationLayer({
19
- name: fields['layer.name']?.value,
20
- used: fields['layer.used']?.value,
21
- free: fields['layer.free']?.value
22
- })
23
- }));
24
- const locked = Boolean(readOnly || field.admin?.readOnly || !isEditable(form.status, 'allocation'));
25
- const reason = lockedReason(form.status, 'allocation', EDITABLE_BY_STATUS);
26
- const layerId = typeof form.layerId === 'object' && form.layerId !== null ? 'id' in form.layerId ? form.layerId.id : null : form.layerId;
27
- const layer = useLayerUsage(typeof layerId === 'string' || typeof layerId === 'number' ? layerId : null);
28
- const sentence = allocationSentence({
29
- ...form,
30
- allocation: value ?? NaN,
31
- layer: layer ?? form.layer
32
- });
33
- // position: relative anchors Payload's absolutely positioned FieldError tooltip to this field.
34
- return /*#__PURE__*/ _jsxs("div", {
35
- className: "xp-allocation-field",
36
- style: {
37
- position: 'relative'
38
- },
39
- children: [
40
- /*#__PURE__*/ _jsx("label", {
41
- htmlFor: path,
42
- children: WHO_COPY.allocation
43
- }),
44
- /*#__PURE__*/ _jsx("input", {
45
- id: path,
46
- name: path,
47
- type: "number",
48
- min: 1,
49
- max: 100,
50
- step: 1,
51
- required: field.required,
52
- value: value ?? '',
53
- readOnly: locked,
54
- "aria-describedby": `${path}-description`,
55
- style: {
56
- display: 'block',
57
- color: 'var(--theme-text)',
58
- background: 'var(--theme-input-bg)',
59
- border: '1px solid var(--theme-elevation-200)',
60
- borderRadius: 'var(--style-radius-s)',
61
- padding: '0.5rem'
62
- },
63
- onChange: (event)=>{
64
- if (!locked) setValue(event.target.value === '' ? null : event.target.valueAsNumber);
65
- }
66
- }),
67
- /*#__PURE__*/ _jsx(FieldError, {
68
- path: path
69
- }),
70
- /*#__PURE__*/ _jsx("p", {
71
- id: `${path}-description`,
72
- children: sentence
73
- }),
74
- reason && (form.status !== 'running' || !sentence) ? /*#__PURE__*/ _jsx("p", {
75
- className: "xp-locked-notice",
76
- children: reason
77
- }) : null
78
- ]
79
- });
80
- }
@@ -1,2 +0,0 @@
1
- import type { TextFieldClientProps } from 'payload';
2
- export declare function FlagPointSelect({ field, path, readOnly }: TextFieldClientProps): import("react").JSX.Element;