@delmaredigital/payload-experiments 0.2.0-rc.7 → 0.3.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. package/README.md +202 -26
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +50 -21
  4. package/dist/admin/TestsNavLink.d.ts +3 -1
  5. package/dist/admin/TestsNavLink.js +2 -2
  6. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  7. package/dist/admin/actions/ConfirmDialog.js +37 -82
  8. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  9. package/dist/admin/actions/DecideDialog.js +30 -13
  10. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  11. package/dist/admin/actions/MakeContentResult.js +115 -16
  12. package/dist/admin/actions/actions.model.d.ts +2 -4
  13. package/dist/admin/actions/actions.model.js +2 -3
  14. package/dist/admin/allocation.model.js +3 -5
  15. package/dist/admin/api.d.ts +10 -4
  16. package/dist/admin/api.js +11 -7
  17. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  18. package/dist/admin/arms/arms.model.d.ts +1 -21
  19. package/dist/admin/arms/arms.model.js +1 -153
  20. package/dist/admin/copy/actions.d.ts +39 -3
  21. package/dist/admin/copy/actions.js +40 -4
  22. package/dist/admin/copy/armSelect.js +5 -5
  23. package/dist/admin/copy/arms.d.ts +1 -6
  24. package/dist/admin/copy/arms.js +1 -16
  25. package/dist/admin/copy/audience.d.ts +2 -1
  26. package/dist/admin/copy/audience.js +3 -2
  27. package/dist/admin/copy/common.d.ts +13 -6
  28. package/dist/admin/copy/common.js +14 -8
  29. package/dist/admin/copy/goals.d.ts +21 -6
  30. package/dist/admin/copy/goals.js +27 -6
  31. package/dist/admin/copy/readiness.d.ts +22 -0
  32. package/dist/admin/copy/readiness.js +21 -0
  33. package/dist/admin/copy/recommendation.d.ts +0 -3
  34. package/dist/admin/copy/recommendation.js +0 -3
  35. package/dist/admin/copy/results.d.ts +43 -2
  36. package/dist/admin/copy/results.js +44 -3
  37. package/dist/admin/copy/review.d.ts +2 -2
  38. package/dist/admin/copy/review.js +2 -2
  39. package/dist/admin/copy/setting.d.ts +2 -0
  40. package/dist/admin/copy/setting.js +2 -0
  41. package/dist/admin/copy/surface.d.ts +7 -5
  42. package/dist/admin/copy/surface.js +9 -5
  43. package/dist/admin/copy/testPage.d.ts +10 -0
  44. package/dist/admin/copy/testPage.js +10 -0
  45. package/dist/admin/format/date.d.ts +2 -0
  46. package/dist/admin/format/date.js +10 -0
  47. package/dist/admin/paths.d.ts +2 -1
  48. package/dist/admin/paths.js +2 -1
  49. package/dist/admin/previewLinks.model.d.ts +4 -2
  50. package/dist/admin/previewLinks.model.js +8 -7
  51. package/dist/admin/results/CiChart.js +11 -21
  52. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  53. package/dist/admin/results/DataQualityPanel.js +30 -17
  54. package/dist/admin/results/DataTable.d.ts +16 -0
  55. package/dist/admin/results/DataTable.js +152 -0
  56. package/dist/admin/results/StatTile.d.ts +7 -0
  57. package/dist/admin/results/StatTile.js +22 -0
  58. package/dist/admin/results/VerdictHero.d.ts +13 -0
  59. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  60. package/dist/admin/results/legacy.d.ts +16 -0
  61. package/dist/admin/results/legacy.js +27 -0
  62. package/dist/admin/results/releasePages.model.d.ts +17 -0
  63. package/dist/admin/results/releasePages.model.js +10 -0
  64. package/dist/admin/results/results.css +64 -0
  65. package/dist/admin/results/results.model.d.ts +11 -7
  66. package/dist/admin/results/results.model.js +24 -7
  67. package/dist/admin/results/snapshot.d.ts +14 -0
  68. package/dist/admin/results/snapshot.js +3 -0
  69. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  70. package/dist/admin/surface/ResultsRoute.js +59 -34
  71. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  72. package/dist/admin/surface/SurfaceShell.js +49 -14
  73. package/dist/admin/surface/TestsHome.d.ts +6 -1
  74. package/dist/admin/surface/TestsHome.js +182 -114
  75. package/dist/admin/surface/TestsSurface.js +56 -16
  76. package/dist/admin/surface/goals/GoalForm.js +341 -308
  77. package/dist/admin/surface/goals/GoalsList.js +123 -89
  78. package/dist/admin/surface/goals/data.d.ts +8 -2
  79. package/dist/admin/surface/goals/data.js +24 -16
  80. package/dist/admin/surface/goals/goals.css +10 -13
  81. package/dist/admin/surface/home.model.d.ts +8 -8
  82. package/dist/admin/surface/home.model.js +23 -12
  83. package/dist/admin/surface/newTest/NewTest.js +9 -9
  84. package/dist/admin/surface/pageBlock/parts.js +31 -26
  85. package/dist/admin/surface/readiness/ActionDialogs.js +47 -19
  86. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  87. package/dist/admin/surface/readiness/Banner.js +58 -33
  88. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  89. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  90. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  91. package/dist/admin/surface/readiness/readiness.model.js +25 -9
  92. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  93. package/dist/admin/surface/readiness/useTransition.js +2 -1
  94. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  95. package/dist/admin/surface/results/ReleasePages.js +116 -0
  96. package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
  97. package/dist/admin/surface/results/ResultsBody.js +277 -105
  98. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  99. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  100. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  101. package/dist/admin/surface/results/recommendation.model.js +12 -9
  102. package/dist/admin/surface/results/useResults.d.ts +21 -3
  103. package/dist/admin/surface/results/useResults.js +2 -1
  104. package/dist/admin/surface/setting/parts.js +75 -88
  105. package/dist/admin/surface/setting/setting.css +3 -15
  106. package/dist/admin/surface/surface.css +61 -47
  107. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  108. package/dist/admin/surface/test/GoalSection.js +46 -47
  109. package/dist/admin/surface/test/LengthSection.js +46 -34
  110. package/dist/admin/surface/test/Rail.d.ts +4 -0
  111. package/dist/admin/surface/test/Rail.js +83 -0
  112. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  113. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  114. package/dist/admin/surface/test/Section.d.ts +1 -1
  115. package/dist/admin/surface/test/Section.js +36 -16
  116. package/dist/admin/surface/test/TargetingSection.js +168 -142
  117. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  118. package/dist/admin/surface/test/TestPage.js +146 -68
  119. package/dist/admin/surface/test/VersionsSection.js +151 -120
  120. package/dist/admin/surface/test/fields.d.ts +11 -4
  121. package/dist/admin/surface/test/fields.js +84 -85
  122. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  123. package/dist/admin/surface/test/jumpToSection.js +10 -0
  124. package/dist/admin/surface/test/pageParts.js +9 -3
  125. package/dist/admin/surface/test/testPage.css +81 -14
  126. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  127. package/dist/admin/surface/test/testPage.model.js +1 -21
  128. package/dist/admin/surface/useTestDocument.js +12 -5
  129. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  130. package/dist/admin/targeting/targeting.model.js +3 -8
  131. package/dist/admin/theme.css +115 -36
  132. package/dist/admin/theme.d.ts +38 -0
  133. package/dist/admin/theme.js +38 -1
  134. package/dist/admin/useApi.d.ts +17 -2
  135. package/dist/admin/useLayerUsage.js +14 -23
  136. package/dist/admin/views/ExperimentsRootView.js +12 -2
  137. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  138. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  139. package/dist/arms/copy.d.ts +6 -0
  140. package/dist/arms/copy.js +16 -0
  141. package/dist/arms/model.d.ts +23 -0
  142. package/dist/arms/model.js +173 -0
  143. package/dist/collections/experiments/config.js +25 -0
  144. package/dist/collections/experiments/lockHook.js +3 -1
  145. package/dist/collections/identities.js +6 -1
  146. package/dist/collections/metrics.js +7 -0
  147. package/dist/collections/metricsGuards.d.ts +9 -0
  148. package/dist/collections/metricsGuards.js +33 -0
  149. package/dist/contentDone.d.ts +4 -0
  150. package/dist/contentDone.js +5 -0
  151. package/dist/db/appends.d.ts +6 -2
  152. package/dist/db/appends.js +10 -4
  153. package/dist/db/identities.d.ts +8 -7
  154. package/dist/db/identities.js +9 -6
  155. package/dist/db/locks.d.ts +6 -0
  156. package/dist/db/locks.js +13 -1
  157. package/dist/db/metricUsage.d.ts +19 -0
  158. package/dist/db/metricUsage.js +54 -0
  159. package/dist/db/names.d.ts +5 -0
  160. package/dist/db/names.js +5 -0
  161. package/dist/db/pageOccupancy.d.ts +12 -0
  162. package/dist/db/pageOccupancy.js +45 -0
  163. package/dist/db/publishedContent.d.ts +10 -0
  164. package/dist/db/publishedContent.js +43 -0
  165. package/dist/db/results.d.ts +2 -0
  166. package/dist/db/results.js +6 -2
  167. package/dist/db/snapshots.d.ts +18 -6
  168. package/dist/db/snapshots.js +25 -8
  169. package/dist/db/sweep.js +11 -6
  170. package/dist/endpoints/index.js +19 -1
  171. package/dist/endpoints/results.js +5 -0
  172. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  173. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  174. package/dist/endpoints/summary.js +20 -2
  175. package/dist/endpoints/transitions.d.ts +15 -0
  176. package/dist/endpoints/transitions.js +36 -0
  177. package/dist/errors.d.ts +8 -0
  178. package/dist/errors.js +28 -0
  179. package/dist/exports/adminRsc.d.ts +1 -0
  180. package/dist/exports/adminRsc.js +1 -0
  181. package/dist/exports/index.d.ts +2 -1
  182. package/dist/exports/index.js +1 -0
  183. package/dist/exports/puck.d.ts +2 -1
  184. package/dist/exports/puck.js +1 -1
  185. package/dist/exports/puckEditor.d.ts +4 -1
  186. package/dist/exports/puckEditor.js +3 -1
  187. package/dist/exports/rsc.d.ts +6 -1
  188. package/dist/exports/rsc.js +2 -0
  189. package/dist/hooks/pageGuard.d.ts +5 -0
  190. package/dist/hooks/pageGuard.js +139 -0
  191. package/dist/lib/fetchAllDocs.d.ts +16 -0
  192. package/dist/lib/fetchAllDocs.js +56 -0
  193. package/dist/log.js +6 -3
  194. package/dist/manifest/compile.d.ts +5 -0
  195. package/dist/manifest/compile.js +5 -1
  196. package/dist/options.js +16 -0
  197. package/dist/plugin.js +30 -2
  198. package/dist/puck/ExperimentPicker.js +47 -16
  199. package/dist/puck/ExperimentSlot.d.ts +51 -1
  200. package/dist/puck/ExperimentSlot.js +23 -12
  201. package/dist/puck/copy.d.ts +6 -5
  202. package/dist/puck/copy.js +6 -5
  203. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  204. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  205. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  206. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  207. package/dist/puck/editor/ResultsReadout.js +99 -0
  208. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  209. package/dist/puck/editor/VersionBuilder.js +317 -0
  210. package/dist/puck/editor/armWrites.d.ts +7 -0
  211. package/dist/puck/editor/armWrites.js +71 -0
  212. package/dist/puck/editor/armsCache.d.ts +14 -0
  213. package/dist/puck/editor/armsCache.js +78 -0
  214. package/dist/puck/editor/copy.d.ts +33 -1
  215. package/dist/puck/editor/copy.js +45 -8
  216. package/dist/puck/editor/createTest.d.ts +1 -1
  217. package/dist/puck/editor/createTest.js +25 -14
  218. package/dist/puck/editor/editor.chrome.css +121 -0
  219. package/dist/puck/editor/editor.css +288 -204
  220. package/dist/puck/editor/editor.model.d.ts +4 -1
  221. package/dist/puck/editor/editor.model.js +10 -7
  222. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  223. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  224. package/dist/puck/experimentSlotField.d.ts +2 -0
  225. package/dist/puck/experimentSlotField.js +11 -3
  226. package/dist/puck/hostContext.d.ts +10 -0
  227. package/dist/puck/hostContext.js +12 -0
  228. package/dist/puck/routes.d.ts +12 -0
  229. package/dist/puck/routes.js +11 -0
  230. package/dist/puck/slotConfig.d.ts +1 -1
  231. package/dist/puck/slotConfig.js +2 -2
  232. package/dist/results/aggregate.js +4 -3
  233. package/dist/results/analyse.d.ts +4 -1
  234. package/dist/results/analyse.js +27 -11
  235. package/dist/results/cuped.js +3 -2
  236. package/dist/results/identityCredit.d.ts +15 -7
  237. package/dist/results/identityCredit.js +32 -33
  238. package/dist/results/rolloutUnits.js +2 -1
  239. package/dist/results/snapshot.js +8 -2
  240. package/dist/results/srm.js +2 -1
  241. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  242. package/dist/sanitizeReadinessDetail.js +63 -0
  243. package/dist/server/linkIdentity.d.ts +10 -1
  244. package/dist/server/linkIdentity.js +17 -15
  245. package/dist/server/metadata.d.ts +5 -0
  246. package/dist/server/metadata.js +18 -3
  247. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  248. package/dist/server/normaliseContentRelationships.js +62 -0
  249. package/dist/server/recordContentChange.d.ts +1 -1
  250. package/dist/server/recordContentChange.js +17 -8
  251. package/dist/server/runResults.d.ts +3 -2
  252. package/dist/server/runResults.js +157 -35
  253. package/dist/targeting/values.d.ts +4 -0
  254. package/dist/targeting/values.js +10 -0
  255. package/dist/transitions/addMetric.js +8 -0
  256. package/dist/transitions/archive.js +2 -3
  257. package/dist/transitions/checkStart.d.ts +10 -3
  258. package/dist/transitions/checkStart.js +25 -0
  259. package/dist/transitions/contentFields.d.ts +7 -0
  260. package/dist/transitions/contentFields.js +20 -0
  261. package/dist/transitions/decide.js +22 -2
  262. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  263. package/dist/transitions/dismissContentEntry.js +45 -0
  264. package/dist/transitions/makeContent.d.ts +5 -2
  265. package/dist/transitions/makeContent.js +217 -118
  266. package/dist/transitions/phaseRecord.d.ts +8 -2
  267. package/dist/transitions/placements.d.ts +3 -6
  268. package/dist/transitions/placements.js +12 -1
  269. package/dist/transitions/preflightData.d.ts +9 -1
  270. package/dist/transitions/preflightData.js +138 -20
  271. package/dist/transitions/releasePages.d.ts +8 -0
  272. package/dist/transitions/releasePages.js +26 -0
  273. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  274. package/dist/transitions/restoreContentEntry.js +47 -0
  275. package/dist/transitions/start.js +62 -7
  276. package/dist/types/contracts.d.ts +24 -4
  277. package/dist/types/endpoints.d.ts +34 -3
  278. package/dist/types/index.d.ts +1 -0
  279. package/dist/types/index.js +1 -0
  280. package/dist/types/reasons.d.ts +3 -0
  281. package/dist/types/reasons.js +1 -0
  282. package/dist/types/snapshot.d.ts +6 -1
  283. package/dist/ui/Banner.d.ts +2 -0
  284. package/dist/ui/Banner.js +2 -0
  285. package/dist/ui/Breadcrumb.d.ts +7 -0
  286. package/dist/ui/Breadcrumb.js +25 -0
  287. package/dist/ui/Button.d.ts +15 -0
  288. package/dist/ui/Button.js +32 -0
  289. package/dist/ui/Card.d.ts +12 -0
  290. package/dist/ui/Card.js +40 -0
  291. package/dist/ui/Checkbox.d.ts +2 -0
  292. package/dist/ui/Checkbox.js +2 -0
  293. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  294. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  295. package/dist/ui/Dialog.d.ts +13 -0
  296. package/dist/ui/Dialog.js +85 -0
  297. package/dist/ui/EmptyState.d.ts +2 -0
  298. package/dist/ui/EmptyState.js +2 -0
  299. package/dist/ui/Feedback.d.ts +24 -0
  300. package/dist/ui/Feedback.js +40 -0
  301. package/dist/ui/Inputs.d.ts +34 -0
  302. package/dist/ui/Inputs.js +225 -0
  303. package/dist/ui/NumberInput.d.ts +2 -0
  304. package/dist/ui/NumberInput.js +2 -0
  305. package/dist/ui/Range.d.ts +2 -0
  306. package/dist/ui/Range.js +2 -0
  307. package/dist/ui/SearchInput.d.ts +2 -0
  308. package/dist/ui/SearchInput.js +2 -0
  309. package/dist/ui/SectionCard.d.ts +2 -0
  310. package/dist/ui/SectionCard.js +2 -0
  311. package/dist/ui/Select.d.ts +2 -0
  312. package/dist/ui/Select.js +2 -0
  313. package/dist/ui/StatusChip.d.ts +2 -0
  314. package/dist/ui/StatusChip.js +2 -0
  315. package/dist/ui/Tabs.d.ts +16 -0
  316. package/dist/ui/Tabs.js +61 -0
  317. package/dist/ui/TextInput.d.ts +2 -0
  318. package/dist/ui/TextInput.js +2 -0
  319. package/dist/ui/Textarea.d.ts +2 -0
  320. package/dist/ui/Textarea.js +2 -0
  321. package/dist/ui/Toggle.d.ts +2 -0
  322. package/dist/ui/Toggle.js +2 -0
  323. package/dist/ui/accent.d.ts +40 -0
  324. package/dist/ui/accent.js +139 -0
  325. package/dist/ui/index.d.ts +9 -0
  326. package/dist/ui/index.js +9 -0
  327. package/dist/ui/ui.css +92 -0
  328. package/package.json +5 -2
  329. package/dist/admin/components/chipsInput.css +0 -11
  330. package/dist/admin/results/ArmTable.d.ts +0 -8
  331. package/dist/admin/results/ArmTable.js +0 -113
  332. package/dist/admin/results/VerdictCard.d.ts +0 -9
  333. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  334. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  335. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  336. package/dist/admin/surface/test/TestHeader.js +0 -70
@@ -1,14 +1,32 @@
1
- import { commitTransaction, initTransaction, killTransaction } from 'payload';
1
+ import { Forbidden, NotFound, ValidationError, Locked, commitTransaction, initTransaction, killTransaction } from 'payload';
2
+ import { latestDraftContent, publishedContent as readPublishedContent } from '../db/publishedContent.js';
3
+ import { contentBlocks, hasLocalizedFields } from './contentFields.js';
4
+ import { safeError } from '../log.js';
5
+ import { XpRefusal } from '../errors.js';
2
6
  import { lockContentDefaults } from '../db/appends.js';
3
7
  import { pooledExecutor, txExecutor } from '../db/executor.js';
4
8
  import { currentTxid, txidStatus } from '../db/txid.js';
5
9
  import { buildAndStoreManifest } from '../manifest/compile.js';
6
10
  import { puckCollections } from '../options.js';
11
+ import { contentDone } from '../contentDone.js';
7
12
  import { archive } from './archive.js';
8
13
  import { relationshipId } from './checkStart.js';
9
14
  import { withTransition } from './kernel.js';
10
15
  import { scanBlockPlacements } from './placements.js';
11
16
  import { readExperiment, refuse, updateExperiment } from './start.js';
17
+ /** Classify by type/name only; host error messages can contain row data. */ function contentFailureReason(error) {
18
+ // Payload classes may use APIError as their name, so check instances too.
19
+ const name = error instanceof Error ? error.name : undefined;
20
+ if (error instanceof LocalizedContent) return 'localized';
21
+ if (error instanceof Forbidden || name === 'Forbidden') return 'forbidden';
22
+ if (error instanceof NotFound || name === 'NotFound') return 'not-found';
23
+ if (error instanceof ValidationError || name === 'ValidationError') return 'validation';
24
+ if (error instanceof Locked || name === 'Locked' || error instanceof XpRefusal && error.code === 'locked-placement') return 'locked';
25
+ return 'update-failed';
26
+ }
27
+ class LocalizedContent extends Error {
28
+ }
29
+ const isNotFound = (error)=>contentFailureReason(error) === 'not-found' || Boolean(error && typeof error === 'object' && 'status' in error && error.status === 404);
12
30
  const object = (value)=>value !== null && typeof value === 'object' && !Array.isArray(value);
13
31
  /** Flatten selected slots in every content array, including nested selected slots. */ function unwrap(value, slug, slot) {
14
32
  if (Array.isArray(value)) return value.flatMap((node)=>{
@@ -32,26 +50,69 @@ const object = (value)=>value !== null && typeof value === 'object' && !Array.is
32
50
  }
33
51
  /** Payload update merges omissions from the latest version even on publish.
34
52
  * Restore each captured document's fields, omitting API/adapter metadata.
35
- */ function contentData(doc, field, content) {
36
- const metadata = new Set([
37
- 'id',
38
- 'createdAt',
39
- 'updatedAt',
40
- '_isLocked',
41
- '_userEditing'
42
- ]);
43
- return {
44
- ...Object.fromEntries(Object.entries(doc).filter(([key])=>!metadata.has(key))),
53
+ */ const contentMetadata = new Set([
54
+ 'id',
55
+ 'createdAt',
56
+ 'updatedAt',
57
+ '_isLocked',
58
+ '_userEditing'
59
+ ]);
60
+ function contentData(doc, field, content, fields, blocks = []) {
61
+ const data = {
62
+ ...Object.fromEntries(Object.entries(doc).filter(([key])=>!contentMetadata.has(key))),
45
63
  [field]: content
46
64
  };
65
+ return fillContentFields(fields, data, blocks);
66
+ }
67
+ /** Adapter reads can omit empty fields (notably single polymorphic links).
68
+ * Make every stored value explicit so Payload cannot fill it from the other version.
69
+ */ function fillContentFields(fields, source, blocks = []) {
70
+ const result = {
71
+ ...source
72
+ };
73
+ for (const field of fields){
74
+ if (field.type === 'ui' || field.type === 'join' || 'virtual' in field && field.virtual) continue;
75
+ const name = 'name' in field ? field.name : undefined;
76
+ if (name && contentMetadata.has(name)) continue;
77
+ if (name && result[name] === undefined) result[name] = field.type === 'array' || field.type === 'blocks' || 'hasMany' in field && field.hasMany ? [] : null;
78
+ if ('tabs' in field) for (const tab of field.tabs){
79
+ if ('name' in tab && tab.name) {
80
+ const value = result[tab.name];
81
+ // Payload traverses groups/tabs as objects; null would be dereferenced.
82
+ result[tab.name] = fillContentFields(tab.fields, object(value) ? value : {}, blocks);
83
+ } else Object.assign(result, fillContentFields(tab.fields, result, blocks));
84
+ }
85
+ const value = name ? result[name] : result;
86
+ if ('fields' in field) {
87
+ if (Array.isArray(value)) result[name] = value.map((row)=>object(row) ? fillContentFields(field.fields, row, blocks) : row);
88
+ else if (object(value) || field.type === 'group') {
89
+ const filled = fillContentFields(field.fields, object(value) ? value : {}, blocks);
90
+ if (name) result[name] = filled;
91
+ else Object.assign(result, filled);
92
+ }
93
+ }
94
+ if (field.type === 'blocks' && Array.isArray(value)) {
95
+ const definitions = contentBlocks(field, blocks);
96
+ result[name] = value.map((row)=>{
97
+ const block = object(row) ? definitions.find((block)=>block.slug === row.blockType) : undefined;
98
+ return block && object(row) ? fillContentFields(block.fields, row, blocks) : row;
99
+ });
100
+ }
101
+ }
102
+ return result;
47
103
  }
48
- /** Refuse unsafe collection shapes before rebuilding complete host documents. */ async function fieldRefusal(fields, req, doc, operation = 'read', data = doc, siblingData = data, blockData, seen = new Set()) {
104
+ /** Payload strips denied updates and fills them from the latest version. Only
105
+ * refuse when that substitution would change the proposed value (KI-1).
106
+ */ // Payload beforeChange/getExistingRowDoc.js matches array/block rows by ID.
107
+ const existingRow = (row, original)=>object(row) && row.id && Array.isArray(original) ? original.find((value)=>object(value) && value.id === row.id) ?? {} : {};
108
+ async function fieldRefusal(fields, req, doc, operation = 'update', data = doc, siblingData = data, blockData, originalSibling = fillContentFields(fields, doc, req.payload.config.blocks)) {
49
109
  for (const field of fields){
50
- if (seen.has(field)) continue;
51
- const branch = new Set(seen).add(field);
52
- if ('localized' in field && field.localized) return 'This page is localized; update it by hand.';
53
- if ('hidden' in field && field.hidden === true) return 'This page has hidden fields; update it by hand.';
54
- if ('access' in field && field.access?.[operation] && !await field.access[operation]({
110
+ if (field.type === 'ui' || field.type === 'join' || 'virtual' in field && field.virtual) continue;
111
+ if ('name' in field && ![
112
+ 'id',
113
+ 'createdAt',
114
+ 'updatedAt'
115
+ ].includes(field.name) && JSON.stringify(siblingData[field.name] ?? null) !== JSON.stringify(originalSibling[field.name] ?? null) && 'access' in field && field.access?.[operation] && !await field.access[operation]({
55
116
  id: doc.id,
56
117
  req,
57
118
  doc,
@@ -59,13 +120,11 @@ const object = (value)=>value !== null && typeof value === 'object' && !Array.is
59
120
  siblingData,
60
121
  blockData
61
122
  })) {
62
- return operation === 'read' ? 'This page has fields you cannot read; update it by hand.' : 'This page has fields you cannot update; update it by hand.';
123
+ return 'This page has fields you cannot update; update it by hand.';
63
124
  }
64
125
  const value = 'name' in field ? siblingData[field.name] : siblingData;
65
126
  if ('tabs' in field) for (const tab of field.tabs){
66
- if ('localized' in tab && tab.localized) return 'This page is localized; update it by hand.';
67
- if ('hidden' in tab && tab.hidden === true) return 'This page has hidden fields; update it by hand.';
68
- if ('access' in tab && tab.access?.[operation] && !await tab.access[operation]({
127
+ if ('name' in tab && tab.name && JSON.stringify(siblingData[tab.name] ?? null) !== JSON.stringify(originalSibling[tab.name] ?? null) && 'access' in tab && tab.access?.[operation] && !await tab.access[operation]({
69
128
  id: doc.id,
70
129
  req,
71
130
  doc,
@@ -73,31 +132,29 @@ const object = (value)=>value !== null && typeof value === 'object' && !Array.is
73
132
  siblingData,
74
133
  blockData
75
134
  })) {
76
- return operation === 'read' ? 'This page has fields you cannot read; update it by hand.' : 'This page has fields you cannot update; update it by hand.';
135
+ return 'This page has fields you cannot update; update it by hand.';
77
136
  }
78
137
  const tabData = 'name' in tab && tab.name ? siblingData[tab.name] : siblingData;
79
- const refusal = await fieldRefusal(tab.fields, req, doc, operation, data, object(tabData) ? tabData : {}, blockData, branch);
138
+ const originalTab = 'name' in tab && tab.name ? originalSibling[tab.name] : originalSibling;
139
+ const refusal = await fieldRefusal(tab.fields, req, doc, operation, data, object(tabData) ? tabData : {}, blockData, object(originalTab) ? originalTab : {});
80
140
  if (refusal) return refusal;
81
141
  }
142
+ const originalValue = 'name' in field ? originalSibling[field.name] : originalSibling;
82
143
  if ('fields' in field) for (const row of Array.isArray(value) && value.length ? value : [
83
144
  value
84
145
  ]){
85
- const refusal = await fieldRefusal(field.fields, req, doc, operation, data, object(row) ? row : {}, blockData, branch);
146
+ const original = Array.isArray(value) ? existingRow(row, originalValue) : originalValue;
147
+ const refusal = await fieldRefusal(field.fields, req, doc, operation, data, object(row) ? row : {}, blockData, object(original) ? original : {});
86
148
  if (refusal) return refusal;
87
149
  }
88
150
  if (field.type === 'blocks') {
89
- const blocks = [
90
- ...field.blocks,
91
- ...(field.blockReferences ?? []).map((slug)=>req.payload.config.blocks?.find((block)=>block.slug === slug)).filter((block)=>block !== undefined)
92
- ];
93
- for (const block of blocks){
94
- const rows = Array.isArray(value) ? value.filter((row)=>object(row) && row.blockType === block.slug) : [];
95
- for (const row of rows.length ? rows : [
96
- {}
97
- ]){
98
- const refusal = await fieldRefusal(block.fields, req, doc, operation, data, row, row, branch);
99
- if (refusal) return refusal;
100
- }
151
+ const blocks = contentBlocks(field, req.payload.config.blocks);
152
+ for (const row of Array.isArray(value) ? value : []){
153
+ const block = object(row) ? blocks.find((block)=>block.slug === row.blockType) : undefined;
154
+ if (!block || !object(row)) continue;
155
+ const original = existingRow(row, originalValue);
156
+ const refusal = await fieldRefusal(block.fields, req, doc, operation, data, row, row, object(original) ? original : {});
157
+ if (refusal) return refusal;
101
158
  }
102
159
  }
103
160
  }
@@ -142,29 +199,71 @@ export async function makeContent(req, { experimentId, options }) {
142
199
  const decision = current.decision;
143
200
  const winner = current.arms.find((arm)=>arm.key === decision?.winnerArm && arm.kind !== 'holdout');
144
201
  if (!winner) refuse('invalid-winner');
202
+ const report = decision?.contentReport;
203
+ const previous = new Map((report?.docs ?? []).map((doc)=>[
204
+ doc.collection + ':' + doc.id,
205
+ doc
206
+ ]));
145
207
  const docs = [];
146
- const attempt = async (collection, id, fn)=>{
208
+ // Dismissed failures are handled by the manager: retain them unchanged and
209
+ // never retry them until restored. Each retried entry gets a fresh outcome.
210
+ const retainDismissed = (collection, id)=>{
211
+ const entry = previous.get(collection + ':' + id);
212
+ if (!entry?.dismissedAt) return false;
213
+ docs.push(entry);
214
+ return true;
215
+ };
216
+ const attempt = async (collection, id, fn, missingOk = false)=>{
217
+ if (retainDismissed(collection, id)) return;
218
+ let missing = false;
147
219
  try {
148
- await hostTransaction(req, fn);
220
+ await hostTransaction(req, async ()=>{
221
+ try {
222
+ await fn();
223
+ } catch (error) {
224
+ if (missingOk && isNotFound(error)) {
225
+ // Payload may already have killed the failed write's transaction.
226
+ // Roll back any remaining writes, then check the row while still
227
+ // detached from the outer transition and its privileged context.
228
+ await killTransaction(req);
229
+ if (!await initTransaction(req)) throw new Error('A content transaction could not be started.');
230
+ missing = !await readPublishedContent(req, collection, id);
231
+ }
232
+ throw error;
233
+ }
234
+ });
149
235
  docs.push({
150
236
  collection,
151
237
  id,
152
238
  ok: true
153
239
  });
154
240
  } catch (error) {
155
- const detail = error instanceof Error ? error.message : 'The content could not be updated.';
241
+ let detail;
242
+ if (isNotFound(error)) {
243
+ if (missingOk) detail = 'forbidden';
244
+ else detail = 'not-found';
245
+ } else {
246
+ detail = contentFailureReason(error);
247
+ }
248
+ req.payload.logger.error({
249
+ err: safeError(error),
250
+ experimentId,
251
+ collection,
252
+ id
253
+ }, 'Experiment content update failed');
156
254
  docs.push({
157
255
  collection,
158
256
  id,
159
- ok: false,
160
- detail,
161
- error: detail
257
+ ok: Boolean(missing),
258
+ detail: missing ? 'not-found' : detail,
259
+ ...!missing ? {
260
+ error: detail
261
+ } : {}
162
262
  });
163
263
  }
164
264
  };
165
265
  if (current.kind === 'block') {
166
266
  if (!winner.slot) refuse('invalid-winner');
167
- const report = decision?.contentReport;
168
267
  const registrations = puckCollections(options);
169
268
  const scanned = [];
170
269
  for (const registration of registrations){
@@ -180,13 +279,18 @@ export async function makeContent(req, { experimentId, options }) {
180
279
  } catch (error) {
181
280
  // A collection made unreadable since the last attempt still has
182
281
  // retained placements to report below. Never sacrifice the outer tx.
282
+ req.payload.logger.error({
283
+ err: safeError(error),
284
+ experimentId,
285
+ collection: registration.slug
286
+ }, 'Experiment content scan failed');
183
287
  const denied = error && typeof error === 'object' && 'status' in error && error.status === 403;
184
288
  if (!denied || !report?.docs?.some((doc)=>doc.collection === registration.slug)) throw error;
185
289
  } finally{
186
290
  req.transactionID = transactionID;
187
291
  }
188
292
  }
189
- if (scanned.some((doc)=>doc.locked)) refuse('locked-placement');
293
+ if (scanned.some((doc)=>doc.locked && !previous.get(doc.collection + ':' + doc.id)?.dismissedAt)) refuse('locked-placement');
190
294
  const placements = new Map();
191
295
  for (const doc of [
192
296
  ...report?.docs ?? [],
@@ -196,14 +300,13 @@ export async function makeContent(req, { experimentId, options }) {
196
300
  // Probe with no borrowed transaction: disableErrors handles expected
197
301
  // NotFound/Forbidden, and even a host read hook cannot kill the outer tx.
198
302
  for (const [key, { collection, id }] of placements){
303
+ if (retainDismissed(collection, id)) {
304
+ skipped.add(key);
305
+ continue;
306
+ }
199
307
  const transactionID = req.transactionID;
200
308
  delete req.transactionID;
201
309
  try {
202
- const config = req.payload.config.collections.find((config)=>config.slug === collection);
203
- const access = config?.access?.read ? await config.access.read({
204
- id,
205
- req
206
- }) : true;
207
310
  const doc = await req.payload.findByID({
208
311
  collection,
209
312
  id,
@@ -214,34 +317,44 @@ export async function makeContent(req, { experimentId, options }) {
214
317
  includeLockStatus: true,
215
318
  disableErrors: true
216
319
  });
217
- if (!doc) {
218
- // Fail closed (exp-orch 16:20): only an unfiltered read proves the page is gone;
219
- // a Where-filtered miss may be a page this manager cannot see.
220
- const ok = access === true || typeof access === 'object' && access !== null && Object.keys(access).length === 0;
221
- const detail = ok ? 'This page no longer exists; nothing to change.' : access === false ? 'You can no longer read this page.' : 'You can no longer read this page. If it was deleted, archive this experiment with discard or hard-coded instead.';
222
- docs.push({
223
- collection,
224
- id,
225
- ok,
226
- detail,
227
- ...!ok ? {
228
- error: detail
229
- } : {}
230
- });
231
- skipped.add(key);
232
- } else if (doc._isLocked === true || doc.locked === true) refuse('locked-placement', {
320
+ // Null can also mean trashed or hidden by a host hook. Use the same
321
+ // unfiltered existence check as a thrown NotFound before reporting done.
322
+ if (!doc) throw new NotFound();
323
+ if (doc._isLocked === true || doc.locked === true) refuse('locked-placement', {
233
324
  collection,
234
325
  id
235
326
  });
236
327
  } catch (error) {
237
328
  if (error && typeof error === 'object' && 'code' in error && error.code === 'locked-placement') throw error;
238
- const missing = error && typeof error === 'object' && 'status' in error && error.status === 404;
239
- const detail = missing ? 'This page no longer exists; nothing to change.' : 'You can no longer read this page.';
329
+ let missing = false;
330
+ let detail = isNotFound(error) ? 'forbidden' : contentFailureReason(error);
331
+ if (isNotFound(error)) {
332
+ try {
333
+ await hostTransaction(req, async ()=>{
334
+ missing = !await readPublishedContent(req, collection, id);
335
+ });
336
+ } catch (checkError) {
337
+ missing = false;
338
+ detail = 'update-failed';
339
+ req.payload.logger.error({
340
+ err: safeError(checkError),
341
+ experimentId,
342
+ collection,
343
+ id
344
+ }, 'Experiment content existence check failed');
345
+ }
346
+ }
347
+ req.payload.logger.error({
348
+ err: safeError(error),
349
+ experimentId,
350
+ collection,
351
+ id
352
+ }, 'Experiment content read failed');
240
353
  docs.push({
241
354
  collection,
242
355
  id,
243
356
  ok: Boolean(missing),
244
- detail,
357
+ detail: missing ? 'not-found' : detail,
245
358
  ...!missing ? {
246
359
  error: detail
247
360
  } : {}
@@ -256,7 +369,9 @@ export async function makeContent(req, { experimentId, options }) {
256
369
  await attempt(collection, id, async ()=>{
257
370
  const field = registrations.find((entry)=>entry.slug === collection)?.field;
258
371
  if (!field) throw new Error('This content collection is no longer configured.');
259
- const draft = await req.payload.findByID({
372
+ const config = req.payload.config.collections.find((config)=>config.slug === collection);
373
+ if (hasLocalizedFields(config?.fields ?? [], req.payload.config)) throw new LocalizedContent();
374
+ await req.payload.findByID({
260
375
  collection,
261
376
  id,
262
377
  req,
@@ -264,50 +379,48 @@ export async function makeContent(req, { experimentId, options }) {
264
379
  depth: 0,
265
380
  draft: true
266
381
  });
267
- const config = req.payload.config.collections.find((config)=>config.slug === collection);
268
382
  const drafts = Boolean(config?.versions && config.versions.drafts);
269
- const published = drafts ? await req.payload.findByID({
270
- collection,
271
- id,
272
- req,
273
- overrideAccess: false,
274
- depth: 0,
275
- draft: false
276
- }) : draft;
277
- const draftContent = unwrap(draft[field], current.slug, winner.slot);
383
+ const published = await readPublishedContent(req, collection, id);
384
+ if (!published) throw new NotFound();
385
+ const draft = drafts ? await latestDraftContent(req, collection, id) ?? published : published;
386
+ const draftValue = draft[field] ?? null;
387
+ const draftContent = unwrap(draftValue, current.slug, winner.slot);
278
388
  const publishedContent = unwrap(published[field], current.slug, winner.slot);
279
- const changedDraft = JSON.stringify(draftContent) !== JSON.stringify(draft[field]);
389
+ const changedDraft = JSON.stringify(draftContent) !== JSON.stringify(draftValue);
280
390
  const changedPublished = JSON.stringify(publishedContent) !== JSON.stringify(published[field]);
281
- // Nothing left to rewrite (e.g. an editor already did it by hand): done, before any refusal —
282
- // but only when the content field was really read (not stripped as hidden/unreadable, not localized).
283
- const contentField = topLevelField(config?.fields ?? [], field);
284
- // Payload strips hidden and read-denied fields from a read, so presence in both proves readability;
285
- // a localized content field compares only one locale, so it never takes the shortcut.
286
- const contentReadable = Object.hasOwn(draft, field) && Object.hasOwn(published, field) && !contentField?.localized;
287
- if (contentReadable && !changedDraft && !changedPublished) return;
288
- for (const doc of [
289
- draft,
290
- published
291
- ]){
292
- const refusal = await fieldRefusal(config?.fields ?? [], req, doc);
293
- if (refusal) throw new Error(refusal);
294
- }
295
- const publishedData = contentData(published, field, publishedContent);
391
+ if (!changedDraft && !changedPublished) return;
392
+ const publishedData = contentData(published, field, publishedContent, config?.fields ?? [], req.payload.config.blocks);
296
393
  const draftData = {
297
- ...contentData(draft, field, draftContent),
394
+ ...contentData(draft, field, draftContent, config?.fields ?? [], req.payload.config.blocks),
298
395
  _status: 'draft'
299
396
  };
300
397
  // Payload beforeValidate receives the latest version as doc, and the
301
398
  // proposed write as data/siblingData (blockData is the proposed row).
302
- const publicationRefusal = await fieldRefusal(config?.fields ?? [], req, draft, 'update', publishedData);
303
- if (publicationRefusal) throw new Error(publicationRefusal);
399
+ if (changedPublished) {
400
+ const publicationRefusal = await fieldRefusal(config?.fields ?? [], req, draft, 'update', publishedData);
401
+ if (publicationRefusal) throw new ValidationError({
402
+ errors: [
403
+ {
404
+ message: publicationRefusal,
405
+ path: field
406
+ }
407
+ ]
408
+ });
409
+ }
304
410
  if (drafts) {
305
411
  const draftOriginal = changedPublished ? {
306
412
  ...published,
307
413
  ...publishedData
308
414
  } : draft;
309
415
  const draftRefusal = await fieldRefusal(config?.fields ?? [], req, draftOriginal, 'update', draftData);
310
- if (draftRefusal) throw new Error(draftRefusal);
416
+ if (draftRefusal) throw new ValidationError({
417
+ errors: [
418
+ {
419
+ message: draftRefusal,
420
+ path: field
421
+ }
422
+ ]
423
+ });
311
424
  }
312
425
  // Host writes are access checked with the manager's req (§7.8).
313
426
  if (changedPublished) await req.payload.update({
@@ -328,7 +441,7 @@ export async function makeContent(req, { experimentId, options }) {
328
441
  draft: true,
329
442
  data: draftData
330
443
  });
331
- });
444
+ }, true);
332
445
  }
333
446
  } else if (current.kind === 'flag') {
334
447
  const point = options.flagPoints?.find((point)=>point.id === current.flagPoint);
@@ -341,6 +454,7 @@ export async function makeContent(req, { experimentId, options }) {
341
454
  },
342
455
  req
343
456
  }));
457
+ else retainDismissed('xp-experiments', experimentId);
344
458
  // Lock the shared state before its read/modify/write, after L/F/E.
345
459
  await lockContentDefaults(exec);
346
460
  // §7.8 transition exception: durable made-content defaults in plugin state.
@@ -376,7 +490,9 @@ export async function makeContent(req, { experimentId, options }) {
376
490
  }
377
491
  }
378
492
  });
379
- if (docs.every((doc)=>doc.ok)) return {
493
+ if (contentDone({
494
+ docs
495
+ })) return {
380
496
  ...await archive(req, {
381
497
  experimentId,
382
498
  mode: 'after-content'
@@ -392,20 +508,3 @@ export async function makeContent(req, { experimentId, options }) {
392
508
  });
393
509
  return result;
394
510
  }
395
- /** The top-level data field with this name, looking through unnamed rows, collapsibles and tabs. */ function topLevelField(fields, name) {
396
- for (const raw of fields){
397
- const field = raw;
398
- if (field.name === name) return field;
399
- if (!field.name && (field.type === 'row' || field.type === 'collapsible') && field.fields) {
400
- const found = topLevelField(field.fields, name);
401
- if (found) return found;
402
- }
403
- if (field.type === 'tabs') {
404
- for (const tab of field.tabs ?? [])if (!tab.name && tab.fields) {
405
- const found = topLevelField(tab.fields, name);
406
- if (found) return found;
407
- }
408
- }
409
- }
410
- return undefined;
411
- }
@@ -13,14 +13,20 @@ export interface PhaseRecord {
13
13
  allocationChanges?: AllocationChange[];
14
14
  targeting: TargetingRule[];
15
15
  metricDefinitions?: Array<Record<string, unknown>>;
16
- analysis?: Record<string, unknown>;
16
+ analysis?: Record<string, unknown> & {
17
+ sequentialTuningParameter?: number | null;
18
+ };
17
19
  contentChanges?: ContentChange[];
18
20
  }
19
21
  export declare function readPhaseRecord(req: PayloadRequest, experimentId: DocId, phase: number): Promise<PhaseRecord & {
20
22
  id: DocId;
21
23
  }>;
22
24
  /** Caller holds the experiment lock and passes the kernel's transaction req. */
23
- export declare function writePhaseRecord(req: PayloadRequest, record: PhaseRecord): Promise<import("payload").JsonObject & import("payload").TypeWithID>;
25
+ export declare function writePhaseRecord(req: PayloadRequest, record: PhaseRecord & {
26
+ analysis: Record<string, unknown> & {
27
+ sequentialTuningParameter: number;
28
+ };
29
+ }): Promise<import("payload").JsonObject & import("payload").TypeWithID>;
24
30
  /** End only an open phase, preserving the original stop time on a later decide.
25
31
  * Caller holds the experiment lock and passes the kernel's transaction req.
26
32
  */
@@ -1,13 +1,10 @@
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;
7
+ localized?: boolean;
11
8
  }
12
9
  export type FlagPlacement = Awaited<ReturnType<NonNullable<FlagPoint['placements']>>>[number];
13
10
  /** Each matching ExperimentSlot is checked separately, including multiple slots
@@ -1,3 +1,4 @@
1
+ import { hasLocalizedFields } from './contentFields.js';
1
2
  import { puckCollections as normalisePuckCollections } from '../options.js';
2
3
  const slots = [
3
4
  'control',
@@ -23,6 +24,7 @@ export async function scanBlockPlacements(req, args) {
23
24
  }
24
25
  })){
25
26
  const publishedIds = new Set();
27
+ const publishedSlugs = new Map();
26
28
  const draftsEnabled = req.payload.config.collections?.some((config)=>config.slug === collection && config.versions && config.versions.drafts);
27
29
  for (const draft of [
28
30
  false,
@@ -50,7 +52,10 @@ export async function scanBlockPlacements(req, args) {
50
52
  for (const doc of result.docs){
51
53
  if (!draft && doc._status === 'draft') continue;
52
54
  if (draft && doc._status !== 'draft' && publishedIds.has(String(doc.id))) continue;
53
- 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
+ }
54
59
  function walk(value) {
55
60
  if (!value || typeof value !== 'object') return;
56
61
  if (Array.isArray(value)) {
@@ -67,10 +72,16 @@ export async function scanBlockPlacements(req, args) {
67
72
  id: doc.id,
68
73
  title: String(doc.title ?? doc.name ?? doc.id),
69
74
  published: !draft || doc._status === 'published',
75
+ ...publishedSlugs.has(String(doc.id)) ? {
76
+ slug: publishedSlugs.get(String(doc.id))
77
+ } : {},
70
78
  slots: Object.fromEntries(slots.map((slot)=>[
71
79
  slot,
72
80
  Array.isArray(props[slot]) ? props[slot].length : 0
73
81
  ])),
82
+ ...hasLocalizedFields(req.payload.config.collections?.find((config)=>config.slug === collection)?.fields ?? [], req.payload.config) ? {
83
+ localized: true
84
+ } : {},
74
85
  locked: doc._isLocked === true || doc.locked === true
75
86
  });
76
87
  }
@@ -1,4 +1,4 @@
1
- import type { PayloadRequest } from 'payload';
1
+ import { type PayloadRequest } from 'payload';
2
2
  import type { PowerWeeksInput, PowerWeeksResult } from '@delmaredigital/experiments-stats';
3
3
  import type { DocId, ResolvedOptions } from '../types/contracts.js';
4
4
  import type { PreflightResult } from '../types/endpoints.js';
@@ -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
  */