@delmaredigital/payload-experiments 0.2.0-rc.6 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +129 -11
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +24 -16
  4. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  5. package/dist/admin/actions/ConfirmDialog.js +37 -82
  6. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  7. package/dist/admin/actions/DecideDialog.js +30 -13
  8. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  9. package/dist/admin/actions/MakeContentResult.js +115 -16
  10. package/dist/admin/actions/actions.model.d.ts +2 -4
  11. package/dist/admin/actions/actions.model.js +2 -3
  12. package/dist/admin/allocation.model.js +3 -5
  13. package/dist/admin/api.d.ts +10 -4
  14. package/dist/admin/api.js +11 -7
  15. package/dist/admin/copy/actions.d.ts +39 -3
  16. package/dist/admin/copy/actions.js +40 -4
  17. package/dist/admin/copy/armSelect.js +5 -5
  18. package/dist/admin/copy/arms.d.ts +1 -7
  19. package/dist/admin/copy/arms.js +1 -7
  20. package/dist/admin/copy/audience.d.ts +2 -1
  21. package/dist/admin/copy/audience.js +3 -2
  22. package/dist/admin/copy/common.d.ts +13 -6
  23. package/dist/admin/copy/common.js +14 -8
  24. package/dist/admin/copy/goals.d.ts +21 -6
  25. package/dist/admin/copy/goals.js +27 -6
  26. package/dist/admin/copy/readiness.d.ts +19 -0
  27. package/dist/admin/copy/readiness.js +19 -0
  28. package/dist/admin/copy/recommendation.d.ts +0 -3
  29. package/dist/admin/copy/recommendation.js +0 -3
  30. package/dist/admin/copy/results.d.ts +41 -2
  31. package/dist/admin/copy/results.js +42 -3
  32. package/dist/admin/copy/review.d.ts +1 -1
  33. package/dist/admin/copy/review.js +1 -1
  34. package/dist/admin/copy/setting.d.ts +2 -0
  35. package/dist/admin/copy/setting.js +2 -0
  36. package/dist/admin/copy/surface.d.ts +7 -4
  37. package/dist/admin/copy/surface.js +9 -4
  38. package/dist/admin/copy/testPage.d.ts +12 -0
  39. package/dist/admin/copy/testPage.js +12 -0
  40. package/dist/admin/format/date.d.ts +2 -0
  41. package/dist/admin/format/date.js +10 -0
  42. package/dist/admin/labels.d.ts +2 -7
  43. package/dist/admin/labels.js +2 -7
  44. package/dist/admin/previewLinks.model.d.ts +2 -2
  45. package/dist/admin/previewLinks.model.js +5 -7
  46. package/dist/admin/results/CiChart.js +11 -21
  47. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  48. package/dist/admin/results/DataQualityPanel.js +30 -17
  49. package/dist/admin/results/DataTable.d.ts +16 -0
  50. package/dist/admin/results/DataTable.js +152 -0
  51. package/dist/admin/results/StatTile.d.ts +7 -0
  52. package/dist/admin/results/StatTile.js +22 -0
  53. package/dist/admin/results/VerdictHero.d.ts +13 -0
  54. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  55. package/dist/admin/results/releasePages.model.d.ts +17 -0
  56. package/dist/admin/results/releasePages.model.js +10 -0
  57. package/dist/admin/results/results.css +64 -0
  58. package/dist/admin/results/results.model.d.ts +6 -2
  59. package/dist/admin/results/results.model.js +12 -5
  60. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  61. package/dist/admin/surface/ResultsRoute.js +60 -31
  62. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  63. package/dist/admin/surface/SurfaceShell.js +49 -14
  64. package/dist/admin/surface/TestsHome.d.ts +6 -1
  65. package/dist/admin/surface/TestsHome.js +183 -112
  66. package/dist/admin/surface/TestsSurface.js +56 -16
  67. package/dist/admin/surface/contracts.d.ts +1 -0
  68. package/dist/admin/surface/goals/GoalForm.js +341 -308
  69. package/dist/admin/surface/goals/GoalsList.js +123 -89
  70. package/dist/admin/surface/goals/data.d.ts +8 -2
  71. package/dist/admin/surface/goals/data.js +11 -3
  72. package/dist/admin/surface/goals/goals.css +10 -13
  73. package/dist/admin/surface/home.model.d.ts +7 -8
  74. package/dist/admin/surface/home.model.js +23 -12
  75. package/dist/admin/surface/newTest/NewTest.js +5 -5
  76. package/dist/admin/surface/pageBlock/parts.js +31 -26
  77. package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
  78. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  79. package/dist/admin/surface/readiness/Banner.js +63 -36
  80. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  81. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  82. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  83. package/dist/admin/surface/readiness/readiness.model.js +18 -9
  84. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  85. package/dist/admin/surface/readiness/useTransition.js +2 -1
  86. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  87. package/dist/admin/surface/results/ReleasePages.js +116 -0
  88. package/dist/admin/surface/results/ResultsBody.d.ts +15 -2
  89. package/dist/admin/surface/results/ResultsBody.js +281 -107
  90. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  91. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  92. package/dist/admin/surface/results/recommendation.model.js +2 -6
  93. package/dist/admin/surface/results/useResults.d.ts +21 -3
  94. package/dist/admin/surface/results/useResults.js +2 -1
  95. package/dist/admin/surface/setting/parts.js +75 -88
  96. package/dist/admin/surface/setting/setting.css +3 -15
  97. package/dist/admin/surface/surface.css +62 -47
  98. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  99. package/dist/admin/surface/test/GoalSection.js +39 -32
  100. package/dist/admin/surface/test/LengthSection.js +46 -34
  101. package/dist/admin/surface/test/Rail.d.ts +4 -0
  102. package/dist/admin/surface/test/Rail.js +83 -0
  103. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  104. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  105. package/dist/admin/surface/test/Section.d.ts +1 -1
  106. package/dist/admin/surface/test/Section.js +36 -16
  107. package/dist/admin/surface/test/TargetingSection.js +168 -142
  108. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  109. package/dist/admin/surface/test/TestPage.js +146 -59
  110. package/dist/admin/surface/test/VersionsSection.js +151 -120
  111. package/dist/admin/surface/test/fields.d.ts +11 -4
  112. package/dist/admin/surface/test/fields.js +84 -85
  113. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  114. package/dist/admin/surface/test/jumpToSection.js +10 -0
  115. package/dist/admin/surface/test/pageParts.js +9 -3
  116. package/dist/admin/surface/test/testPage.css +81 -14
  117. package/dist/admin/surface/useTestDocument.d.ts +1 -0
  118. package/dist/admin/surface/useTestDocument.js +40 -9
  119. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  120. package/dist/admin/targeting/targeting.model.js +3 -8
  121. package/dist/admin/theme.css +115 -36
  122. package/dist/admin/theme.d.ts +38 -0
  123. package/dist/admin/theme.js +38 -1
  124. package/dist/admin/useApi.d.ts +17 -2
  125. package/dist/admin/views/ExperimentsRootView.js +12 -2
  126. package/dist/collections/experiments/config.js +44 -1
  127. package/dist/collections/experiments/lockHook.js +16 -2
  128. package/dist/collections/identities.js +6 -1
  129. package/dist/collections/metrics.js +7 -0
  130. package/dist/collections/metricsGuards.d.ts +9 -0
  131. package/dist/collections/metricsGuards.js +33 -0
  132. package/dist/constants.d.ts +1 -0
  133. package/dist/constants.js +1 -0
  134. package/dist/contentDone.d.ts +4 -0
  135. package/dist/contentDone.js +5 -0
  136. package/dist/db/identities.d.ts +8 -7
  137. package/dist/db/identities.js +9 -6
  138. package/dist/db/metricUsage.d.ts +19 -0
  139. package/dist/db/metricUsage.js +54 -0
  140. package/dist/db/names.d.ts +5 -0
  141. package/dist/db/names.js +5 -0
  142. package/dist/db/publishedContent.d.ts +10 -0
  143. package/dist/db/publishedContent.js +43 -0
  144. package/dist/db/results.d.ts +2 -0
  145. package/dist/db/results.js +6 -2
  146. package/dist/db/snapshots.d.ts +18 -6
  147. package/dist/db/snapshots.js +23 -7
  148. package/dist/db/staleEdit.d.ts +6 -0
  149. package/dist/db/staleEdit.js +10 -0
  150. package/dist/db/sweep.js +11 -6
  151. package/dist/endpoints/events.js +3 -3
  152. package/dist/endpoints/index.js +19 -1
  153. package/dist/endpoints/recompute.js +2 -1
  154. package/dist/endpoints/results.js +5 -0
  155. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  156. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  157. package/dist/endpoints/summary.js +20 -2
  158. package/dist/endpoints/transitions.d.ts +15 -0
  159. package/dist/endpoints/transitions.js +36 -0
  160. package/dist/errors.d.ts +8 -0
  161. package/dist/errors.js +28 -0
  162. package/dist/exports/adminClient.d.ts +1 -0
  163. package/dist/exports/adminClient.js +1 -0
  164. package/dist/exports/index.d.ts +2 -1
  165. package/dist/exports/index.js +1 -0
  166. package/dist/exports/puckEditor.d.ts +1 -0
  167. package/dist/exports/puckEditor.js +1 -0
  168. package/dist/exports/rsc.d.ts +5 -1
  169. package/dist/exports/rsc.js +2 -0
  170. package/dist/hooks/hostHook.js +3 -2
  171. package/dist/hooks/pageGuard.d.ts +5 -0
  172. package/dist/hooks/pageGuard.js +121 -0
  173. package/dist/labels/arms.d.ts +7 -0
  174. package/dist/labels/arms.js +7 -0
  175. package/dist/log.d.ts +7 -0
  176. package/dist/log.js +38 -0
  177. package/dist/manifest/compile.d.ts +5 -0
  178. package/dist/manifest/compile.js +5 -1
  179. package/dist/options.js +23 -0
  180. package/dist/plugin.js +26 -2
  181. package/dist/puck/copy.d.ts +1 -1
  182. package/dist/puck/copy.js +6 -6
  183. package/dist/puck/editor/editor.chrome.css +235 -0
  184. package/dist/puck/editor/editor.css +241 -43
  185. package/dist/results/aggregate.js +4 -3
  186. package/dist/results/analyse.d.ts +4 -1
  187. package/dist/results/analyse.js +27 -11
  188. package/dist/results/cuped.js +3 -2
  189. package/dist/results/identityCredit.d.ts +15 -7
  190. package/dist/results/identityCredit.js +32 -33
  191. package/dist/results/rolloutUnits.js +2 -1
  192. package/dist/results/snapshot.js +8 -2
  193. package/dist/results/srm.js +2 -1
  194. package/dist/results/verdict.d.ts +2 -0
  195. package/dist/results/verdict.js +18 -1
  196. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  197. package/dist/sanitizeReadinessDetail.js +54 -0
  198. package/dist/server/linkIdentity.d.ts +10 -1
  199. package/dist/server/linkIdentity.js +20 -18
  200. package/dist/server/metadata.d.ts +5 -0
  201. package/dist/server/metadata.js +9 -1
  202. package/dist/server/recordContentChange.js +2 -1
  203. package/dist/server/recordExposures.js +3 -3
  204. package/dist/server/runResults.d.ts +3 -2
  205. package/dist/server/runResults.js +125 -34
  206. package/dist/server/trackConversion.js +5 -6
  207. package/dist/targeting/values.d.ts +4 -0
  208. package/dist/targeting/values.js +10 -0
  209. package/dist/transitions/addMetric.js +8 -0
  210. package/dist/transitions/archive.js +2 -3
  211. package/dist/transitions/checkStart.d.ts +6 -3
  212. package/dist/transitions/checkStart.js +15 -0
  213. package/dist/transitions/contentFields.d.ts +7 -0
  214. package/dist/transitions/contentFields.js +20 -0
  215. package/dist/transitions/decide.js +6 -1
  216. package/dist/transitions/deleteExperiment.js +2 -1
  217. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  218. package/dist/transitions/dismissContentEntry.js +45 -0
  219. package/dist/transitions/makeContent.d.ts +5 -2
  220. package/dist/transitions/makeContent.js +217 -118
  221. package/dist/transitions/phaseRecord.d.ts +8 -2
  222. package/dist/transitions/placements.d.ts +1 -0
  223. package/dist/transitions/placements.js +4 -0
  224. package/dist/transitions/preflightData.d.ts +1 -1
  225. package/dist/transitions/preflightData.js +42 -4
  226. package/dist/transitions/releasePages.d.ts +8 -0
  227. package/dist/transitions/releasePages.js +26 -0
  228. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  229. package/dist/transitions/restoreContentEntry.js +47 -0
  230. package/dist/transitions/start.js +30 -5
  231. package/dist/types/contracts.d.ts +14 -0
  232. package/dist/types/endpoints.d.ts +34 -3
  233. package/dist/types/index.d.ts +1 -0
  234. package/dist/types/index.js +1 -0
  235. package/dist/types/reasons.d.ts +3 -0
  236. package/dist/types/reasons.js +1 -0
  237. package/dist/types/snapshot.d.ts +6 -1
  238. package/dist/ui/Banner.d.ts +2 -0
  239. package/dist/ui/Banner.js +2 -0
  240. package/dist/ui/Breadcrumb.d.ts +7 -0
  241. package/dist/ui/Breadcrumb.js +25 -0
  242. package/dist/ui/Button.d.ts +15 -0
  243. package/dist/ui/Button.js +32 -0
  244. package/dist/ui/Card.d.ts +12 -0
  245. package/dist/ui/Card.js +40 -0
  246. package/dist/ui/Checkbox.d.ts +2 -0
  247. package/dist/ui/Checkbox.js +2 -0
  248. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  249. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  250. package/dist/ui/Dialog.d.ts +13 -0
  251. package/dist/ui/Dialog.js +85 -0
  252. package/dist/ui/EmptyState.d.ts +2 -0
  253. package/dist/ui/EmptyState.js +2 -0
  254. package/dist/ui/Feedback.d.ts +24 -0
  255. package/dist/ui/Feedback.js +40 -0
  256. package/dist/ui/Inputs.d.ts +34 -0
  257. package/dist/ui/Inputs.js +225 -0
  258. package/dist/ui/NumberInput.d.ts +2 -0
  259. package/dist/ui/NumberInput.js +2 -0
  260. package/dist/ui/Range.d.ts +2 -0
  261. package/dist/ui/Range.js +2 -0
  262. package/dist/ui/SearchInput.d.ts +2 -0
  263. package/dist/ui/SearchInput.js +2 -0
  264. package/dist/ui/SectionCard.d.ts +2 -0
  265. package/dist/ui/SectionCard.js +2 -0
  266. package/dist/ui/Select.d.ts +2 -0
  267. package/dist/ui/Select.js +2 -0
  268. package/dist/ui/StatusChip.d.ts +2 -0
  269. package/dist/ui/StatusChip.js +2 -0
  270. package/dist/ui/Tabs.d.ts +14 -0
  271. package/dist/ui/Tabs.js +54 -0
  272. package/dist/ui/TextInput.d.ts +2 -0
  273. package/dist/ui/TextInput.js +2 -0
  274. package/dist/ui/Textarea.d.ts +2 -0
  275. package/dist/ui/Textarea.js +2 -0
  276. package/dist/ui/Toggle.d.ts +2 -0
  277. package/dist/ui/Toggle.js +2 -0
  278. package/dist/ui/accent.d.ts +21 -0
  279. package/dist/ui/accent.js +81 -0
  280. package/dist/ui/index.d.ts +9 -0
  281. package/dist/ui/index.js +9 -0
  282. package/dist/ui/ui.css +92 -0
  283. package/package.json +1 -1
  284. package/dist/admin/components/chipsInput.css +0 -11
  285. package/dist/admin/results/ArmTable.d.ts +0 -8
  286. package/dist/admin/results/ArmTable.js +0 -113
  287. package/dist/admin/results/VerdictCard.d.ts +0 -9
  288. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  289. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  290. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  291. package/dist/admin/surface/test/TestHeader.js +0 -69
@@ -2,12 +2,14 @@ import * as paths from '../../admin/paths.js';
2
2
  import { managedAccess } from '../access.js';
3
3
  import { serializeAttributeKeys, serializeFlagPoints } from '../serialize.js';
4
4
  import { TARGETING_OPS, isOpValid } from '../../targeting/ops.js';
5
+ import { readListValue } from '../../targeting/values.js';
5
6
  import { createDefaults } from './createDefaults.js';
6
7
  import { flagControlParams } from './flagControlParams.js';
7
8
  import { lockHook } from './lockHook.js';
8
9
  import { deleteGuard, deleteGuardAfter } from './deleteGuard.js';
9
10
  import { checkArms } from './armInvariants.js';
10
- import { XpNotImplemented } from '../../errors.js';
11
+ import { XpNotImplemented, XpRefusal } from '../../errors.js';
12
+ const targetingValue = (rule)=>rule.op === 'in' || rule.op === 'nin' ? readListValue(rule.value) : rule.value;
11
13
  const positiveInteger = (value)=>value != null && Number.isInteger(value) && value > 0 || 'Enter a positive whole number.';
12
14
  export function experimentsCollection(options) {
13
15
  const flagPoints = serializeFlagPoints(options);
@@ -91,6 +93,24 @@ export function experimentsCollection(options) {
91
93
  ],
92
94
  afterDelete: [
93
95
  deleteGuardAfter
96
+ ],
97
+ afterError: [
98
+ ({ error })=>{
99
+ // Payload preserves public Error status/message, but omits custom codes.
100
+ if (error instanceof XpRefusal && error.code === 'stale-edit') {
101
+ return {
102
+ status: error.status,
103
+ response: {
104
+ errors: [
105
+ {
106
+ code: error.code,
107
+ message: error.message
108
+ }
109
+ ]
110
+ }
111
+ };
112
+ }
113
+ }
94
114
  ]
95
115
  },
96
116
  fields: [
@@ -206,6 +226,21 @@ export function experimentsCollection(options) {
206
226
  {
207
227
  name: 'targeting',
208
228
  type: 'array',
229
+ validate: (value, { data, previousValue })=>{
230
+ // Payload 3.90.2 beforeChange/promise.js passes merged document data
231
+ // and this document's stored array as previousValue, including in bulk updates.
232
+ const status = data?.status ?? 'draft';
233
+ if (data?.kind === 'block' && (status === 'draft' || status === 'stopped')) {
234
+ for (const rule of value ?? []){
235
+ if (rule.attribute !== 'path') continue;
236
+ const previous = rule.id == null ? undefined : previousValue?.find((stored)=>stored.id != null && String(stored.id) === String(rule.id));
237
+ if (!previous || previous.attribute !== rule.attribute || previous.op !== rule.op || JSON.stringify(targetingValue(previous)) !== JSON.stringify(targetingValue(rule))) {
238
+ return 'Page block tests run on their page; remove the page-path rule.';
239
+ }
240
+ }
241
+ }
242
+ return true;
243
+ },
209
244
  fields: [
210
245
  {
211
246
  name: 'attribute',
@@ -459,6 +494,14 @@ export function experimentsCollection(options) {
459
494
  {
460
495
  name: 'holdoutReleasedAt',
461
496
  type: 'date'
497
+ },
498
+ {
499
+ name: 'pagesReleasedAt',
500
+ type: 'date'
501
+ },
502
+ {
503
+ name: 'pagesReleasedBy',
504
+ type: 'text'
462
505
  }
463
506
  ]
464
507
  }
@@ -1,4 +1,7 @@
1
- import { XpLockedFieldError } from '../../errors.js';
1
+ import { XpLockedFieldError, XpRefusal } from '../../errors.js';
2
+ import { XP_IF_UPDATED_AT_HEADER } from '../../constants.js';
3
+ import { txExecutor } from '../../db/executor.js';
4
+ import { lockExperimentUpdatedAt } from '../../db/staleEdit.js';
2
5
  import { xpOptionsFromReq } from '../../options/fromReq.js';
3
6
  import { EDITABLE_BY_STATUS } from './editability.js';
4
7
  import { defaultArms } from './armInvariants.js';
@@ -9,6 +12,7 @@ const relationships = new Set([
9
12
  'metrics.secondary',
10
13
  'metrics.guardrails',
11
14
  'decision.decidedBy',
15
+ 'decision.pagesReleasedBy',
12
16
  'decision.snapshotId'
13
17
  ]);
14
18
  const dates = new Set([
@@ -17,7 +21,8 @@ const dates = new Set([
17
21
  'decidedAt',
18
22
  'archivedAt',
19
23
  'decision.discardedAt',
20
- 'decision.holdoutReleasedAt'
24
+ 'decision.holdoutReleasedAt',
25
+ 'decision.pagesReleasedAt'
21
26
  ]);
22
27
  /** Schema-aware: JSON retains its own ids and date-like strings. */ function normalize(value, path, json = false) {
23
28
  json ||= [
@@ -60,6 +65,15 @@ const dates = new Set([
60
65
  const same = (a, b, path)=>JSON.stringify(normalize(a, path)) === JSON.stringify(normalize(b, path));
61
66
  export const lockHook = async ({ data, originalDoc, operation, req })=>{
62
67
  if (req.context.xpTransition === true) return data;
68
+ const baseline = req.headers.get(XP_IF_UPDATED_AT_HEADER);
69
+ if (operation === 'update' && baseline !== null) {
70
+ // originalDoc predates beforeChange; read again under a transaction row lock
71
+ // so a competing save observes the preceding save's committed timestamp.
72
+ const stored = await lockExperimentUpdatedAt(txExecutor(req), originalDoc.id);
73
+ if (stored === null || new Date(baseline).getTime() !== new Date(stored).getTime()) {
74
+ throw new XpRefusal('stale-edit', 'Someone else changed this test. Reload to see their changes.', undefined, 409);
75
+ }
76
+ }
63
77
  const admin = async ()=>{
64
78
  const options = xpOptionsFromReq(req);
65
79
  return await (options.access.admin ?? options.access.manage)({
@@ -19,12 +19,17 @@ export function identitiesCollection(options) {
19
19
  {
20
20
  name: 'userId',
21
21
  type: 'text',
22
- required: true
22
+ required: true,
23
+ index: true
23
24
  },
24
25
  {
25
26
  name: 'linkedAt',
26
27
  type: 'date',
27
28
  required: true
29
+ },
30
+ {
31
+ name: 'verifiedAt',
32
+ type: 'date'
28
33
  }
29
34
  ]
30
35
  };
@@ -2,6 +2,7 @@ import { XP_METRIC_SOURCE_SELECT } from '../admin/paths.js';
2
2
  import { managedAccess } from './access.js';
3
3
  import { serializeMetricSources } from './serialize.js';
4
4
  import { validateCappingType, validateCappingValue } from './metricValidation.js';
5
+ import { metricChangeGuard, metricDeleteGuard } from './metricsGuards.js';
5
6
  export function metricsCollection(options) {
6
7
  const sources = serializeMetricSources(options);
7
8
  return {
@@ -20,6 +21,12 @@ export function metricsCollection(options) {
20
21
  },
21
22
  access: managedAccess(options),
22
23
  hooks: {
24
+ beforeDelete: [
25
+ metricDeleteGuard
26
+ ],
27
+ beforeChange: [
28
+ metricChangeGuard
29
+ ],
23
30
  beforeValidate: [
24
31
  ({ data, originalDoc })=>{
25
32
  if (!data) return data;
@@ -0,0 +1,9 @@
1
+ import type { CollectionBeforeChangeHook, CollectionBeforeDeleteHook } from 'payload';
2
+ /** Payload's REST formatter preserves public plugin errors and their 4xx status. */
3
+ export declare class XpGoalInUseError extends Error {
4
+ name: string;
5
+ readonly status = 400;
6
+ readonly isPublic = true;
7
+ }
8
+ export declare const metricDeleteGuard: CollectionBeforeDeleteHook;
9
+ export declare const metricChangeGuard: CollectionBeforeChangeHook;
@@ -0,0 +1,33 @@
1
+ import { pooledExecutor, txExecutor } from '../db/executor.js';
2
+ import { findMetricUsage, lockMetricRow } from '../db/metricUsage.js';
3
+ /** Payload's REST formatter preserves public plugin errors and their 4xx status. */ export class XpGoalInUseError extends Error {
4
+ name = 'XpGoalInUseError';
5
+ status = 400;
6
+ isPublic = true;
7
+ }
8
+ async function executor(req, id, mode) {
9
+ const transactionID = await req.transactionID;
10
+ if (transactionID != null) {
11
+ const exec = txExecutor(req);
12
+ await lockMetricRow(exec, id, mode);
13
+ return exec;
14
+ }
15
+ // With no transaction a pooled row lock would expire before the usage read;
16
+ // retain the existing best-effort usage check without taking a lock.
17
+ return pooledExecutor(req.payload);
18
+ }
19
+ export const metricDeleteGuard = async ({ req, id })=>{
20
+ const usage = await findMetricUsage(await executor(req, id, 'delete'), id);
21
+ if (usage) throw new XpGoalInUseError(`This goal is used by the test "${usage.name}". Remove it from the test first.`);
22
+ };
23
+ export const metricChangeGuard = async ({ req, data, originalDoc, operation })=>{
24
+ if (operation !== 'update') return data;
25
+ const changed = [
26
+ 'slug',
27
+ 'source'
28
+ ].some((field)=>field in data && data[field] !== originalDoc[field]);
29
+ if (changed && await findMetricUsage(await executor(req, originalDoc.id, 'change'), originalDoc.id)) {
30
+ throw new XpGoalInUseError("This goal is used by a test, so its short name and what it measures can't change. Create a new goal instead.");
31
+ }
32
+ return data;
33
+ };
@@ -6,3 +6,4 @@ export declare const XP_HEADER = "x-xp-assignments";
6
6
  export declare const XP_PREVIEW_PARAM = "xp_preview";
7
7
  export declare const XP_MANIFEST_KEY_HEADER = "x-xp-manifest-key";
8
8
  export declare const XP_REQUEST_HEADER = "x-xp-request";
9
+ export declare const XP_IF_UPDATED_AT_HEADER = "x-xp-if-updated-at";
package/dist/constants.js CHANGED
@@ -6,3 +6,4 @@ export const XP_HEADER = 'x-xp-assignments';
6
6
  export const XP_PREVIEW_PARAM = 'xp_preview';
7
7
  export const XP_MANIFEST_KEY_HEADER = 'x-xp-manifest-key';
8
8
  export const XP_REQUEST_HEADER = 'x-xp-request';
9
+ export const XP_IF_UPDATED_AT_HEADER = 'x-xp-if-updated-at';
@@ -0,0 +1,4 @@
1
+ import type { ContentReport } from './types/endpoints.js';
2
+ /** Every recorded content entry is updated or handled by the manager. A present but empty report is done:
3
+ * page tests and flag tests without a host callback record no entries. An absent report is not done. */
4
+ export declare function contentDone(report: ContentReport | null | undefined): boolean;
@@ -0,0 +1,5 @@
1
+ /** Every recorded content entry is updated or handled by the manager. A present but empty report is done:
2
+ * page tests and flag tests without a host callback record no entries. An absent report is not done. */ export function contentDone(report) {
3
+ const docs = Array.isArray(report) ? report : report?.docs;
4
+ return Array.isArray(docs) && docs.every((doc)=>doc?.ok === true || Boolean(doc?.dismissedAt));
5
+ }
@@ -1,11 +1,12 @@
1
1
  import type { Payload } from 'payload';
2
2
  import { type Executor } from './executor.js';
3
- export declare function insertIdentity(exec: Executor, { anonymousId, userId }: {
3
+ export type IdentityWriteOutcome = 'linked' | 'verified' | 'skipped';
4
+ type IdentityInput = {
4
5
  anonymousId: string;
5
6
  userId: string;
6
- }): Promise<void>;
7
- /** §5.6 / §7.8: privileged insert, isolated from the caller's sign-in transaction. */
8
- export declare function insertIdentityOnOwnConnection(payload: Payload, identity: {
9
- anonymousId: string;
10
- userId: string;
11
- }): Promise<void>;
7
+ verified: boolean;
8
+ };
9
+ export declare function insertIdentity(exec: Executor, { anonymousId, userId, verified }: IdentityInput): Promise<IdentityWriteOutcome>;
10
+ /** §5.6 / §7.8: privileged insert/upgrade, isolated from the caller's sign-in transaction. */
11
+ export declare function insertIdentityOnOwnConnection(payload: Payload, identity: IdentityInput): Promise<IdentityWriteOutcome>;
12
+ export {};
@@ -1,26 +1,29 @@
1
1
  import { ownConnection } from './executor.js';
2
2
  import { COLUMNS, TABLES } from './names.js';
3
3
  const quote = (name)=>`"${name.replaceAll('"', '""')}"`;
4
- export async function insertIdentity(exec, { anonymousId, userId }) {
4
+ export async function insertIdentity(exec, { anonymousId, userId, verified }) {
5
5
  const schema = exec.schemaName;
6
6
  const table = `${schema ? `${quote(schema)}.` : ''}${quote(TABLES.identities)}`;
7
7
  const c = COLUMNS.identities;
8
- await exec.query(`INSERT INTO ${table} (${[
8
+ const { rows } = await exec.query(`INSERT INTO ${table} AS identity (${[
9
9
  c.anonymousId,
10
10
  c.userId,
11
11
  c.linkedAt,
12
+ c.verifiedAt,
12
13
  c.createdAt,
13
14
  c.updatedAt
14
- ].map(quote).join(', ')}) VALUES ($1, $2, now(), now(), now()) ON CONFLICT (${quote(c.anonymousId)}) DO NOTHING`, [
15
+ ].map(quote).join(', ')}) VALUES ($1, $2, now(), CASE WHEN $3 THEN now() END, now(), now()) ON CONFLICT (${quote(c.anonymousId)}) DO UPDATE SET ${quote(c.verifiedAt)} = now(), ${quote(c.updatedAt)} = now() WHERE identity.${quote(c.userId)} = EXCLUDED.${quote(c.userId)} AND identity.${quote(c.verifiedAt)} IS NULL AND $3 RETURNING (xmax = 0) AS inserted`, [
15
16
  anonymousId,
16
- userId
17
+ userId,
18
+ verified
17
19
  ]);
20
+ return rows.length === 0 ? 'skipped' : rows[0].inserted ? 'linked' : 'verified';
18
21
  }
19
- /** §5.6 / §7.8: privileged insert, isolated from the caller's sign-in transaction. */ export async function insertIdentityOnOwnConnection(payload, identity) {
22
+ /** §5.6 / §7.8: privileged insert/upgrade, isolated from the caller's sign-in transaction. */ export async function insertIdentityOnOwnConnection(payload, identity) {
20
23
  const db = payload.db;
21
24
  const exec = await ownConnection(db.pool);
22
25
  try {
23
- await insertIdentity(Object.assign(exec, {
26
+ return await insertIdentity(Object.assign(exec, {
24
27
  schemaName: db.schemaName
25
28
  }), identity);
26
29
  } finally{
@@ -0,0 +1,19 @@
1
+ import type { Executor } from './executor.js';
2
+ import type { DocId } from '../types/contracts.js';
3
+ /** Request transaction only: serialize the guard with FK checks and freezes. */
4
+ export declare function lockMetricRow(exec: Executor, id: DocId, mode: 'delete' | 'change'): Promise<void>;
5
+ /** Transition transaction only, after advisory locks. SQL orders by the native
6
+ * id type so overlapping freezes always acquire row locks in the same order.
7
+ * Return the locked identities so callers can reject a change that committed
8
+ * after preflight. Missing rows mean a goal was deleted; never freeze that copy.
9
+ * txExecutor throws if the request no longer has a transaction session.
10
+ */
11
+ export declare function lockMetricsForFreeze(exec: Executor, ids: DocId[]): Promise<Array<{
12
+ id: DocId;
13
+ slug: string;
14
+ source: string;
15
+ }> | null>;
16
+ /** Live selections cover drafts; frozen definitions keep historical goals in use. */
17
+ export declare function findMetricUsage(exec: Executor, id: DocId): Promise<{
18
+ name: string;
19
+ } | null>;
@@ -0,0 +1,54 @@
1
+ import { COLUMNS, TABLES } from './names.js';
2
+ import { qualifiedTable, quoteIdentifier as q } from './sql.js';
3
+ /** Request transaction only: serialize the guard with FK checks and freezes. */ export async function lockMetricRow(exec, id, mode) {
4
+ const lock = mode === 'delete' ? 'FOR UPDATE' : 'FOR NO KEY UPDATE';
5
+ await exec.query(`SELECT 1 FROM ${qualifiedTable(exec, TABLES.metrics)} WHERE ${q(COLUMNS.metrics.id)} = $1 ${lock}`, [
6
+ id
7
+ ]);
8
+ }
9
+ /** Transition transaction only, after advisory locks. SQL orders by the native
10
+ * id type so overlapping freezes always acquire row locks in the same order.
11
+ * Return the locked identities so callers can reject a change that committed
12
+ * after preflight. Missing rows mean a goal was deleted; never freeze that copy.
13
+ * txExecutor throws if the request no longer has a transaction session.
14
+ */ export async function lockMetricsForFreeze(exec, ids) {
15
+ // Deduplicate for the row-count check; SQL ORDER BY sets the lock order.
16
+ const sorted = [
17
+ ...new Map(ids.map((id)=>[
18
+ String(id),
19
+ id
20
+ ])).values()
21
+ ].sort((a, b)=>typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)));
22
+ const m = COLUMNS.metrics, id = q(m.id);
23
+ const result = await exec.query(`SELECT ${id}, ${q(m.slug)}, ${q(m.source)} FROM ${qualifiedTable(exec, TABLES.metrics)} WHERE ${id} = ANY($1) ORDER BY ${id} FOR SHARE`, [
24
+ sorted
25
+ ]);
26
+ return result.rows.length === sorted.length ? result.rows : null;
27
+ }
28
+ /** Live selections cover drafts; frozen definitions keep historical goals in use. */ export async function findMetricUsage(exec, id) {
29
+ const e = COLUMNS.experiments, p = COLUMNS.phases, r = COLUMNS.experimentsRels;
30
+ const result = await exec.query(`
31
+ SELECT COALESCE(usage.name, 'Deleted test') AS name FROM (
32
+ SELECT e.${q(e.name)} AS name, e.${q(e.status)} AS status
33
+ FROM ${qualifiedTable(exec, TABLES.phases)} p
34
+ LEFT JOIN ${qualifiedTable(exec, TABLES.experiments)} e ON e.${q(e.id)} = p.${q(p.experimentId)}
35
+ WHERE jsonb_typeof(p.${q(p.metricDefinitions)}) = 'array' AND EXISTS (
36
+ SELECT 1 FROM jsonb_array_elements(p.${q(p.metricDefinitions)}) definition
37
+ WHERE definition->>'metricId' = $1::text
38
+ )
39
+ UNION ALL
40
+ SELECT e.${q(e.name)} AS name, e.${q(e.status)} AS status
41
+ FROM ${qualifiedTable(exec, TABLES.experiments)} e
42
+ WHERE e.${q(e.metricsPrimaryId)}::text = $1::text OR EXISTS (
43
+ SELECT 1 FROM ${qualifiedTable(exec, TABLES.experimentsRels)} r
44
+ WHERE r.${q(r.parentId)} = e.${q(e.id)}
45
+ AND r.${q(r.path)} IN ('metrics.secondary', 'metrics.guardrails')
46
+ AND r.${q(r.xpMetricsId)}::text = $1::text
47
+ )
48
+ ) usage
49
+ ORDER BY CASE WHEN usage.status IN ('running', 'decided') THEN 0 ELSE 1 END, usage.name
50
+ LIMIT 1`, [
51
+ String(id)
52
+ ]);
53
+ return result.rows[0] ?? null;
54
+ }
@@ -83,6 +83,8 @@ export declare const COLUMNS: {
83
83
  readonly decisionDiscardedAt: "decision_discarded_at";
84
84
  readonly decisionContentReport: "decision_content_report";
85
85
  readonly decisionHoldoutReleasedAt: "decision_holdout_released_at";
86
+ readonly decisionPagesReleasedAt: "decision_pages_released_at";
87
+ readonly decisionPagesReleasedBy: "decision_pages_released_by";
86
88
  readonly updatedAt: "updated_at";
87
89
  readonly createdAt: "created_at";
88
90
  };
@@ -151,6 +153,8 @@ export declare const COLUMNS: {
151
153
  readonly versionDecisionDiscardedAt: "version_decision_discarded_at";
152
154
  readonly versionDecisionContentReport: "version_decision_content_report";
153
155
  readonly versionDecisionHoldoutReleasedAt: "version_decision_holdout_released_at";
156
+ readonly versionDecisionPagesReleasedAt: "version_decision_pages_released_at";
157
+ readonly versionDecisionPagesReleasedBy: "version_decision_pages_released_by";
154
158
  readonly versionUpdatedAt: "version_updated_at";
155
159
  readonly versionCreatedAt: "version_created_at";
156
160
  readonly createdAt: "created_at";
@@ -227,6 +231,7 @@ export declare const COLUMNS: {
227
231
  readonly anonymousId: "anonymous_id";
228
232
  readonly userId: "user_id";
229
233
  readonly linkedAt: "linked_at";
234
+ readonly verifiedAt: "verified_at";
230
235
  readonly updatedAt: "updated_at";
231
236
  readonly createdAt: "created_at";
232
237
  };
package/dist/db/names.js CHANGED
@@ -82,6 +82,8 @@ export const COLUMNS = {
82
82
  decisionDiscardedAt: 'decision_discarded_at',
83
83
  decisionContentReport: 'decision_content_report',
84
84
  decisionHoldoutReleasedAt: 'decision_holdout_released_at',
85
+ decisionPagesReleasedAt: 'decision_pages_released_at',
86
+ decisionPagesReleasedBy: 'decision_pages_released_by',
85
87
  updatedAt: 'updated_at',
86
88
  createdAt: 'created_at'
87
89
  },
@@ -150,6 +152,8 @@ export const COLUMNS = {
150
152
  versionDecisionDiscardedAt: 'version_decision_discarded_at',
151
153
  versionDecisionContentReport: 'version_decision_content_report',
152
154
  versionDecisionHoldoutReleasedAt: 'version_decision_holdout_released_at',
155
+ versionDecisionPagesReleasedAt: 'version_decision_pages_released_at',
156
+ versionDecisionPagesReleasedBy: 'version_decision_pages_released_by',
153
157
  versionUpdatedAt: 'version_updated_at',
154
158
  versionCreatedAt: 'version_created_at',
155
159
  createdAt: 'created_at',
@@ -226,6 +230,7 @@ export const COLUMNS = {
226
230
  anonymousId: 'anonymous_id',
227
231
  userId: 'user_id',
228
232
  linkedAt: 'linked_at',
233
+ verifiedAt: 'verified_at',
229
234
  updatedAt: 'updated_at',
230
235
  createdAt: 'created_at'
231
236
  },
@@ -0,0 +1,10 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { DocId } from '../types/contracts.js';
3
+ /** §7.8 make-content: the published main row, so Payload's draft baseline never fills a field (KI-1). */
4
+ export declare function publishedContent(req: PayloadRequest, collection: string, id: DocId): Promise<(Record<string, unknown> & {
5
+ id: DocId;
6
+ }) | null>;
7
+ /** §7.8 make-content: the latest draft version, so restoring the draft never drops fields the manager cannot read (KI-1). */
8
+ export declare function latestDraftContent(req: PayloadRequest, collection: string, id: DocId): Promise<(Record<string, unknown> & {
9
+ id: DocId;
10
+ }) | undefined>;
@@ -0,0 +1,43 @@
1
+ // Keep this module adapter-only: the §7.8 exception does not permit Local API calls.
2
+ /** §7.8 make-content: the published main row, so Payload's draft baseline never fills a field (KI-1). */ export async function publishedContent(req, collection, id) {
3
+ return req.payload.db.findOne({
4
+ collection,
5
+ req,
6
+ where: {
7
+ id: {
8
+ equals: id
9
+ }
10
+ },
11
+ // Static allow-list marker only; adapter reads have no access layer.
12
+ overrideAccess: true
13
+ });
14
+ }
15
+ /** §7.8 make-content: the latest draft version, so restoring the draft never drops fields the manager cannot read (KI-1). */ export async function latestDraftContent(req, collection, id) {
16
+ const { docs } = await req.payload.db.findVersions({
17
+ collection,
18
+ req,
19
+ limit: 1,
20
+ pagination: false,
21
+ sort: '-updatedAt',
22
+ where: {
23
+ and: [
24
+ {
25
+ parent: {
26
+ equals: id
27
+ }
28
+ },
29
+ {
30
+ latest: {
31
+ equals: true
32
+ }
33
+ }
34
+ ]
35
+ },
36
+ // Static allow-list marker only; adapter reads have no access layer.
37
+ overrideAccess: true
38
+ });
39
+ return docs[0] ? {
40
+ ...docs[0].version,
41
+ id
42
+ } : undefined;
43
+ }
@@ -20,6 +20,8 @@ export interface MetricEventRow {
20
20
  export interface IdentityRow {
21
21
  anonymousId: string;
22
22
  userId: string;
23
+ verifiedAt: number | null;
24
+ ownedAt: number | null;
23
25
  }
24
26
  type PhaseQuery = {
25
27
  experimentId: DocId;
@@ -91,12 +91,16 @@ function phaseFilter({ experimentId, phase, endedAt }, type) {
91
91
  const i = COLUMNS.identities;
92
92
  const rows = [];
93
93
  for(let offset = 0; offset < ids.length; offset += batchSize){
94
- const result = await exec.query(`SELECT ${q(i.anonymousId)} AS "anonymousId", ${q(i.userId)} AS "userId" FROM ${qualifiedTable(exec, TABLES.identities)} WHERE ${q(i.anonymousId)} = ANY($1::text[]) ORDER BY ${q(i.anonymousId)}`, [
94
+ // Ownership is global to the user, including browsers outside these exposure batches.
95
+ const table = qualifiedTable(exec, TABLES.identities);
96
+ const result = await exec.query(`SELECT link.${q(i.anonymousId)} AS "anonymousId", link.${q(i.userId)} AS "userId", round(extract(epoch from link.${q(i.verifiedAt)}) * 1000)::bigint AS "verifiedAt", (SELECT round(extract(epoch from min(owner.${q(i.verifiedAt)})) * 1000)::bigint FROM ${table} owner WHERE owner.${q(i.userId)} = link.${q(i.userId)}) AS "ownedAt" FROM ${table} link WHERE link.${q(i.anonymousId)} = ANY($1::text[]) ORDER BY link.${q(i.anonymousId)}`, [
95
97
  ids.slice(offset, offset + batchSize)
96
98
  ]);
97
99
  rows.push(...result.rows.map((row)=>({
98
100
  anonymousId: row.anonymousId,
99
- userId: String(row.userId)
101
+ userId: String(row.userId),
102
+ verifiedAt: row.verifiedAt == null ? null : Number(row.verifiedAt),
103
+ ownedAt: row.ownedAt == null ? null : Number(row.ownedAt)
100
104
  })));
101
105
  }
102
106
  return rows;
@@ -1,9 +1,10 @@
1
1
  import type { Payload } from 'payload';
2
- import type { DocId, UnitType } from '../types/contracts.js';
2
+ import type { Arm, DocId, ExperimentKind, Status, UnitType } from '../types/contracts.js';
3
3
  import type { Snapshot } from '../types/snapshot.js';
4
4
  import type { AnalysisPhase, FrozenMetric } from '../results/analyse.js';
5
5
  import { type Executor } from './executor.js';
6
6
  export interface ResultsPhase extends AnalysisPhase {
7
+ arms: Array<AnalysisPhase['arms'][number] & Pick<Arm, 'pageTarget'>>;
7
8
  seed: string;
8
9
  allocation: number;
9
10
  metricDefinitions: Array<FrozenMetric & {
@@ -14,11 +15,18 @@ export interface ResultsPhase extends AnalysisPhase {
14
15
  }
15
16
  export interface ResultsCandidate {
16
17
  phase: ResultsPhase;
18
+ frozenSnapshot?: Snapshot | null;
19
+ kind?: ExperimentKind;
20
+ currentPhase?: number;
21
+ winnerArm?: string | null;
17
22
  unitType: UnitType;
18
23
  status: 'running' | 'stopped' | 'decided' | 'archived';
19
24
  latestComputedAt: string | null;
20
25
  testArms: Array<{
26
+ key: string;
21
27
  weight: number;
28
+ kind?: string;
29
+ pageTarget?: string;
22
30
  }>;
23
31
  layerSlug: string | null;
24
32
  layerSlices: number[] | null;
@@ -39,10 +47,14 @@ export declare function writeSnapshots(exec: Executor, snapshots: Snapshot[]): P
39
47
  /** Fetch pointers only, then the endpoint reads the bounded set through Payload
40
48
  * with req/overrideAccess:false. Called behind the readResults/manage gate. */
41
49
  export declare function newestSnapshotIds(exec: Executor, ids: DocId[], per: 'phase' | 'experiment'): Promise<DocId[]>;
42
- /** Called only behind the endpoint's readResults gate. Experiment collection
43
- * reads require manage, so read only the decision metadata needed by results. */
44
- export declare function resultsMetadata(exec: Executor, id: DocId): Promise<{
45
- status: string;
50
+ type ResultsMetadata = {
51
+ name: string | null;
52
+ kind: ExperimentKind;
53
+ status: Status;
46
54
  snapshotId: DocId | null;
47
55
  winnerArm: string | null;
48
- } | null>;
56
+ };
57
+ /** Called only behind the endpoint's readResults gate. Experiment collection
58
+ * reads require manage, so read only the identity and decision metadata needed by results. */
59
+ export declare function resultsMetadata(exec: Executor, id: DocId): Promise<ResultsMetadata | null>;
60
+ export {};
@@ -31,16 +31,32 @@ const LATE_BEACON_GRACE_MS = 24 * 60 * 60 * 1000;
31
31
  'startedAt', p.${q(p.startedAt)}, 'endedAt', p.${q(p.endedAt)}, 'seed', p.${q(p.seed)},
32
32
  'arms', p.${q(p.arms)}, 'allocation', p.${q(p.allocation)}, 'metricDefinitions', p.${q(p.metricDefinitions)},
33
33
  'analysis', p.${q(p.analysis)}, 'contentChanges', p.${q(p.contentChanges)}) AS "phase",
34
- e.${q(e.unitType)} AS "unitType", e.${q(e.status)} AS "status",
34
+ e.${q(e.kind)} AS "kind", e.${q(e.phase)} AS "currentPhase", e.${q(e.unitType)} AS "unitType", e.${q(e.status)} AS "status", e.${q(e.decisionWinnerArm)} AS "winnerArm",
35
35
  (SELECT max(s.${q(s.computedAt)}) FROM ${snapshots} s WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}) AS "latestComputedAt",
36
36
  (SELECT t.${q(p.arms)} FROM ${phases} t WHERE t.${q(p.experimentId)} = p.${q(p.experimentId)} AND t.${q(p.phaseKind)} = 'test' AND t.${q(p.phase)} <= p.${q(p.phase)} ORDER BY t.${q(p.phase)} DESC LIMIT 1) AS "testArms",
37
+ frozen."snapshot" AS "frozenSnapshot",
37
38
  l.${q(l.slug)} AS "layerSlug", e.${q(e.layerSlices)} AS "layerSlices"
38
39
  FROM ${phases} p JOIN ${qualifiedTable(exec, TABLES.experiments)} e ON e.${q(e.id)} = p.${q(p.experimentId)}
39
40
  LEFT JOIN ${qualifiedTable(exec, TABLES.layers)} l ON l.${q(l.id)} = e.${q(e.layerId)}
40
- WHERE (p.${q(p.endedAt)} IS NULL OR p.${q(p.endedAt)} >= $1)
41
- AND NOT EXISTS (SELECT 1 FROM ${snapshots} frozen WHERE frozen.${q(s.experimentId)} = p.${q(p.experimentId)} AND frozen.${q(s.phase)} = p.${q(p.phase)}
42
- AND frozen.${q(s.computedAt)} > p.${q(p.endedAt)} + $2 * interval '1 millisecond')${experimentId === undefined ? '' : ` AND p.${q(p.experimentId)} = $3`}
43
- ORDER BY "latestComputedAt" NULLS FIRST, p.${q(p.experimentId)}, p.${q(p.phase)}`, params);
41
+ LEFT JOIN LATERAL (SELECT s.${q(s.snapshot)} AS "snapshot" FROM ${snapshots} s
42
+ WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}
43
+ AND p.${q(p.endedAt)} IS NOT NULL
44
+ AND (s.${q(s.computedAt)} > p.${q(p.endedAt)} + $2 * interval '1 millisecond' OR p.${q(p.endedAt)} < $1)
45
+ ORDER BY s.${q(s.computedAt)} DESC, s.${q(s.id)} DESC LIMIT 1) frozen ON true
46
+ WHERE ((e.${q(e.kind)} = 'page'
47
+ AND (p.${q(p.endedAt)} IS NULL OR p.${q(p.endedAt)} >= $1 OR EXISTS
48
+ (SELECT 1 FROM ${snapshots} existing WHERE existing.${q(s.experimentId)} = p.${q(p.experimentId)} AND existing.${q(s.phase)} = p.${q(p.phase)}))
49
+ AND e.${q(e.status)} IN ('running', 'decided') AND (p.${q(p.phase)} = e.${q(e.phase)}
50
+ OR p.${q(p.phase)} = (SELECT max(active.${q(p.phase)}) FROM ${phases} active WHERE active.${q(p.experimentId)} = e.${q(e.id)} AND active.${q(p.phaseKind)} = 'test')))
51
+ OR ((p.${q(p.endedAt)} IS NULL OR p.${q(p.endedAt)} >= $1)
52
+ AND NOT EXISTS (SELECT 1 FROM ${snapshots} done WHERE done.${q(s.experimentId)} = p.${q(p.experimentId)} AND done.${q(s.phase)} = p.${q(p.phase)}
53
+ AND done.${q(s.computedAt)} > p.${q(p.endedAt)} + $2 * interval '1 millisecond')))
54
+ ${experimentId === undefined ? '' : ` AND p.${q(p.experimentId)} = $3`}
55
+ ORDER BY (frozen."snapshot" IS NOT NULL),
56
+ CASE WHEN frozen."snapshot" IS NULL THEN
57
+ (SELECT max(s.${q(s.computedAt)}) FROM ${snapshots} s WHERE s.${q(s.experimentId)} = p.${q(p.experimentId)} AND s.${q(s.phase)} = p.${q(p.phase)}) END NULLS FIRST,
58
+ CASE WHEN frozen."snapshot" IS NOT NULL THEN random() END,
59
+ p.${q(p.experimentId)}, p.${q(p.phase)}`, params);
44
60
  return rows;
45
61
  }
46
62
  export async function writeSnapshot(exec, snapshot) {
@@ -80,9 +96,9 @@ export async function writeSnapshot(exec, snapshot) {
80
96
  return rows.map((row)=>row.id);
81
97
  }
82
98
  /** Called only behind the endpoint's readResults gate. Experiment collection
83
- * reads require manage, so read only the decision metadata needed by results. */ export async function resultsMetadata(exec, id) {
99
+ * reads require manage, so read only the identity and decision metadata needed by results. */ export async function resultsMetadata(exec, id) {
84
100
  const c = COLUMNS.experiments;
85
- const { rows } = await exec.query(`SELECT ${q(c.status)} AS "status", ${q(c.decisionSnapshotId)} AS "snapshotId", ${q(c.decisionWinnerArm)} AS "winnerArm" FROM ${qualifiedTable(exec, TABLES.experiments)} WHERE ${q(c.id)} = $1`, [
101
+ const { rows } = await exec.query(`SELECT ${q(c.name)} AS "name", ${q(c.kind)} AS "kind", ${q(c.status)} AS "status", ${q(c.decisionSnapshotId)} AS "snapshotId", ${q(c.decisionWinnerArm)} AS "winnerArm" FROM ${qualifiedTable(exec, TABLES.experiments)} WHERE ${q(c.id)} = $1`, [
86
102
  id
87
103
  ]);
88
104
  return rows[0] ?? null;
@@ -0,0 +1,6 @@
1
+ import type { Executor } from './executor.js';
2
+ import type { DocId } from '../types/contracts.js';
3
+ /** Request transaction only: hold the row lock through the experiment write. */
4
+ export declare function lockExperimentUpdatedAt(exec: Executor & {
5
+ schemaName?: string;
6
+ }, id: DocId): Promise<Date | string | null>;
@@ -0,0 +1,10 @@
1
+ import { COLUMNS, TABLES } from './names.js';
2
+ const quote = (name)=>`"${name.replaceAll('"', '""')}"`;
3
+ /** Request transaction only: hold the row lock through the experiment write. */ export async function lockExperimentUpdatedAt(exec, id) {
4
+ const table = `${exec.schemaName ? `${quote(exec.schemaName)}.` : ''}${quote(TABLES.experiments)}`;
5
+ const columns = COLUMNS.experiments;
6
+ const result = await exec.query(`SELECT ${quote(columns.updatedAt)} AS "updatedAt" FROM ${table} WHERE ${quote(columns.id)} = $1 FOR NO KEY UPDATE`, [
7
+ id
8
+ ]);
9
+ return result.rows[0]?.updatedAt ?? null;
10
+ }