@storyshelf/app 0.6.2 → 0.8.0

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 (429) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +2 -6
  3. package/dist/asset-manifest.mjs +14 -7
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +9 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +9 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +79 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +17 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +74 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +45 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +58 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +96 -119
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +33 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +57 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/credential-keys.mjs +1 -2
  26. package/dist/credentials.mjs +1 -2
  27. package/dist/git-status.mjs +45 -0
  28. package/dist/git-status.mjs.map +1 -0
  29. package/dist/hidden-data.mjs +23 -0
  30. package/dist/hidden-data.mjs.map +1 -0
  31. package/dist/index.d.mts +3 -5
  32. package/dist/index.mjs +204 -127
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/insights/budget.mjs +48 -0
  35. package/dist/insights/budget.mjs.map +1 -0
  36. package/dist/insights/deps.mjs +20 -0
  37. package/dist/insights/deps.mjs.map +1 -0
  38. package/dist/insights/errors.mjs +21 -0
  39. package/dist/insights/errors.mjs.map +1 -0
  40. package/dist/insights/health-panel.mjs +39 -0
  41. package/dist/insights/health-panel.mjs.map +1 -0
  42. package/dist/insights/job.mjs +102 -0
  43. package/dist/insights/job.mjs.map +1 -0
  44. package/dist/insights/load-health.mjs +58 -0
  45. package/dist/insights/load-health.mjs.map +1 -0
  46. package/dist/insights/load-triage.mjs +97 -0
  47. package/dist/insights/load-triage.mjs.map +1 -0
  48. package/dist/insights/panel.mjs +27 -0
  49. package/dist/insights/panel.mjs.map +1 -0
  50. package/dist/insights/request.mjs +112 -0
  51. package/dist/insights/request.mjs.map +1 -0
  52. package/dist/insights/stale.mjs +20 -0
  53. package/dist/insights/stale.mjs.map +1 -0
  54. package/dist/insights/usage-report.mjs +59 -0
  55. package/dist/insights/usage-report.mjs.map +1 -0
  56. package/dist/insights/view.mjs +47 -0
  57. package/dist/insights/view.mjs.map +1 -0
  58. package/dist/job-canceller.mjs +13 -0
  59. package/dist/job-canceller.mjs.map +1 -0
  60. package/dist/job-queue.mjs +50 -0
  61. package/dist/job-queue.mjs.map +1 -0
  62. package/dist/jobs-setup.mjs +131 -0
  63. package/dist/jobs-setup.mjs.map +1 -0
  64. package/dist/lifecycle.mjs +93 -22
  65. package/dist/lifecycle.mjs.map +1 -1
  66. package/dist/middleware/auth-gate.mjs +5 -4
  67. package/dist/middleware/auth-gate.mjs.map +1 -1
  68. package/dist/middleware/csrf.mjs +1 -2
  69. package/dist/middleware/rate-limit.mjs +42 -35
  70. package/dist/middleware/rate-limit.mjs.map +1 -1
  71. package/dist/middleware/request-log.mjs +4 -4
  72. package/dist/middleware/request-log.mjs.map +1 -1
  73. package/dist/middleware/require-review.mjs +50 -0
  74. package/dist/middleware/require-review.mjs.map +1 -0
  75. package/dist/middleware/require-testing.mjs +40 -0
  76. package/dist/middleware/require-testing.mjs.map +1 -0
  77. package/dist/middleware/server-timing.mjs +1 -2
  78. package/dist/middleware/setup-gate.mjs +1 -2
  79. package/dist/middleware/setup-gate.mjs.map +1 -1
  80. package/dist/middleware/store-scope.mjs +13 -4
  81. package/dist/middleware/store-scope.mjs.map +1 -1
  82. package/dist/notifications-pause.mjs +32 -0
  83. package/dist/notifications-pause.mjs.map +1 -0
  84. package/dist/notify.d.mts +3 -4
  85. package/dist/notify.mjs +79 -41
  86. package/dist/notify.mjs.map +1 -1
  87. package/dist/pages/a11y-violations.mjs +115 -0
  88. package/dist/pages/a11y-violations.mjs.map +1 -0
  89. package/dist/pages/admin-ai.mjs +77 -0
  90. package/dist/pages/admin-ai.mjs.map +1 -0
  91. package/dist/pages/admin-system-origin.mjs +132 -0
  92. package/dist/pages/admin-system-origin.mjs.map +1 -0
  93. package/dist/pages/admin-system-targets.mjs +52 -0
  94. package/dist/pages/admin-system-targets.mjs.map +1 -0
  95. package/dist/pages/admin-system.mjs +64 -15
  96. package/dist/pages/admin-system.mjs.map +1 -1
  97. package/dist/pages/build-detail.mjs +105 -78
  98. package/dist/pages/build-detail.mjs.map +1 -1
  99. package/dist/pages/build-diff-actions.mjs +88 -35
  100. package/dist/pages/build-diff-actions.mjs.map +1 -1
  101. package/dist/pages/build-diff-comments.mjs +17 -16
  102. package/dist/pages/build-diff-comments.mjs.map +1 -1
  103. package/dist/pages/build-diff-default.mjs +53 -0
  104. package/dist/pages/build-diff-default.mjs.map +1 -0
  105. package/dist/pages/build-diff-environment.mjs +81 -0
  106. package/dist/pages/build-diff-environment.mjs.map +1 -0
  107. package/dist/pages/build-diff-header.mjs +23 -6
  108. package/dist/pages/build-diff-header.mjs.map +1 -1
  109. package/dist/pages/build-diff-insight.mjs +112 -0
  110. package/dist/pages/build-diff-insight.mjs.map +1 -0
  111. package/dist/pages/build-diff-model.mjs +74 -12
  112. package/dist/pages/build-diff-model.mjs.map +1 -1
  113. package/dist/pages/build-diff-nav.mjs +29 -14
  114. package/dist/pages/build-diff-nav.mjs.map +1 -1
  115. package/dist/pages/build-diff-reference.mjs +84 -0
  116. package/dist/pages/build-diff-reference.mjs.map +1 -0
  117. package/dist/pages/build-diff-shortcuts.mjs +6 -3
  118. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  119. package/dist/pages/build-diff-stale.mjs +1 -2
  120. package/dist/pages/build-diff-usage.mjs +62 -0
  121. package/dist/pages/build-diff-usage.mjs.map +1 -0
  122. package/dist/pages/build-diff-viewer.mjs +97 -14
  123. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  124. package/dist/pages/build-diff.mjs +37 -22
  125. package/dist/pages/build-diff.mjs.map +1 -1
  126. package/dist/pages/build-tests.mjs +73 -0
  127. package/dist/pages/build-tests.mjs.map +1 -0
  128. package/dist/pages/component-detail.mjs +248 -0
  129. package/dist/pages/component-detail.mjs.map +1 -0
  130. package/dist/pages/component-stories.mjs +167 -0
  131. package/dist/pages/component-stories.mjs.map +1 -0
  132. package/dist/pages/compute-jobs.mjs +14 -28
  133. package/dist/pages/compute-jobs.mjs.map +1 -1
  134. package/dist/pages/design-links.mjs +181 -0
  135. package/dist/pages/design-links.mjs.map +1 -0
  136. package/dist/pages/environment-change.mjs +52 -0
  137. package/dist/pages/environment-change.mjs.map +1 -0
  138. package/dist/pages/health-panel.mjs +125 -0
  139. package/dist/pages/health-panel.mjs.map +1 -0
  140. package/dist/pages/invite.mjs +1 -2
  141. package/dist/pages/label-detail.mjs +1 -2
  142. package/dist/pages/library-grid.mjs +104 -0
  143. package/dist/pages/library-grid.mjs.map +1 -0
  144. package/dist/pages/library-model.mjs +46 -0
  145. package/dist/pages/library-model.mjs.map +1 -0
  146. package/dist/pages/library-snapshots.mjs +129 -0
  147. package/dist/pages/library-snapshots.mjs.map +1 -0
  148. package/dist/pages/library.mjs +113 -220
  149. package/dist/pages/library.mjs.map +1 -1
  150. package/dist/pages/login.mjs +1 -2
  151. package/dist/pages/notifications-paused.mjs +21 -0
  152. package/dist/pages/notifications-paused.mjs.map +1 -0
  153. package/dist/pages/passkey-ceremony.mjs +1 -2
  154. package/dist/pages/profile.mjs +54 -50
  155. package/dist/pages/profile.mjs.map +1 -1
  156. package/dist/pages/project-builds-table.mjs +3 -4
  157. package/dist/pages/project-builds-table.mjs.map +1 -1
  158. package/dist/pages/project-builds.mjs +52 -23
  159. package/dist/pages/project-builds.mjs.map +1 -1
  160. package/dist/pages/project-create.mjs +1 -2
  161. package/dist/pages/project-settings.mjs +52 -14
  162. package/dist/pages/project-settings.mjs.map +1 -1
  163. package/dist/pages/projects-card.mjs +15 -3
  164. package/dist/pages/projects-card.mjs.map +1 -1
  165. package/dist/pages/projects.mjs +7 -4
  166. package/dist/pages/projects.mjs.map +1 -1
  167. package/dist/pages/review-changeset.mjs +115 -0
  168. package/dist/pages/review-changeset.mjs.map +1 -0
  169. package/dist/pages/review-comments.mjs +102 -0
  170. package/dist/pages/review-comments.mjs.map +1 -0
  171. package/dist/pages/review-verdict.mjs +101 -0
  172. package/dist/pages/review-verdict.mjs.map +1 -0
  173. package/dist/pages/root.mjs +1 -2
  174. package/dist/pages/settings-ai.mjs +68 -0
  175. package/dist/pages/settings-ai.mjs.map +1 -0
  176. package/dist/pages/settings-capture.mjs +123 -0
  177. package/dist/pages/settings-capture.mjs.map +1 -0
  178. package/dist/pages/settings-design-credential.mjs +98 -0
  179. package/dist/pages/settings-design-credential.mjs.map +1 -0
  180. package/dist/pages/settings-design-links.mjs +178 -0
  181. package/dist/pages/settings-design-links.mjs.map +1 -0
  182. package/dist/pages/settings-design.mjs +47 -0
  183. package/dist/pages/settings-design.mjs.map +1 -0
  184. package/dist/pages/settings-general.mjs +47 -23
  185. package/dist/pages/settings-general.mjs.map +1 -1
  186. package/dist/pages/settings-labels.mjs +44 -4
  187. package/dist/pages/settings-labels.mjs.map +1 -1
  188. package/dist/pages/settings-links.mjs +111 -0
  189. package/dist/pages/settings-links.mjs.map +1 -0
  190. package/dist/pages/settings-members.mjs +1 -2
  191. package/dist/pages/settings-notifications-git.mjs +127 -0
  192. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  193. package/dist/pages/settings-notifications.mjs +80 -44
  194. package/dist/pages/settings-notifications.mjs.map +1 -1
  195. package/dist/pages/settings-tests.mjs +34 -4
  196. package/dist/pages/settings-tests.mjs.map +1 -1
  197. package/dist/pages/settings-tokens.mjs +1 -2
  198. package/dist/pages/settings-webhooks.mjs +1 -2
  199. package/dist/pages/storybook.mjs +1 -2
  200. package/dist/pages/target-choices.mjs +38 -0
  201. package/dist/pages/target-choices.mjs.map +1 -0
  202. package/dist/publish-setup.mjs +88 -0
  203. package/dist/publish-setup.mjs.map +1 -0
  204. package/dist/retention-timer.mjs +12 -12
  205. package/dist/retention-timer.mjs.map +1 -1
  206. package/dist/review-events.mjs +47 -0
  207. package/dist/review-events.mjs.map +1 -0
  208. package/dist/routers/admin-pages.mjs +46 -5
  209. package/dist/routers/admin-pages.mjs.map +1 -1
  210. package/dist/routers/admin-public-origin.mjs +92 -0
  211. package/dist/routers/admin-public-origin.mjs.map +1 -0
  212. package/dist/routers/admin.mjs +8 -4
  213. package/dist/routers/admin.mjs.map +1 -1
  214. package/dist/routers/assets.mjs +10 -4
  215. package/dist/routers/assets.mjs.map +1 -1
  216. package/dist/routers/attempts.mjs +1 -2
  217. package/dist/routers/auth-sync.mjs +1 -2
  218. package/dist/routers/auth.mjs +2 -3
  219. package/dist/routers/baseline-guard.mjs +4 -5
  220. package/dist/routers/baseline-guard.mjs.map +1 -1
  221. package/dist/routers/baseline-reference.mjs +72 -0
  222. package/dist/routers/baseline-reference.mjs.map +1 -0
  223. package/dist/routers/baselines.handlers.mjs +63 -0
  224. package/dist/routers/baselines.handlers.mjs.map +1 -0
  225. package/dist/routers/baselines.mjs +124 -0
  226. package/dist/routers/baselines.mjs.map +1 -0
  227. package/dist/routers/build-cancel.mjs +67 -0
  228. package/dist/routers/build-cancel.mjs.map +1 -0
  229. package/dist/routers/build-changeset.mjs +69 -0
  230. package/dist/routers/build-changeset.mjs.map +1 -0
  231. package/dist/routers/build-reviews.handlers.mjs +85 -0
  232. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  233. package/dist/routers/build-reviews.mjs +73 -0
  234. package/dist/routers/build-reviews.mjs.map +1 -0
  235. package/dist/routers/builds.handlers.mjs +65 -60
  236. package/dist/routers/builds.handlers.mjs.map +1 -1
  237. package/dist/routers/builds.mjs +77 -19
  238. package/dist/routers/builds.mjs.map +1 -1
  239. package/dist/routers/capture-cache.mjs +29 -0
  240. package/dist/routers/capture-cache.mjs.map +1 -0
  241. package/dist/routers/capture-config.mjs +34 -0
  242. package/dist/routers/capture-config.mjs.map +1 -0
  243. package/dist/routers/comments.mjs +42 -17
  244. package/dist/routers/comments.mjs.map +1 -1
  245. package/dist/routers/comparison-image.mjs +29 -0
  246. package/dist/routers/comparison-image.mjs.map +1 -0
  247. package/dist/routers/component-catalog.handlers.mjs +70 -0
  248. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  249. package/dist/routers/component.handlers.mjs +120 -0
  250. package/dist/routers/component.handlers.mjs.map +1 -0
  251. package/dist/routers/connections.mjs +117 -0
  252. package/dist/routers/connections.mjs.map +1 -0
  253. package/dist/routers/design-connect.handlers.mjs +43 -0
  254. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  255. package/dist/routers/design.handlers.mjs +113 -0
  256. package/dist/routers/design.handlers.mjs.map +1 -0
  257. package/dist/routers/flash.mjs +1 -2
  258. package/dist/routers/health-report.mjs +3 -5
  259. package/dist/routers/health-report.mjs.map +1 -1
  260. package/dist/routers/health.mjs +5 -5
  261. package/dist/routers/health.mjs.map +1 -1
  262. package/dist/routers/helpers.mjs +20 -5
  263. package/dist/routers/helpers.mjs.map +1 -1
  264. package/dist/routers/htmx.mjs +1 -2
  265. package/dist/routers/insights-ui.mjs +63 -0
  266. package/dist/routers/insights-ui.mjs.map +1 -0
  267. package/dist/routers/insights.handlers.mjs +44 -0
  268. package/dist/routers/insights.handlers.mjs.map +1 -0
  269. package/dist/routers/insights.mjs +188 -0
  270. package/dist/routers/insights.mjs.map +1 -0
  271. package/dist/routers/labels.mjs +1 -2
  272. package/dist/routers/links.handlers.mjs +79 -0
  273. package/dist/routers/links.handlers.mjs.map +1 -0
  274. package/dist/routers/links.mjs +138 -0
  275. package/dist/routers/links.mjs.map +1 -0
  276. package/dist/routers/media.mjs +30 -7
  277. package/dist/routers/media.mjs.map +1 -1
  278. package/dist/routers/members.mjs +3 -3
  279. package/dist/routers/members.mjs.map +1 -1
  280. package/dist/routers/notification-channels.mjs +18 -25
  281. package/dist/routers/notification-channels.mjs.map +1 -1
  282. package/dist/routers/notification-subscriptions.mjs +1 -2
  283. package/dist/routers/oauth-state.mjs +44 -0
  284. package/dist/routers/oauth-state.mjs.map +1 -0
  285. package/dist/routers/preview-base.mjs +37 -0
  286. package/dist/routers/preview-base.mjs.map +1 -0
  287. package/dist/routers/preview-token.mjs +50 -0
  288. package/dist/routers/preview-token.mjs.map +1 -0
  289. package/dist/routers/profile.mjs +4 -3
  290. package/dist/routers/profile.mjs.map +1 -1
  291. package/dist/routers/projects.mjs +18 -3
  292. package/dist/routers/projects.mjs.map +1 -1
  293. package/dist/routers/publish-upload.mjs +85 -0
  294. package/dist/routers/publish-upload.mjs.map +1 -0
  295. package/dist/routers/published-access.mjs +51 -0
  296. package/dist/routers/published-access.mjs.map +1 -0
  297. package/dist/routers/published-compression.mjs +109 -0
  298. package/dist/routers/published-compression.mjs.map +1 -0
  299. package/dist/routers/published-files.mjs +69 -0
  300. package/dist/routers/published-files.mjs.map +1 -0
  301. package/dist/routers/review-forms.mjs +71 -0
  302. package/dist/routers/review-forms.mjs.map +1 -0
  303. package/dist/routers/review-next.mjs +1 -2
  304. package/dist/routers/review-section.handlers.mjs +84 -0
  305. package/dist/routers/review-section.handlers.mjs.map +1 -0
  306. package/dist/routers/review-switch-off.mjs +28 -0
  307. package/dist/routers/review-switch-off.mjs.map +1 -0
  308. package/dist/routers/schemas.mjs +141 -24
  309. package/dist/routers/schemas.mjs.map +1 -1
  310. package/dist/routers/scoped-models.mjs +21 -0
  311. package/dist/routers/scoped-models.mjs.map +1 -0
  312. package/dist/routers/settings-ai.mjs +34 -0
  313. package/dist/routers/settings-ai.mjs.map +1 -0
  314. package/dist/routers/settings-capture.mjs +76 -0
  315. package/dist/routers/settings-capture.mjs.map +1 -0
  316. package/dist/routers/settings-design.mjs +102 -0
  317. package/dist/routers/settings-design.mjs.map +1 -0
  318. package/dist/routers/settings-general.mjs +56 -12
  319. package/dist/routers/settings-general.mjs.map +1 -1
  320. package/dist/routers/settings-git-channel.mjs +76 -0
  321. package/dist/routers/settings-git-channel.mjs.map +1 -0
  322. package/dist/routers/settings-labels.mjs +28 -3
  323. package/dist/routers/settings-labels.mjs.map +1 -1
  324. package/dist/routers/settings-links.mjs +62 -0
  325. package/dist/routers/settings-links.mjs.map +1 -0
  326. package/dist/routers/settings-members.mjs +5 -9
  327. package/dist/routers/settings-members.mjs.map +1 -1
  328. package/dist/routers/settings-notifications.mjs +16 -12
  329. package/dist/routers/settings-notifications.mjs.map +1 -1
  330. package/dist/routers/settings-tokens.mjs +3 -7
  331. package/dist/routers/settings-tokens.mjs.map +1 -1
  332. package/dist/routers/settings-webhooks.mjs +8 -26
  333. package/dist/routers/settings-webhooks.mjs.map +1 -1
  334. package/dist/routers/settings.handlers.mjs +108 -28
  335. package/dist/routers/settings.handlers.mjs.map +1 -1
  336. package/dist/routers/settings.mjs +17 -6
  337. package/dist/routers/settings.mjs.map +1 -1
  338. package/dist/routers/signed-storybook.mjs +55 -0
  339. package/dist/routers/signed-storybook.mjs.map +1 -0
  340. package/dist/routers/snapshot-groups.mjs +62 -0
  341. package/dist/routers/snapshot-groups.mjs.map +1 -0
  342. package/dist/routers/snapshots.mjs +104 -7
  343. package/dist/routers/snapshots.mjs.map +1 -1
  344. package/dist/routers/storybook.mjs +57 -104
  345. package/dist/routers/storybook.mjs.map +1 -1
  346. package/dist/routers/tokens.mjs +3 -7
  347. package/dist/routers/tokens.mjs.map +1 -1
  348. package/dist/routers/ui.mjs +38 -7
  349. package/dist/routers/ui.mjs.map +1 -1
  350. package/dist/routers/webhooks.mjs +6 -13
  351. package/dist/routers/webhooks.mjs.map +1 -1
  352. package/dist/routers/well-known.mjs +12 -6
  353. package/dist/routers/well-known.mjs.map +1 -1
  354. package/dist/routes/api-routes.mjs +92 -0
  355. package/dist/routes/api-routes.mjs.map +1 -0
  356. package/dist/routes/classification.mjs +37 -0
  357. package/dist/routes/classification.mjs.map +1 -0
  358. package/dist/routes/openapi-classes.mjs +21 -0
  359. package/dist/routes/openapi-classes.mjs.map +1 -0
  360. package/dist/routes/page-routes.mjs +103 -0
  361. package/dist/routes/page-routes.mjs.map +1 -0
  362. package/dist/runtime.mjs +19 -15
  363. package/dist/runtime.mjs.map +1 -1
  364. package/dist/serverless.mjs +4 -0
  365. package/dist/store.mjs +20 -3
  366. package/dist/store.mjs.map +1 -1
  367. package/dist/targets.mjs +45 -0
  368. package/dist/targets.mjs.map +1 -0
  369. package/dist/ui/avatar.mjs +1 -2
  370. package/dist/ui/buttons.mjs +17 -5
  371. package/dist/ui/buttons.mjs.map +1 -1
  372. package/dist/ui/code-block.mjs +1 -2
  373. package/dist/ui/compare-stage.mjs +86 -54
  374. package/dist/ui/compare-stage.mjs.map +1 -1
  375. package/dist/ui/csrf-field.mjs +1 -2
  376. package/dist/ui/css.mjs +1 -2
  377. package/dist/ui/design-reference.mjs +96 -0
  378. package/dist/ui/design-reference.mjs.map +1 -0
  379. package/dist/ui/document.mjs +2 -4
  380. package/dist/ui/document.mjs.map +1 -1
  381. package/dist/ui/dropdown.mjs +1 -2
  382. package/dist/ui/feedback.mjs +1 -2
  383. package/dist/ui/filter-input.mjs +1 -2
  384. package/dist/ui/forms.mjs +7 -7
  385. package/dist/ui/forms.mjs.map +1 -1
  386. package/dist/ui/icons/icon.mjs +1 -2
  387. package/dist/ui/icons/paths.mjs +2 -2
  388. package/dist/ui/icons/paths.mjs.map +1 -1
  389. package/dist/ui/icons/sprite.mjs +1 -2
  390. package/dist/ui/kbd.mjs +1 -2
  391. package/dist/ui/layout.mjs +1 -2
  392. package/dist/ui/preview-embed.mjs +81 -0
  393. package/dist/ui/preview-embed.mjs.map +1 -0
  394. package/dist/ui/progress.mjs +1 -2
  395. package/dist/ui/segmented.mjs +1 -2
  396. package/dist/ui/shell/account.mjs +1 -2
  397. package/dist/ui/shell/bare.mjs +1 -2
  398. package/dist/ui/shell/boot-script.mjs +1 -2
  399. package/dist/ui/shell/client-script.mjs +86 -2
  400. package/dist/ui/shell/client-script.mjs.map +1 -1
  401. package/dist/ui/shell/nav.mjs +13 -10
  402. package/dist/ui/shell/nav.mjs.map +1 -1
  403. package/dist/ui/shell/review-script.mjs +25 -3
  404. package/dist/ui/shell/review-script.mjs.map +1 -1
  405. package/dist/ui/shell/sidebar.mjs +3 -4
  406. package/dist/ui/shell/sidebar.mjs.map +1 -1
  407. package/dist/ui/shell/topbar.mjs +1 -2
  408. package/dist/ui/stacks.mjs +1 -2
  409. package/dist/ui/styles/base.mjs +1 -2
  410. package/dist/ui/styles/behavior.mjs +1 -2
  411. package/dist/ui/styles/review-layout.mjs +1 -2
  412. package/dist/ui/styles/review.mjs +1 -2
  413. package/dist/ui/styles/tokens.mjs +1 -2
  414. package/dist/ui/styles.mjs +1 -2
  415. package/dist/ui/subnav.mjs +1 -2
  416. package/dist/ui/table.mjs +1 -2
  417. package/dist/ui/theme.mjs +1 -2
  418. package/dist/ui/thumbnail.mjs +1 -2
  419. package/dist/ui/time.mjs +1 -2
  420. package/package.json +14 -12
  421. package/dist/asset-paths.mjs +0 -23
  422. package/dist/asset-paths.mjs.map +0 -1
  423. package/dist/assets/mark.svg +0 -1
  424. package/dist/pages/settings-status.mjs +0 -137
  425. package/dist/pages/settings-status.mjs.map +0 -1
  426. package/dist/routers/settings-status.mjs +0 -77
  427. package/dist/routers/settings-status.mjs.map +0 -1
  428. package/dist/routers/status-configs.mjs +0 -108
  429. package/dist/routers/status-configs.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"baseline-guard.mjs","names":[],"sources":["../../src/routers/baseline-guard.ts"],"sourcesContent":["/** Refuse to promote a screenshot over a baseline the reviewer has not seen. */\nimport {\n BaselineModel,\n baselineStatus,\n isBaselineDrifted,\n type BaselineStatus,\n} from \"@storyshelf/core/models\";\nimport type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** Whether a snapshot is awaiting a review decision. */\nexport function isOpenSnapshot(snapshot: Pick<Snapshot, \"status\">): boolean {\n return snapshot.status === \"new\" || snapshot.status === \"changed\";\n}\n\n/** Compare a snapshot's recorded baseline with the baseline that applies to it now. */\nexport async function snapshotBaselineStatus(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n): Promise<BaselineStatus> {\n const baselines = new BaselineModel(getStore().db);\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n );\n return baselineStatus(snapshot, current);\n}\n\n/** Open snapshots whose diff no longer matches the current baseline. */\nexport async function findDriftedSnapshots(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshots: Snapshot[],\n): Promise<Map<string, BaselineStatus>> {\n const open = snapshots.filter((snapshot) => isOpenSnapshot(snapshot));\n const statuses = await Promise.all(\n open.map(async (snapshot) => await snapshotBaselineStatus(project, build, snapshot)),\n );\n const drifted = new Map<string, BaselineStatus>();\n for (const [index, snapshot] of open.entries()) {\n const status = statuses[index];\n if (status && isBaselineDrifted(status)) {\n drifted.set(snapshot.id, status);\n }\n }\n return drifted;\n}\n\n/** 409 telling the client the diff is out of date; recompute it or force the approval. */\nexport function baselineChanged(snapshotId: string, status: BaselineStatus): HTTPException {\n const body = {\n code: \"baseline_changed\",\n message:\n \"The baseline changed after this diff was computed. Re-diff the build, or approve with force.\",\n snapshotId,\n baselineStatus: status,\n };\n return new HTTPException(409, {\n res: new Response(JSON.stringify(body), {\n status: 409,\n headers: { \"content-type\": \"application/json\" },\n }),\n });\n}\n\n/** Throw {@link baselineChanged} when an open snapshot's baseline drifted and `force` is off. */\nexport async function assertBaselineCurrent(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n force: boolean,\n): Promise<void> {\n if (!isOpenSnapshot(snapshot)) {\n return;\n }\n const status = await snapshotBaselineStatus(project, build, snapshot);\n if (!isBaselineDrifted(status)) {\n return;\n }\n if (force) {\n getStore().logger.warn(\n { snapshotId: snapshot.id, baselineStatus: status, userId: getStore().user?.id ?? null },\n \"approved snapshot despite changed baseline (forced)\",\n );\n return;\n }\n throw baselineChanged(snapshot.id, status);\n}\n\n/** True when `error` is the 409 raised by {@link baselineChanged}. */\nexport function isBaselineChanged(error: unknown): boolean {\n return error instanceof HTTPException && error.status === 409;\n}\n\n/** Per-snapshot baseline facts the review pages need. */\nexport interface BaselineView {\n /** Snapshot ids that have a baseline to compare against. */\n hasBaseline: Record<string, boolean>;\n /** Open snapshots whose recorded baseline no longer matches the current one. */\n drifted: Record<string, \"stale\" | \"removed\">;\n}\n\n/** Resolve each snapshot's current baseline once and derive presence plus drift. */\nexport async function loadBaselineView(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\" | \"isDefault\">,\n snapshots: Snapshot[],\n): Promise<BaselineView> {\n const baselines = new BaselineModel(getStore().db);\n const entries = await Promise.all(\n snapshots.map(async (snapshot) => {\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n );\n return { snapshot, current };\n }),\n );\n const view: BaselineView = { hasBaseline: {}, drifted: {} };\n for (const { snapshot, current } of entries) {\n view.hasBaseline[snapshot.id] = Boolean(current);\n const status = baselineStatus(snapshot, current);\n if (\n !build.isDefault &&\n isOpenSnapshot(snapshot) &&\n (status === \"stale\" || status === \"removed\")\n ) {\n view.drifted[snapshot.id] = status;\n }\n }\n return view;\n}\n"],"mappings":";;;;;;;;;AAYA,SAAgB,eAAe,UAA6C;CAC1E,OAAO,SAAS,WAAW,SAAS,SAAS,WAAW;AAC1D;;AAGA,eAAsB,uBACpB,SACA,OACA,UACyB;CAEzB,MAAM,UAAU,MAAM,IADA,cAAc,SAAS,CAAC,CAAC,EACjB,CAAC,CAAC,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,gBACV;CACA,OAAO,eAAe,UAAU,OAAO;AACzC;;AAGA,eAAsB,qBACpB,SACA,OACA,WACsC;CACtC,MAAM,OAAO,UAAU,QAAQ,aAAa,eAAe,QAAQ,CAAC;CACpE,MAAM,WAAW,MAAM,QAAQ,IAC7B,KAAK,IAAI,OAAO,aAAa,MAAM,uBAAuB,SAAS,OAAO,QAAQ,CAAC,CACrF;CACA,MAAM,0BAAU,IAAI,IAA4B;CAChD,KAAK,MAAM,CAAC,OAAO,aAAa,KAAK,QAAQ,GAAG;EAC9C,MAAM,SAAS,SAAS;EACxB,IAAI,UAAU,kBAAkB,MAAM,GACpC,QAAQ,IAAI,SAAS,IAAI,MAAM;CAEnC;CACA,OAAO;AACT;;AAGA,SAAgB,gBAAgB,YAAoB,QAAuC;CAQzF,OAAO,IAAI,cAAc,KAAK,EAC5B,KAAK,IAAI,SAAS,KAAK,UAAU;EAPjC,MAAM;EACN,SACE;EACF;EACA,gBAAgB;CAGoB,CAAC,GAAG;EACtC,QAAQ;EACR,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC,EACH,CAAC;AACH;;AAGA,eAAsB,sBACpB,SACA,OACA,UACA,OACe;CACf,IAAI,CAAC,eAAe,QAAQ,GAC1B;CAEF,MAAM,SAAS,MAAM,uBAAuB,SAAS,OAAO,QAAQ;CACpE,IAAI,CAAC,kBAAkB,MAAM,GAC3B;CAEF,IAAI,OAAO;EACT,SAAS,CAAC,CAAC,OAAO,KAChB;GAAE,YAAY,SAAS;GAAI,gBAAgB;GAAQ,QAAQ,SAAS,CAAC,CAAC,MAAM,MAAM;EAAK,GACvF,qDACF;EACA;CACF;CACA,MAAM,gBAAgB,SAAS,IAAI,MAAM;AAC3C;;AAGA,SAAgB,kBAAkB,OAAyB;CACzD,OAAO,iBAAiB,iBAAiB,MAAM,WAAW;AAC5D;;AAWA,eAAsB,iBACpB,SACA,OACA,WACuB;CACvB,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,UAAU,MAAM,QAAQ,IAC5B,UAAU,IAAI,OAAO,aAAa;EAQhC,OAAO;GAAE;GAAU,SAAA,MAPG,UAAU,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,gBACV;EAC2B;CAC7B,CAAC,CACH;CACA,MAAM,OAAqB;EAAE,aAAa,CAAC;EAAG,SAAS,CAAC;CAAE;CAC1D,KAAK,MAAM,EAAE,UAAU,aAAa,SAAS;EAC3C,KAAK,YAAY,SAAS,MAAM,QAAQ,OAAO;EAC/C,MAAM,SAAS,eAAe,UAAU,OAAO;EAC/C,IACE,CAAC,MAAM,aACP,eAAe,QAAQ,MACtB,WAAW,WAAW,WAAW,YAElC,KAAK,QAAQ,SAAS,MAAM;CAEhC;CACA,OAAO;AACT"}
1
+ {"version":3,"file":"baseline-guard.mjs","names":[],"sources":["../../src/routers/baseline-guard.ts"],"sourcesContent":["/** Refuse to promote a screenshot over a baseline the reviewer has not seen. */\nimport {\n BaselineModel,\n baselineStatus,\n isBaselineDrifted,\n type BaselineStatus,\n} from \"@storyshelf/core/models\";\nimport type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** Whether a snapshot is awaiting a review decision. */\nexport function isOpenSnapshot(snapshot: Pick<Snapshot, \"status\">): boolean {\n return snapshot.status === \"new\" || snapshot.status === \"changed\";\n}\n\n/** Compare a snapshot's recorded baseline with the baseline that applies to it now. */\nexport async function snapshotBaselineStatus(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n): Promise<BaselineStatus> {\n const baselines = new BaselineModel(getStore().db);\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n snapshot.browser,\n );\n return baselineStatus(snapshot, current);\n}\n\n/** Open snapshots whose diff no longer matches the current baseline. */\nexport async function findDriftedSnapshots(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshots: Snapshot[],\n): Promise<Map<string, BaselineStatus>> {\n const open = snapshots.filter((snapshot) => isOpenSnapshot(snapshot));\n const statuses = await Promise.all(\n open.map(async (snapshot) => await snapshotBaselineStatus(project, build, snapshot)),\n );\n const drifted = new Map<string, BaselineStatus>();\n for (const [index, snapshot] of open.entries()) {\n const status = statuses[index];\n if (status && isBaselineDrifted(status)) {\n drifted.set(snapshot.id, status);\n }\n }\n return drifted;\n}\n\n/** 409 telling the client the diff is out of date; recompute it or force the approval. */\nexport function baselineChanged(snapshotId: string, status: BaselineStatus): HTTPException {\n const body = {\n code: \"baseline_changed\",\n message:\n \"The baseline changed after this diff was computed. Re-diff the build, or approve with force.\",\n snapshotId,\n baselineStatus: status,\n };\n return new HTTPException(409, {\n res: new Response(JSON.stringify(body), {\n status: 409,\n headers: { \"content-type\": \"application/json\" },\n }),\n });\n}\n\n/** Throw {@link baselineChanged} when an open snapshot's baseline drifted and `force` is off. */\nexport async function assertBaselineCurrent(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n force: boolean,\n): Promise<void> {\n if (!isOpenSnapshot(snapshot)) {\n return;\n }\n const status = await snapshotBaselineStatus(project, build, snapshot);\n if (!isBaselineDrifted(status)) {\n return;\n }\n if (force) {\n getStore().logger.warn(\n { snapshotId: snapshot.id, baselineStatus: status, userId: getStore().user?.id ?? null },\n \"approved snapshot despite changed baseline (forced)\",\n );\n return;\n }\n throw baselineChanged(snapshot.id, status);\n}\n\n/** True when `error` is the 409 raised by {@link baselineChanged}. */\nexport function isBaselineChanged(error: unknown): boolean {\n return error instanceof HTTPException && error.status === 409;\n}\n\n/** Per-snapshot baseline facts the review pages need. */\nexport interface BaselineView {\n /** Snapshot ids that have a baseline to compare against. */\n hasBaseline: Record<string, boolean>;\n /** Open snapshots whose recorded baseline no longer matches the current one. */\n drifted: Record<string, \"stale\" | \"removed\">;\n}\n\n/** Resolve each snapshot's current baseline once and derive presence plus drift. */\nexport async function loadBaselineView(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\" | \"isDefault\">,\n snapshots: Snapshot[],\n): Promise<BaselineView> {\n const baselines = new BaselineModel(getStore().db);\n const entries = await Promise.all(\n snapshots.map(async (snapshot) => {\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n snapshot.browser,\n );\n return { snapshot, current };\n }),\n );\n const view: BaselineView = { hasBaseline: {}, drifted: {} };\n for (const { snapshot, current } of entries) {\n view.hasBaseline[snapshot.id] = Boolean(snapshot.comparisonImagePath ?? current);\n const status = baselineStatus(snapshot, current);\n if (\n !build.isDefault &&\n isOpenSnapshot(snapshot) &&\n (status === \"stale\" || status === \"removed\")\n ) {\n view.drifted[snapshot.id] = status;\n }\n }\n return view;\n}\n"],"mappings":";;;;;;;;;AAYA,SAAgB,eAAe,UAA6C;CAC1E,OAAO,SAAS,WAAW,SAAS,SAAS,WAAW;AAC1D;;AAGA,eAAsB,uBACpB,SACA,OACA,UACyB;CAEzB,MAAM,UAAU,MAAM,IADA,cAAc,SAAS,CAAC,CAAC,EACjB,CAAC,CAAC,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,kBACR,SAAS,OACX;CACA,OAAO,eAAe,UAAU,OAAO;AACzC;;AAGA,eAAsB,qBACpB,SACA,OACA,WACsC;CACtC,MAAM,OAAO,UAAU,QAAQ,aAAa,eAAe,QAAQ,CAAC;CACpE,MAAM,WAAW,MAAM,QAAQ,IAC7B,KAAK,IAAI,OAAO,aAAa,MAAM,uBAAuB,SAAS,OAAO,QAAQ,CAAC,CACrF;CACA,MAAM,0BAAU,IAAI,IAA4B;CAChD,KAAK,MAAM,CAAC,OAAO,aAAa,KAAK,QAAQ,GAAG;EAC9C,MAAM,SAAS,SAAS;EACxB,IAAI,UAAU,kBAAkB,MAAM,GACpC,QAAQ,IAAI,SAAS,IAAI,MAAM;CAEnC;CACA,OAAO;AACT;;AAGA,SAAgB,gBAAgB,YAAoB,QAAuC;CAQzF,OAAO,IAAI,cAAc,KAAK,EAC5B,KAAK,IAAI,SAAS,KAAK,UAAU;EAPjC,MAAM;EACN,SACE;EACF;EACA,gBAAgB;CAGoB,CAAC,GAAG;EACtC,QAAQ;EACR,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC,EACH,CAAC;AACH;;AAGA,eAAsB,sBACpB,SACA,OACA,UACA,OACe;CACf,IAAI,CAAC,eAAe,QAAQ,GAC1B;CAEF,MAAM,SAAS,MAAM,uBAAuB,SAAS,OAAO,QAAQ;CACpE,IAAI,CAAC,kBAAkB,MAAM,GAC3B;CAEF,IAAI,OAAO;EACT,SAAS,CAAC,CAAC,OAAO,KAChB;GAAE,YAAY,SAAS;GAAI,gBAAgB;GAAQ,QAAQ,SAAS,CAAC,CAAC,MAAM,MAAM;EAAK,GACvF,qDACF;EACA;CACF;CACA,MAAM,gBAAgB,SAAS,IAAI,MAAM;AAC3C;;AAGA,SAAgB,kBAAkB,OAAyB;CACzD,OAAO,iBAAiB,iBAAiB,MAAM,WAAW;AAC5D;;AAWA,eAAsB,iBACpB,SACA,OACA,WACuB;CACvB,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,UAAU,MAAM,QAAQ,IAC5B,UAAU,IAAI,OAAO,aAAa;EAShC,OAAO;GAAE;GAAU,SAAA,MARG,UAAU,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,kBACR,SAAS,OACX;EAC2B;CAC7B,CAAC,CACH;CACA,MAAM,OAAqB;EAAE,aAAa,CAAC;EAAG,SAAS,CAAC;CAAE;CAC1D,KAAK,MAAM,EAAE,UAAU,aAAa,SAAS;EAC3C,KAAK,YAAY,SAAS,MAAM,QAAQ,SAAS,uBAAuB,OAAO;EAC/E,MAAM,SAAS,eAAe,UAAU,OAAO;EAC/C,IACE,CAAC,MAAM,aACP,eAAe,QAAQ,MACtB,WAAW,WAAW,WAAW,YAElC,KAAK,QAAQ,SAAS,MAAM;CAEhC;CACA,OAAO;AACT"}
@@ -0,0 +1,72 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { readComparisonImage, validFrozenReference } from "./comparison-image.mjs";
6
+ import { BaselineModel, BuildModel, SnapshotModel, baselineStatus, resolveBaselineOrigin } from "@storyshelf/core/models";
7
+ // src/routers/baseline-reference.ts
8
+ async function loadBaselineReference(project, build, snapshot) {
9
+ if (snapshot.comparisonImagePath || snapshot.comparisonMetadata) return await frozenReference(project, build, snapshot);
10
+ return await legacyReference(project, build, snapshot);
11
+ }
12
+ async function legacyReference(project, build, snapshot) {
13
+ if (snapshot.baselineVersion === "none" && snapshot.baselineId === null) return { kind: "none" };
14
+ if (!snapshot.baselineId || !snapshot.baselineVersion) return { kind: "unavailable" };
15
+ const db = getStore().db;
16
+ const baseline = await new BaselineModel(db).resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch, snapshot.browser);
17
+ if (!baseline || baselineStatus(snapshot, baseline) !== "current") return { kind: "unavailable" };
18
+ const snapshots = new SnapshotModel(db);
19
+ const builds = new BuildModel(db);
20
+ const reference = await resolveBaselineOrigin(baseline, {
21
+ baseline: async (id) => await db.get(db.tables.baselines, id),
22
+ snapshot: async (id) => await snapshots.get(id),
23
+ build: async (id) => await builds.get(id)
24
+ });
25
+ return {
26
+ kind: "known",
27
+ branch: baseline.branch,
28
+ defaultBranch: baseline.branch === project.gitDefaultBranch,
29
+ updatedAt: baseline.updatedAt,
30
+ imageAvailable: await imageExists(baseline.screenshotPath),
31
+ build: reference
32
+ };
33
+ }
34
+ async function frozenReference(project, build, snapshot) {
35
+ const frozen = validFrozenReference(snapshot, project.id, build.id);
36
+ if (!frozen) return { kind: "unavailable" };
37
+ const { reference: metadata, path } = frozen;
38
+ const reference = metadata.sourceBuildId ? await new BuildModel(getStore().db).get(metadata.sourceBuildId) : null;
39
+ return {
40
+ kind: "known",
41
+ branch: metadata.branch,
42
+ defaultBranch: metadata.defaultBranch,
43
+ updatedAt: metadata.baselineVersion,
44
+ imageAvailable: Boolean(await readComparisonImage(path, metadata.imageSha256)),
45
+ build: reference?.projectId === project.id ? reference : null
46
+ };
47
+ }
48
+ /** A separate, read-only view of the current default-branch reference. */
49
+ async function loadDefaultComparison(project, build, snapshot) {
50
+ if (build.gitBranch === project.gitDefaultBranch) return null;
51
+ const { db } = getStore();
52
+ const baseline = await new BaselineModel(db).getFor(project.id, snapshot.storyId, snapshot.viewportName, project.gitDefaultBranch, snapshot.browser);
53
+ if (!baseline || baseline.id === snapshot.baselineId && baseline.updatedAt === snapshot.baselineVersion) return null;
54
+ const environment = !baseline.infraHash || !snapshot.infraHash ? "unknown" : baseline.infraHash === snapshot.infraHash ? "matching" : "different";
55
+ return {
56
+ id: baseline.id,
57
+ version: baseline.updatedAt,
58
+ branch: baseline.branch,
59
+ available: await imageExists(baseline.screenshotPath),
60
+ environment
61
+ };
62
+ }
63
+ async function imageExists(path) {
64
+ try {
65
+ return await getStore().storage.exists(path);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+ export { loadBaselineReference, loadDefaultComparison };
71
+
72
+ //# sourceMappingURL=baseline-reference.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baseline-reference.mjs","names":[],"sources":["../../src/routers/baseline-reference.ts"],"sourcesContent":["import {\n BaselineModel,\n BuildModel,\n SnapshotModel,\n baselineStatus,\n resolveBaselineOrigin,\n} from \"@storyshelf/core/models\";\nimport type { Baseline, Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { getStore } from \"../store.ts\";\nimport { readComparisonImage, validFrozenReference } from \"./comparison-image.ts\";\n\n/** Reference information is only reliable while the recorded baseline version still exists. */\nexport type BaselineReference =\n | { kind: \"none\" }\n | { kind: \"unavailable\" }\n | {\n kind: \"known\";\n branch: string;\n defaultBranch: boolean;\n updatedAt: string;\n build: Build | null;\n imageAvailable: boolean;\n };\n\nexport async function loadBaselineReference(\n project: Project,\n build: Build,\n snapshot: Snapshot,\n): Promise<BaselineReference> {\n if (snapshot.comparisonImagePath || snapshot.comparisonMetadata) {\n return await frozenReference(project, build, snapshot);\n }\n return await legacyReference(project, build, snapshot);\n}\n\nasync function legacyReference(\n project: Project,\n build: Build,\n snapshot: Snapshot,\n): Promise<BaselineReference> {\n if (snapshot.baselineVersion === \"none\" && snapshot.baselineId === null) return { kind: \"none\" };\n if (!snapshot.baselineId || !snapshot.baselineVersion) return { kind: \"unavailable\" };\n const db = getStore().db;\n const baseline = await new BaselineModel(db).resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n snapshot.browser,\n );\n if (!baseline || baselineStatus(snapshot, baseline) !== \"current\") return { kind: \"unavailable\" };\n const snapshots = new SnapshotModel(db);\n const builds = new BuildModel(db);\n const reference = await resolveBaselineOrigin(baseline, {\n baseline: async (id) => (await db.get(db.tables.baselines, id)) as unknown as Baseline | null,\n snapshot: async (id) => await snapshots.get(id),\n build: async (id) => await builds.get(id),\n });\n return {\n kind: \"known\",\n branch: baseline.branch,\n defaultBranch: baseline.branch === project.gitDefaultBranch,\n updatedAt: baseline.updatedAt,\n imageAvailable: await imageExists(baseline.screenshotPath),\n build: reference,\n };\n}\n\nasync function frozenReference(\n project: Project,\n build: Build,\n snapshot: Snapshot,\n): Promise<BaselineReference> {\n const frozen = validFrozenReference(snapshot, project.id, build.id);\n if (!frozen) return { kind: \"unavailable\" };\n const { reference: metadata, path } = frozen;\n const reference = metadata.sourceBuildId\n ? await new BuildModel(getStore().db).get(metadata.sourceBuildId)\n : null;\n return {\n kind: \"known\",\n branch: metadata.branch,\n defaultBranch: metadata.defaultBranch,\n updatedAt: metadata.baselineVersion,\n imageAvailable: Boolean(await readComparisonImage(path, metadata.imageSha256)),\n build: reference?.projectId === project.id ? reference : null,\n };\n}\n\nexport interface DefaultComparison {\n id: string;\n version: string;\n branch: string;\n available: boolean;\n environment: \"unknown\" | \"matching\" | \"different\";\n}\n\n/** A separate, read-only view of the current default-branch reference. */\nexport async function loadDefaultComparison(\n project: Project,\n build: Build,\n snapshot: Snapshot,\n): Promise<DefaultComparison | null> {\n if (build.gitBranch === project.gitDefaultBranch) return null;\n const { db } = getStore();\n const baseline = await new BaselineModel(db).getFor(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n project.gitDefaultBranch,\n snapshot.browser,\n );\n if (\n !baseline ||\n (baseline.id === snapshot.baselineId && baseline.updatedAt === snapshot.baselineVersion)\n )\n return null;\n const environment =\n !baseline.infraHash || !snapshot.infraHash\n ? \"unknown\"\n : baseline.infraHash === snapshot.infraHash\n ? \"matching\"\n : \"different\";\n return {\n id: baseline.id,\n version: baseline.updatedAt,\n branch: baseline.branch,\n available: await imageExists(baseline.screenshotPath),\n environment,\n };\n}\n\nasync function imageExists(path: string): Promise<boolean> {\n try {\n return await getStore().storage.exists(path);\n } catch {\n return false;\n }\n}\n"],"mappings":";;;;;;;AAwBA,eAAsB,sBACpB,SACA,OACA,UAC4B;CAC5B,IAAI,SAAS,uBAAuB,SAAS,oBAC3C,OAAO,MAAM,gBAAgB,SAAS,OAAO,QAAQ;CAEvD,OAAO,MAAM,gBAAgB,SAAS,OAAO,QAAQ;AACvD;AAEA,eAAe,gBACb,SACA,OACA,UAC4B;CAC5B,IAAI,SAAS,oBAAoB,UAAU,SAAS,eAAe,MAAM,OAAO,EAAE,MAAM,OAAO;CAC/F,IAAI,CAAC,SAAS,cAAc,CAAC,SAAS,iBAAiB,OAAO,EAAE,MAAM,cAAc;CACpF,MAAM,KAAK,SAAS,CAAC,CAAC;CACtB,MAAM,WAAW,MAAM,IAAI,cAAc,EAAE,CAAC,CAAC,QAC3C,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,kBACR,SAAS,OACX;CACA,IAAI,CAAC,YAAY,eAAe,UAAU,QAAQ,MAAM,WAAW,OAAO,EAAE,MAAM,cAAc;CAChG,MAAM,YAAY,IAAI,cAAc,EAAE;CACtC,MAAM,SAAS,IAAI,WAAW,EAAE;CAChC,MAAM,YAAY,MAAM,sBAAsB,UAAU;EACtD,UAAU,OAAO,OAAQ,MAAM,GAAG,IAAI,GAAG,OAAO,WAAW,EAAE;EAC7D,UAAU,OAAO,OAAO,MAAM,UAAU,IAAI,EAAE;EAC9C,OAAO,OAAO,OAAO,MAAM,OAAO,IAAI,EAAE;CAC1C,CAAC;CACD,OAAO;EACL,MAAM;EACN,QAAQ,SAAS;EACjB,eAAe,SAAS,WAAW,QAAQ;EAC3C,WAAW,SAAS;EACpB,gBAAgB,MAAM,YAAY,SAAS,cAAc;EACzD,OAAO;CACT;AACF;AAEA,eAAe,gBACb,SACA,OACA,UAC4B;CAC5B,MAAM,SAAS,qBAAqB,UAAU,QAAQ,IAAI,MAAM,EAAE;CAClE,IAAI,CAAC,QAAQ,OAAO,EAAE,MAAM,cAAc;CAC1C,MAAM,EAAE,WAAW,UAAU,SAAS;CACtC,MAAM,YAAY,SAAS,gBACvB,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,SAAS,aAAa,IAC9D;CACJ,OAAO;EACL,MAAM;EACN,QAAQ,SAAS;EACjB,eAAe,SAAS;EACxB,WAAW,SAAS;EACpB,gBAAgB,QAAQ,MAAM,oBAAoB,MAAM,SAAS,WAAW,CAAC;EAC7E,OAAO,WAAW,cAAc,QAAQ,KAAK,YAAY;CAC3D;AACF;;AAWA,eAAsB,sBACpB,SACA,OACA,UACmC;CACnC,IAAI,MAAM,cAAc,QAAQ,kBAAkB,OAAO;CACzD,MAAM,EAAE,OAAO,SAAS;CACxB,MAAM,WAAW,MAAM,IAAI,cAAc,EAAE,CAAC,CAAC,OAC3C,QAAQ,IACR,SAAS,SACT,SAAS,cACT,QAAQ,kBACR,SAAS,OACX;CACA,IACE,CAAC,YACA,SAAS,OAAO,SAAS,cAAc,SAAS,cAAc,SAAS,iBAExE,OAAO;CACT,MAAM,cACJ,CAAC,SAAS,aAAa,CAAC,SAAS,YAC7B,YACA,SAAS,cAAc,SAAS,YAC9B,aACA;CACR,OAAO;EACL,IAAI,SAAS;EACb,SAAS,SAAS;EAClB,QAAQ,SAAS;EACjB,WAAW,MAAM,YAAY,SAAS,cAAc;EACpD;CACF;AACF;AAEA,eAAe,YAAY,MAAgC;CACzD,IAAI;EACF,OAAO,MAAM,SAAS,CAAC,CAAC,QAAQ,OAAO,IAAI;CAC7C,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -0,0 +1,63 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { DEFAULT_MAX_UPLOAD_BYTES } from "@storyshelf/core/config";
5
+ import { BASELINE_ARCHIVE_LIMITS } from "@storyshelf/core/models";
6
+ import { HTTPException } from "hono/http-exception";
7
+ // src/routers/baselines.handlers.ts
8
+ /** HTTP helpers for the baseline export/import router. */
9
+ /** Ceiling for a buffered import archive, whatever `maxUploadBytes` allows. */
10
+ const MAX_BASELINE_IMPORT_BYTES = BASELINE_ARCHIVE_LIMITS.maxArchiveBytes;
11
+ /** Byte limit for an import body: `maxUploadBytes`, capped because the archive is buffered. */
12
+ function importByteLimit(config) {
13
+ return Math.min(config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES, MAX_BASELINE_IMPORT_BYTES);
14
+ }
15
+ /**
16
+ * Buffer a request body, failing fast with 413 once it passes `maxBytes`
17
+ * (checked against `content-length` up front and while streaming).
18
+ */
19
+ async function readCappedBody(request, maxBytes) {
20
+ if (Number(request.headers.get("content-length") ?? 0) > maxBytes) throw tooLarge(maxBytes);
21
+ if (!request.body) throw new HTTPException(400, { message: "Missing request body" });
22
+ return await collectCapped(request.body.getReader(), maxBytes);
23
+ }
24
+ async function collectCapped(reader, maxBytes) {
25
+ const chunks = [];
26
+ let seen = 0;
27
+ let next = await reader.read();
28
+ while (!next.done && seen <= maxBytes) {
29
+ seen += next.value.length;
30
+ chunks.push(next.value);
31
+ next = await reader.read();
32
+ }
33
+ if (seen > maxBytes) await abortTooLarge(reader, maxBytes);
34
+ return Buffer.concat(chunks);
35
+ }
36
+ async function abortTooLarge(reader, maxBytes) {
37
+ await reader.cancel();
38
+ throw tooLarge(maxBytes);
39
+ }
40
+ function tooLarge(maxBytes) {
41
+ return new HTTPException(413, { message: `Archive exceeds ${maxBytes} bytes` });
42
+ }
43
+ /** Adapt a lazily produced chunk sequence to a pull-based web stream. */
44
+ function toWebStream(chunks) {
45
+ const iterator = chunks[Symbol.asyncIterator]();
46
+ return new ReadableStream({
47
+ async pull(controller) {
48
+ const next = await iterator.next();
49
+ if (next.done) controller.close();
50
+ else controller.enqueue(new Uint8Array(next.value));
51
+ },
52
+ async cancel() {
53
+ await iterator.return?.();
54
+ }
55
+ });
56
+ }
57
+ /** Download file name: `<slug>-baselines[-<branch>].zip`, header-safe. */
58
+ function archiveFileName(slug, branch) {
59
+ return `${(branch ? `${slug}-baselines-${branch}` : `${slug}-baselines`).replaceAll(/[^\w.-]+/gu, "_")}.zip`;
60
+ }
61
+ export { MAX_BASELINE_IMPORT_BYTES, archiveFileName, importByteLimit, readCappedBody, toWebStream };
62
+
63
+ //# sourceMappingURL=baselines.handlers.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baselines.handlers.mjs","names":[],"sources":["../../src/routers/baselines.handlers.ts"],"sourcesContent":["/** HTTP helpers for the baseline export/import router. */\nimport { DEFAULT_MAX_UPLOAD_BYTES, type ShelfConfig } from \"@storyshelf/core/config\";\nimport { BASELINE_ARCHIVE_LIMITS } from \"@storyshelf/core/models\";\nimport { HTTPException } from \"hono/http-exception\";\n\n/** Ceiling for a buffered import archive, whatever `maxUploadBytes` allows. */\nexport const MAX_BASELINE_IMPORT_BYTES = BASELINE_ARCHIVE_LIMITS.maxArchiveBytes;\n\n/** Byte limit for an import body: `maxUploadBytes`, capped because the archive is buffered. */\nexport function importByteLimit(config: Pick<ShelfConfig, \"maxUploadBytes\">): number {\n return Math.min(config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES, MAX_BASELINE_IMPORT_BYTES);\n}\n\n/**\n * Buffer a request body, failing fast with 413 once it passes `maxBytes`\n * (checked against `content-length` up front and while streaming).\n */\nexport async function readCappedBody(request: Request, maxBytes: number): Promise<Buffer> {\n if (Number(request.headers.get(\"content-length\") ?? 0) > maxBytes) {\n throw tooLarge(maxBytes);\n }\n if (!request.body) {\n throw new HTTPException(400, { message: \"Missing request body\" });\n }\n return await collectCapped(request.body.getReader(), maxBytes);\n}\n\nasync function collectCapped(\n reader: ReadableStreamDefaultReader<Uint8Array>,\n maxBytes: number,\n): Promise<Buffer> {\n const chunks: Uint8Array[] = [];\n let seen = 0;\n let next = await reader.read();\n while (!next.done && seen <= maxBytes) {\n seen += next.value.length;\n chunks.push(next.value);\n // oxlint-disable-next-line eslint/no-await-in-loop -- reading a stream is inherently sequential\n next = await reader.read();\n }\n if (seen > maxBytes) {\n await abortTooLarge(reader, maxBytes);\n }\n return Buffer.concat(chunks);\n}\n\nasync function abortTooLarge(\n reader: ReadableStreamDefaultReader<Uint8Array>,\n maxBytes: number,\n): Promise<never> {\n await reader.cancel();\n throw tooLarge(maxBytes);\n}\n\nfunction tooLarge(maxBytes: number): HTTPException {\n return new HTTPException(413, { message: `Archive exceeds ${maxBytes} bytes` });\n}\n\n/** Adapt a lazily produced chunk sequence to a pull-based web stream. */\nexport function toWebStream(chunks: AsyncIterable<Buffer>): ReadableStream<Uint8Array> {\n const iterator = chunks[Symbol.asyncIterator]();\n return new ReadableStream<Uint8Array>({\n async pull(controller): Promise<void> {\n const next = await iterator.next();\n if (next.done) {\n controller.close();\n } else {\n controller.enqueue(new Uint8Array(next.value));\n }\n },\n async cancel(): Promise<void> {\n await iterator.return?.();\n },\n });\n}\n\n/** Download file name: `<slug>-baselines[-<branch>].zip`, header-safe. */\nexport function archiveFileName(slug: string, branch?: string): string {\n const base = branch ? `${slug}-baselines-${branch}` : `${slug}-baselines`;\n return `${base.replaceAll(/[^\\w.-]+/gu, \"_\")}.zip`;\n}\n"],"mappings":";;;;;;;;;AAMA,MAAa,4BAA4B,wBAAwB;;AAGjE,SAAgB,gBAAgB,QAAqD;CACnF,OAAO,KAAK,IAAI,OAAO,kBAAkB,0BAA0B,yBAAyB;AAC9F;;;;;AAMA,eAAsB,eAAe,SAAkB,UAAmC;CACxF,IAAI,OAAO,QAAQ,QAAQ,IAAI,gBAAgB,KAAK,CAAC,IAAI,UACvD,MAAM,SAAS,QAAQ;CAEzB,IAAI,CAAC,QAAQ,MACX,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,uBAAuB,CAAC;CAElE,OAAO,MAAM,cAAc,QAAQ,KAAK,UAAU,GAAG,QAAQ;AAC/D;AAEA,eAAe,cACb,QACA,UACiB;CACjB,MAAM,SAAuB,CAAC;CAC9B,IAAI,OAAO;CACX,IAAI,OAAO,MAAM,OAAO,KAAK;CAC7B,OAAO,CAAC,KAAK,QAAQ,QAAQ,UAAU;EACrC,QAAQ,KAAK,MAAM;EACnB,OAAO,KAAK,KAAK,KAAK;EAEtB,OAAO,MAAM,OAAO,KAAK;CAC3B;CACA,IAAI,OAAO,UACT,MAAM,cAAc,QAAQ,QAAQ;CAEtC,OAAO,OAAO,OAAO,MAAM;AAC7B;AAEA,eAAe,cACb,QACA,UACgB;CAChB,MAAM,OAAO,OAAO;CACpB,MAAM,SAAS,QAAQ;AACzB;AAEA,SAAS,SAAS,UAAiC;CACjD,OAAO,IAAI,cAAc,KAAK,EAAE,SAAS,mBAAmB,SAAS,QAAQ,CAAC;AAChF;;AAGA,SAAgB,YAAY,QAA2D;CACrF,MAAM,WAAW,OAAO,OAAO,cAAc,CAAC;CAC9C,OAAO,IAAI,eAA2B;EACpC,MAAM,KAAK,YAA2B;GACpC,MAAM,OAAO,MAAM,SAAS,KAAK;GACjC,IAAI,KAAK,MACP,WAAW,MAAM;QAEjB,WAAW,QAAQ,IAAI,WAAW,KAAK,KAAK,CAAC;EAEjD;EACA,MAAM,SAAwB;GAC5B,MAAM,SAAS,SAAS;EAC1B;CACF,CAAC;AACH;;AAGA,SAAgB,gBAAgB,MAAc,QAAyB;CAErE,OAAO,IADM,SAAS,GAAG,KAAK,aAAa,WAAW,GAAG,KAAK,YAAA,CAC/C,WAAW,cAAc,GAAG,EAAE;AAC/C"}
@@ -0,0 +1,124 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { badRequest, errorSchema, forbidden, notFound, unauthorized } from "./schemas.mjs";
7
+ import { archiveFileName, importByteLimit, readCappedBody, toWebStream } from "./baselines.handlers.mjs";
8
+ import { createRoute, z } from "@hono/zod-openapi";
9
+ import { BaselineArchiveError, BaselineTransferModel } from "@storyshelf/core/models";
10
+ import { HTTPException } from "hono/http-exception";
11
+ // src/routers/baselines.ts
12
+ /**
13
+ * Baseline portability endpoints: export a project's baselines as a zip
14
+ * (`manifest.json` + PNGs, format v1) and import (upsert) the same format.
15
+ * Format, limits, and validation live in `@storyshelf/core/models`
16
+ * (`BaselineTransferModel`); this router owns HTTP concerns only.
17
+ */
18
+ const VIEW_ROLES = [
19
+ "viewer",
20
+ "developer",
21
+ "approver",
22
+ "admin"
23
+ ];
24
+ const APPROVER_ROLES = ["approver", "admin"];
25
+ const zipSchema = z.string().openapi({
26
+ type: "string",
27
+ format: "binary"
28
+ });
29
+ const importResultSchema = z.object({
30
+ imported: z.number().int().openapi({ description: "Baselines in the archive (all applied)" }),
31
+ created: z.number().int().openapi({ description: "Baselines that did not exist before" }),
32
+ updated: z.number().int().openapi({ description: "Existing baselines overwritten" })
33
+ }).openapi("BaselineImportResult");
34
+ const exportRoute = createRoute({
35
+ method: "get",
36
+ tags: ["Baselines"],
37
+ summary: "Export baselines as a zip archive",
38
+ description: "Streams `manifest.json` (story/mode/browser/branch → file, SHA-256, timestamps) plus one PNG per baseline at `baselines/<branch>/<storyId>/<mode>.png`. Baselines whose image cannot be read are listed under `skipped`.",
39
+ path: "/api/v1/projects/{slug}/baselines/export",
40
+ request: {
41
+ params: z.object({ slug: z.string() }),
42
+ query: z.object({ branch: z.string().min(1).optional().openapi({ description: "Export only this branch" }) })
43
+ },
44
+ responses: {
45
+ 200: {
46
+ content: { "application/zip": { schema: zipSchema } },
47
+ description: "Baseline archive"
48
+ },
49
+ ...badRequest,
50
+ ...unauthorized,
51
+ ...forbidden,
52
+ ...notFound
53
+ }
54
+ });
55
+ const importRoute = createRoute({
56
+ method: "post",
57
+ tags: ["Baselines"],
58
+ summary: "Import (upsert) baselines from a zip archive",
59
+ description: "Accepts an archive produced by the export endpoint. The whole archive is validated (manifest schema, path safety, PNG signature, SHA-256) before anything is written; baselines with the same story/mode/browser/branch are overwritten. Requires the approver role.",
60
+ path: "/api/v1/projects/{slug}/baselines/import",
61
+ request: {
62
+ params: z.object({ slug: z.string() }),
63
+ body: {
64
+ content: { "application/zip": { schema: zipSchema } },
65
+ required: true
66
+ }
67
+ },
68
+ responses: {
69
+ 200: {
70
+ content: { "application/json": { schema: importResultSchema } },
71
+ description: "Baselines imported"
72
+ },
73
+ 413: {
74
+ content: { "application/json": { schema: errorSchema } },
75
+ description: "Archive exceeds the size limit"
76
+ },
77
+ ...badRequest,
78
+ ...unauthorized,
79
+ ...forbidden,
80
+ ...notFound
81
+ }
82
+ });
83
+ /** Map archive validation failures to 400s; rethrow everything else. */
84
+ function asBadRequest(error) {
85
+ if (error instanceof BaselineArchiveError) throw new HTTPException(400, { message: error.message });
86
+ throw error;
87
+ }
88
+ /** Register the baseline export/import endpoints. */
89
+ function registerBaselines(app) {
90
+ app.openapi(exportRoute, async (c) => {
91
+ const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...VIEW_ROLES);
92
+ const { branch } = c.req.valid("query");
93
+ const { db, storage, logger } = getStore();
94
+ const chunks = await new BaselineTransferModel(db, storage).export(project.id, {
95
+ branch,
96
+ project: project.slug,
97
+ onSkipped: (baseline) => {
98
+ logger.warn({
99
+ projectId: project.id,
100
+ ...baseline
101
+ }, "baseline image skipped in export");
102
+ }
103
+ }).catch(asBadRequest);
104
+ return new Response(toWebStream(chunks), { headers: {
105
+ "content-type": "application/zip",
106
+ "content-disposition": `attachment; filename="${archiveFileName(project.slug, branch)}"`,
107
+ "cache-control": "no-store"
108
+ } });
109
+ });
110
+ app.openapi(importRoute, async (c) => {
111
+ const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...APPROVER_ROLES);
112
+ const archive = await readCappedBody(c.req.raw, importByteLimit(getStore().config));
113
+ const { db, storage } = getStore();
114
+ const result = await new BaselineTransferModel(db, storage).import(project.id, archive).catch(asBadRequest);
115
+ getStore().logger.info({
116
+ projectId: project.id,
117
+ ...result
118
+ }, "baselines imported");
119
+ return c.json(result, 200);
120
+ });
121
+ }
122
+ export { registerBaselines };
123
+
124
+ //# sourceMappingURL=baselines.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baselines.mjs","names":[],"sources":["../../src/routers/baselines.ts"],"sourcesContent":["/**\n * Baseline portability endpoints: export a project's baselines as a zip\n * (`manifest.json` + PNGs, format v1) and import (upsert) the same format.\n * Format, limits, and validation live in `@storyshelf/core/models`\n * (`BaselineTransferModel`); this router owns HTTP concerns only.\n */\nimport { createRoute, z } from \"@hono/zod-openapi\";\nimport { BaselineArchiveError, BaselineTransferModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport {\n archiveFileName,\n importByteLimit,\n readCappedBody,\n toWebStream,\n} from \"./baselines.handlers.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { badRequest, errorSchema, forbidden, notFound, unauthorized } from \"./schemas.ts\";\n\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\nconst zipSchema = z.string().openapi({ type: \"string\", format: \"binary\" });\nconst importResultSchema = z\n .object({\n imported: z.number().int().openapi({ description: \"Baselines in the archive (all applied)\" }),\n created: z.number().int().openapi({ description: \"Baselines that did not exist before\" }),\n updated: z.number().int().openapi({ description: \"Existing baselines overwritten\" }),\n })\n .openapi(\"BaselineImportResult\");\n\nconst exportRoute = createRoute({\n method: \"get\",\n tags: [\"Baselines\"],\n summary: \"Export baselines as a zip archive\",\n description:\n \"Streams `manifest.json` (story/mode/browser/branch → file, SHA-256, timestamps) plus one PNG per baseline at `baselines/<branch>/<storyId>/<mode>.png`. Baselines whose image cannot be read are listed under `skipped`.\",\n path: \"/api/v1/projects/{slug}/baselines/export\",\n request: {\n params: z.object({ slug: z.string() }),\n query: z.object({\n branch: z.string().min(1).optional().openapi({ description: \"Export only this branch\" }),\n }),\n },\n responses: {\n 200: { content: { \"application/zip\": { schema: zipSchema } }, description: \"Baseline archive\" },\n ...badRequest,\n ...unauthorized,\n ...forbidden,\n ...notFound,\n },\n});\n\nconst importRoute = createRoute({\n method: \"post\",\n tags: [\"Baselines\"],\n summary: \"Import (upsert) baselines from a zip archive\",\n description:\n \"Accepts an archive produced by the export endpoint. The whole archive is validated (manifest schema, path safety, PNG signature, SHA-256) before anything is written; baselines with the same story/mode/browser/branch are overwritten. Requires the approver role.\",\n path: \"/api/v1/projects/{slug}/baselines/import\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/zip\": { schema: zipSchema } }, required: true },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: importResultSchema } },\n description: \"Baselines imported\",\n },\n 413: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"Archive exceeds the size limit\",\n },\n ...badRequest,\n ...unauthorized,\n ...forbidden,\n ...notFound,\n },\n});\n\n/** Map archive validation failures to 400s; rethrow everything else. */\nfunction asBadRequest(error: unknown): never {\n if (error instanceof BaselineArchiveError) {\n throw new HTTPException(400, { message: error.message });\n }\n throw error;\n}\n\n/** Register the baseline export/import endpoints. */\nexport function registerBaselines(app: ShelfRouter): void {\n app.openapi(exportRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n const { branch } = c.req.valid(\"query\");\n const { db, storage, logger } = getStore();\n const chunks = await new BaselineTransferModel(db, storage)\n .export(project.id, {\n branch,\n project: project.slug,\n onSkipped: (baseline): void => {\n logger.warn({ projectId: project.id, ...baseline }, \"baseline image skipped in export\");\n },\n })\n .catch(asBadRequest);\n return new Response(toWebStream(chunks), {\n headers: {\n \"content-type\": \"application/zip\",\n \"content-disposition\": `attachment; filename=\"${archiveFileName(project.slug, branch)}\"`,\n \"cache-control\": \"no-store\",\n },\n });\n });\n\n app.openapi(importRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...APPROVER_ROLES);\n const archive = await readCappedBody(c.req.raw, importByteLimit(getStore().config));\n const { db, storage } = getStore();\n const result = await new BaselineTransferModel(db, storage)\n .import(project.id, archive)\n .catch(asBadRequest);\n getStore().logger.info({ projectId: project.id, ...result }, \"baselines imported\");\n return c.json(result, 200);\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAqBA,MAAM,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;AACtF,MAAM,iBAAyC,CAAC,YAAY,OAAO;AAEnE,MAAM,YAAY,EAAE,OAAO,CAAC,CAAC,QAAQ;CAAE,MAAM;CAAU,QAAQ;AAAS,CAAC;AACzE,MAAM,qBAAqB,EACxB,OAAO;CACN,UAAU,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,aAAa,yCAAyC,CAAC;CAC5F,SAAS,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,aAAa,sCAAsC,CAAC;CACxF,SAAS,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,aAAa,iCAAiC,CAAC;AACrF,CAAC,CAAC,CACD,QAAQ,sBAAsB;AAEjC,MAAM,cAAc,YAAY;CAC9B,QAAQ;CACR,MAAM,CAAC,WAAW;CAClB,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;EACrC,OAAO,EAAE,OAAO,EACd,QAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,aAAa,0BAA0B,CAAC,EACzF,CAAC;CACH;CACA,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,mBAAmB,EAAE,QAAQ,UAAU,EAAE;GAAG,aAAa;EAAmB;EAC9F,GAAG;EACH,GAAG;EACH,GAAG;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,cAAc,YAAY;CAC9B,QAAQ;CACR,MAAM,CAAC,WAAW;CAClB,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;EACrC,MAAM;GAAE,SAAS,EAAE,mBAAmB,EAAE,QAAQ,UAAU,EAAE;GAAG,UAAU;EAAK;CAChF;CACA,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,mBAAmB,EAAE;GAC9D,aAAa;EACf;EACA,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GACvD,aAAa;EACf;EACA,GAAG;EACH,GAAG;EACH,GAAG;EACH,GAAG;CACL;AACF,CAAC;;AAGD,SAAS,aAAa,OAAuB;CAC3C,IAAI,iBAAiB,sBACnB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,MAAM,QAAQ,CAAC;CAEzD,MAAM;AACR;;AAGA,SAAgB,kBAAkB,KAAwB;CACxD,IAAI,QAAQ,aAAa,OAAO,MAAM;EACpC,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,UAAU;EAC1F,MAAM,EAAE,WAAW,EAAE,IAAI,MAAM,OAAO;EACtC,MAAM,EAAE,IAAI,SAAS,WAAW,SAAS;EACzC,MAAM,SAAS,MAAM,IAAI,sBAAsB,IAAI,OAAO,CAAC,CACxD,OAAO,QAAQ,IAAI;GAClB;GACA,SAAS,QAAQ;GACjB,YAAY,aAAmB;IAC7B,OAAO,KAAK;KAAE,WAAW,QAAQ;KAAI,GAAG;IAAS,GAAG,kCAAkC;GACxF;EACF,CAAC,CAAC,CACD,MAAM,YAAY;EACrB,OAAO,IAAI,SAAS,YAAY,MAAM,GAAG,EACvC,SAAS;GACP,gBAAgB;GAChB,uBAAuB,yBAAyB,gBAAgB,QAAQ,MAAM,MAAM,EAAE;GACtF,iBAAiB;EACnB,EACF,CAAC;CACH,CAAC;CAED,IAAI,QAAQ,aAAa,OAAO,MAAM;EACpC,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,cAAc;EAC9F,MAAM,UAAU,MAAM,eAAe,EAAE,IAAI,KAAK,gBAAgB,SAAS,CAAC,CAAC,MAAM,CAAC;EAClF,MAAM,EAAE,IAAI,YAAY,SAAS;EACjC,MAAM,SAAS,MAAM,IAAI,sBAAsB,IAAI,OAAO,CAAC,CACxD,OAAO,QAAQ,IAAI,OAAO,CAAC,CAC3B,MAAM,YAAY;EACrB,SAAS,CAAC,CAAC,OAAO,KAAK;GAAE,WAAW,QAAQ;GAAI,GAAG;EAAO,GAAG,oBAAoB;EACjF,OAAO,EAAE,KAAK,QAAQ,GAAG;CAC3B,CAAC;AACH"}
@@ -0,0 +1,67 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { hxRefresh } from "./htmx.mjs";
7
+ import { flashHx } from "./flash.mjs";
8
+ import { buildSchema, errorSchema, notFound, unauthorized } from "./schemas.mjs";
9
+ import { DEVELOPER_ROLES, announceBuildStatus, buildForProject } from "./builds.handlers.mjs";
10
+ import { createRoute, z } from "@hono/zod-openapi";
11
+ import { BuildModel, CaptureAttemptModel } from "@storyshelf/core/models";
12
+ import { HTTPException } from "hono/http-exception";
13
+ import { CANCELLABLE_BUILD_STATUSES } from "@storyshelf/core/types";
14
+ // src/routers/build-cancel.ts
15
+ const cancelBuildRoute = createRoute({
16
+ method: "post",
17
+ tags: ["Builds"],
18
+ summary: "Cancel a build",
19
+ description: "Stops the work of a build that has not finished capturing: its queued jobs (capture, publish and notification jobs alike) end cancelled, running ones are signalled and stop within seconds, and the build ends `cancelled`, a terminal status that a capture finishing late cannot move (retry resets it like a failed build). It raises `build:cancelled` (webhook and notification topic), never `build:failed` or a capture-failed alert. `testing` class. 409 when the build is not pending, capturing or comparing.",
20
+ path: "/api/v1/projects/{slug}/builds/{buildId}/cancel",
21
+ request: { params: z.object({
22
+ slug: z.string(),
23
+ buildId: z.string()
24
+ }) },
25
+ responses: {
26
+ 200: {
27
+ content: { "application/json": { schema: buildSchema } },
28
+ description: "The build's jobs were cancelled and the build marked cancelled"
29
+ },
30
+ 409: {
31
+ content: { "application/json": { schema: errorSchema } },
32
+ description: "The build is not in a cancellable state"
33
+ },
34
+ ...notFound,
35
+ ...unauthorized
36
+ }
37
+ });
38
+ /** Register `POST …/builds/:buildId/cancel` (`testing` class). */
39
+ function registerBuildCancel(app) {
40
+ app.openapi(cancelBuildRoute, async (c) => {
41
+ const { slug, buildId } = c.req.valid("param");
42
+ const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);
43
+ const build = await buildForProject(project.id, buildId);
44
+ if (!CANCELLABLE_BUILD_STATUSES.includes(build.status)) throw new HTTPException(409, { message: `A ${build.status} build cannot be cancelled` });
45
+ const updated = await cancelBuild(build.id);
46
+ hxRefresh(c);
47
+ flashHx(c, "Build cancelled");
48
+ return c.json(updated, 200);
49
+ });
50
+ }
51
+ /**
52
+ * Cancel the build's jobs of every kind, close its open attempts, then end the build `cancelled` and
53
+ * announce it. A capture still running notices the cancel on its next check (within seconds) and
54
+ * stops; whatever it still writes cannot move the build off `cancelled`. The jobs end first so the
55
+ * cancel's own notification job is not cancelled.
56
+ */
57
+ async function cancelBuild(buildId) {
58
+ const { db, cancelBuildJobs } = getStore();
59
+ await cancelBuildJobs?.(buildId);
60
+ await new CaptureAttemptModel(db).closeOthers(buildId, null, "Cancelled by a user");
61
+ const updated = await new BuildModel(db).setStatus(buildId, "cancelled");
62
+ await announceBuildStatus(buildId, "cancelled");
63
+ return updated;
64
+ }
65
+ export { registerBuildCancel };
66
+
67
+ //# sourceMappingURL=build-cancel.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-cancel.mjs","names":[],"sources":["../../src/routers/build-cancel.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { BuildModel, CaptureAttemptModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport { CANCELLABLE_BUILD_STATUSES } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { DEVELOPER_ROLES, announceBuildStatus, buildForProject } from \"./builds.handlers.ts\";\nimport { flashHx } from \"./flash.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { hxRefresh } from \"./htmx.ts\";\nimport { buildSchema, errorSchema, notFound, unauthorized } from \"./schemas.ts\";\n\nconst cancelBuildRoute = createRoute({\n method: \"post\",\n tags: [\"Builds\"],\n summary: \"Cancel a build\",\n description:\n \"Stops the work of a build that has not finished capturing: its queued jobs (capture, publish and notification jobs alike) end cancelled, running ones are signalled and stop within seconds, and the build ends `cancelled`, a terminal status that a capture finishing late cannot move (retry resets it like a failed build). It raises `build:cancelled` (webhook and notification topic), never `build:failed` or a capture-failed alert. `testing` class. 409 when the build is not pending, capturing or comparing.\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/cancel\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: buildSchema } },\n description: \"The build's jobs were cancelled and the build marked cancelled\",\n },\n 409: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"The build is not in a cancellable state\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\n/** Register `POST …/builds/:buildId/cancel` (`testing` class). */\nexport function registerBuildCancel(app: ShelfRouter): void {\n app.openapi(cancelBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n if (!CANCELLABLE_BUILD_STATUSES.includes(build.status)) {\n throw new HTTPException(409, { message: `A ${build.status} build cannot be cancelled` });\n }\n const updated = await cancelBuild(build.id);\n hxRefresh(c);\n flashHx(c, \"Build cancelled\");\n return c.json(updated, 200);\n });\n}\n\n/**\n * Cancel the build's jobs of every kind, close its open attempts, then end the build `cancelled` and\n * announce it. A capture still running notices the cancel on its next check (within seconds) and\n * stops; whatever it still writes cannot move the build off `cancelled`. The jobs end first so the\n * cancel's own notification job is not cancelled.\n */\nasync function cancelBuild(buildId: string): Promise<Build> {\n const { db, cancelBuildJobs } = getStore();\n await cancelBuildJobs?.(buildId);\n await new CaptureAttemptModel(db).closeOthers(buildId, null, \"Cancelled by a user\");\n const updated = await new BuildModel(db).setStatus(buildId, \"cancelled\");\n await announceBuildStatus(buildId, \"cancelled\");\n return updated;\n}\n"],"mappings":";;;;;;;;;;;;;;AAaA,MAAM,mBAAmB,YAAY;CACnC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GACvD,aAAa;EACf;EACA,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GACvD,aAAa;EACf;EACA,GAAG;EACH,GAAG;CACL;AACF,CAAC;;AAGD,SAAgB,oBAAoB,KAAwB;CAC1D,IAAI,QAAQ,kBAAkB,OAAO,MAAM;EACzC,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,IAAI,CAAC,2BAA2B,SAAS,MAAM,MAAM,GACnD,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,KAAK,MAAM,OAAO,4BAA4B,CAAC;EAEzF,MAAM,UAAU,MAAM,YAAY,MAAM,EAAE;EAC1C,UAAU,CAAC;EACX,QAAQ,GAAG,iBAAiB;EAC5B,OAAO,EAAE,KAAK,SAAS,GAAG;CAC5B,CAAC;AACH;;;;;;;AAQA,eAAe,YAAY,SAAiC;CAC1D,MAAM,EAAE,IAAI,oBAAoB,SAAS;CACzC,MAAM,kBAAkB,OAAO;CAC/B,MAAM,IAAI,oBAAoB,EAAE,CAAC,CAAC,YAAY,SAAS,MAAM,qBAAqB;CAClF,MAAM,UAAU,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,UAAU,SAAS,WAAW;CACvE,MAAM,oBAAoB,SAAS,WAAW;CAC9C,OAAO;AACT"}
@@ -0,0 +1,69 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { notFound, unauthorized } from "./schemas.mjs";
7
+ import { VIEW_ROLES, buildForProject } from "./builds.handlers.mjs";
8
+ import { createRoute, z } from "@hono/zod-openapi";
9
+ import { changesetOf } from "@storyshelf/core/models";
10
+ // src/routers/build-changeset.ts
11
+ const component = z.object({
12
+ importPath: z.string(),
13
+ title: z.string(),
14
+ storyCount: z.number()
15
+ });
16
+ const changesetSchema = z.object({
17
+ baseline: z.object({
18
+ id: z.string(),
19
+ gitBranch: z.string(),
20
+ gitSha: z.string()
21
+ }).nullable().describe("The build compared with; null for the first build"),
22
+ added: component.array(),
23
+ removed: component.array(),
24
+ changed: component.extend({ previousStoryCount: z.number() }).array(),
25
+ unchanged: z.number().describe("Components with the same hash in both builds")
26
+ }).openapi("BuildChangeset");
27
+ const getChangesetRoute = createRoute({
28
+ method: "get",
29
+ tags: ["Review"],
30
+ summary: "Get a build's changeset",
31
+ description: "Components the build added, removed or changed against its baseline build (the latest accepted build of its branch, else of the default branch), from the Storybook catalog. A changed hash means the component's stories changed, not that anything looks different. 404 when the project has review off.",
32
+ path: "/api/v1/projects/{slug}/builds/{buildId}/changeset",
33
+ request: { params: z.object({
34
+ slug: z.string(),
35
+ buildId: z.string()
36
+ }) },
37
+ responses: {
38
+ 200: {
39
+ content: { "application/json": { schema: changesetSchema } },
40
+ description: "The build's changeset"
41
+ },
42
+ ...notFound,
43
+ ...unauthorized
44
+ }
45
+ });
46
+ /** Register the changeset endpoint (`review` class). */
47
+ function registerBuildChangeset(app) {
48
+ app.openapi(getChangesetRoute, async (c) => {
49
+ const { slug, buildId } = c.req.valid("param");
50
+ const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);
51
+ const build = await buildForProject(project.id, buildId);
52
+ const { baseline, changeset } = await changesetOf(getStore().db, project, build);
53
+ const { added, removed, changed, unchanged } = changeset;
54
+ return c.json({
55
+ baseline: baseline ? {
56
+ id: baseline.id,
57
+ gitBranch: baseline.gitBranch,
58
+ gitSha: baseline.gitSha
59
+ } : null,
60
+ added,
61
+ removed,
62
+ changed,
63
+ unchanged
64
+ });
65
+ });
66
+ }
67
+ export { registerBuildChangeset };
68
+
69
+ //# sourceMappingURL=build-changeset.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-changeset.mjs","names":[],"sources":["../../src/routers/build-changeset.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { changesetOf } from \"@storyshelf/core/models\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { VIEW_ROLES, buildForProject } from \"./builds.handlers.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { notFound, unauthorized } from \"./schemas.ts\";\n\nconst component = z.object({\n importPath: z.string(),\n title: z.string(),\n storyCount: z.number(),\n});\n\nconst changesetSchema = z\n .object({\n baseline: z\n .object({ id: z.string(), gitBranch: z.string(), gitSha: z.string() })\n .nullable()\n .describe(\"The build compared with; null for the first build\"),\n added: component.array(),\n removed: component.array(),\n changed: component.extend({ previousStoryCount: z.number() }).array(),\n unchanged: z.number().describe(\"Components with the same hash in both builds\"),\n })\n .openapi(\"BuildChangeset\");\n\nconst getChangesetRoute = createRoute({\n method: \"get\",\n tags: [\"Review\"],\n summary: \"Get a build's changeset\",\n description:\n \"Components the build added, removed or changed against its baseline build (the latest accepted build of its branch, else of the default branch), from the Storybook catalog. A changed hash means the component's stories changed, not that anything looks different. 404 when the project has review off.\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/changeset\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: changesetSchema } },\n description: \"The build's changeset\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\n/** Register the changeset endpoint (`review` class). */\nexport function registerBuildChangeset(app: ShelfRouter): void {\n app.openapi(getChangesetRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n const { baseline, changeset } = await changesetOf(getStore().db, project, build);\n const { added, removed, changed, unchanged } = changeset;\n return c.json({\n baseline: baseline\n ? { id: baseline.id, gitBranch: baseline.gitBranch, gitSha: baseline.gitSha }\n : null,\n added,\n removed,\n changed,\n unchanged,\n });\n });\n}\n"],"mappings":";;;;;;;;;;AAQA,MAAM,YAAY,EAAE,OAAO;CACzB,YAAY,EAAE,OAAO;CACrB,OAAO,EAAE,OAAO;CAChB,YAAY,EAAE,OAAO;AACvB,CAAC;AAED,MAAM,kBAAkB,EACrB,OAAO;CACN,UAAU,EACP,OAAO;EAAE,IAAI,EAAE,OAAO;EAAG,WAAW,EAAE,OAAO;EAAG,QAAQ,EAAE,OAAO;CAAE,CAAC,CAAC,CACrE,SAAS,CAAC,CACV,SAAS,mDAAmD;CAC/D,OAAO,UAAU,MAAM;CACvB,SAAS,UAAU,MAAM;CACzB,SAAS,UAAU,OAAO,EAAE,oBAAoB,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,MAAM;CACpE,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS,8CAA8C;AAC/E,CAAC,CAAC,CACD,QAAQ,gBAAgB;AAE3B,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,gBAAgB,EAAE;GAC3D,aAAa;EACf;EACA,GAAG;EACH,GAAG;CACL;AACF,CAAC;;AAGD,SAAgB,uBAAuB,KAAwB;CAC7D,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,EAAE,UAAU,cAAc,MAAM,YAAY,SAAS,CAAC,CAAC,IAAI,SAAS,KAAK;EAC/E,MAAM,EAAE,OAAO,SAAS,SAAS,cAAc;EAC/C,OAAO,EAAE,KAAK;GACZ,UAAU,WACN;IAAE,IAAI,SAAS;IAAI,WAAW,SAAS;IAAW,QAAQ,SAAS;GAAO,IAC1E;GACJ;GACA;GACA;GACA;EACF,CAAC;CACH,CAAC;AACH"}
@@ -0,0 +1,85 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { REVIEW_EVENTS, announceReview } from "../review-events.mjs";
6
+ import { requestBuildState } from "../git-status.mjs";
7
+ import { announceBuildStatus } from "./builds.handlers.mjs";
8
+ import { publishingOf } from "@storyshelf/core/capture";
9
+ import { BuildModel, BuildReviewModel, deriveReviewStatus, latestVerdicts } from "@storyshelf/core/models";
10
+ import { HTTPException } from "hono/http-exception";
11
+ // src/routers/build-reviews.handlers.ts
12
+ /**
13
+ * Build-level verdicts (ADR 0013, decision 3): giving one and reading where a build stands. The
14
+ * routes are classed `review`, so `requireReview` answers 404 for a project with review off.
15
+ */
16
+ /** Statuses the verdicts may move a build between; a failed, cancelled or capturing build is not theirs to decide. */
17
+ const VERDICT_STATUSES = /* @__PURE__ */ new Set([
18
+ "pending",
19
+ "approved",
20
+ "rejected"
21
+ ]);
22
+ /** The verdicts of a build and what they make of it. */
23
+ async function reviewStateOf(project, build) {
24
+ const history = await new BuildReviewModel(getStore().db).listByBuild(build.id);
25
+ const latest = latestVerdicts(history);
26
+ return {
27
+ status: build.status,
28
+ approvalsRequired: approvalsRequired(project),
29
+ approvals: latest.filter((review) => review.verdict === "approved").length,
30
+ latest,
31
+ history
32
+ };
33
+ }
34
+ /**
35
+ * Record `userId`'s verdict and let the verdicts decide the build. With `testing` on the verdict is
36
+ * stored as a sign-off and the snapshot rollup keeps deciding. Every write asks for the build's
37
+ * state to be shown on its git channels; a status the verdicts moved to `approved` or `rejected`
38
+ * is announced like any other (webhook and notifications). Every verdict raises `review:verdict`.
39
+ */
40
+ async function giveVerdict(project, build, userId, input) {
41
+ await assertReviewable(project, build, userId);
42
+ const review = await new BuildReviewModel(getStore().db).add(build.id, userId, input.verdict, input.body);
43
+ const status = await decideBuild(project, build);
44
+ await announceReview(project, REVIEW_EVENTS.verdict, {
45
+ buildId: build.id,
46
+ gitBranch: build.gitBranch,
47
+ verdict: review.verdict,
48
+ userId,
49
+ body: review.body?.slice(0, 500) ?? null,
50
+ status: status ?? build.status
51
+ }, build.id);
52
+ if (status === null) await requestBuildState(build.id);
53
+ else await announceBuildStatus(build.id, status);
54
+ return review;
55
+ }
56
+ /**
57
+ * A verdict needs a build that is neither `failed` nor `cancelled`, whose publishing has ended (a publish that later
58
+ * failed would otherwise leave an approved build without statics), and, when it has no user (an
59
+ * instance without authentication), a project that needs one approval: the anonymous reviewer is
60
+ * one person, however many verdicts they give.
61
+ */
62
+ async function assertReviewable(project, build, userId) {
63
+ if (build.status === "failed" || build.status === "cancelled") throw new HTTPException(409, { message: `The build ${build.status === "failed" ? "failed" : "was cancelled"}; there is nothing to review` });
64
+ if (await publishingOf(getStore().storage, build) !== "success") throw new HTTPException(409, { message: "The build is still publishing; give a verdict once its Storybook is published" });
65
+ if (userId === null && approvalsRequired(project) > 1) throw new HTTPException(409, { message: `Verdicts without authentication count as one reviewer, but this project needs ${String(approvalsRequired(project))} approvals; enable authentication to review` });
66
+ }
67
+ /** Move the build to the status its verdicts give it; the new status, or null when it did not move. */
68
+ async function decideBuild(project, build) {
69
+ const { db, testing } = getStore();
70
+ const status = deriveReviewStatus({
71
+ testing,
72
+ review: true,
73
+ approvalsRequired: approvalsRequired(project),
74
+ reviews: await new BuildReviewModel(db).listByBuild(build.id)
75
+ });
76
+ if (status === null || !VERDICT_STATUSES.has(build.status) || status === build.status) return null;
77
+ await new BuildModel(db).setStatus(build.id, status);
78
+ return status;
79
+ }
80
+ function approvalsRequired(project) {
81
+ return Math.max(1, project.reviewApprovals ?? 1);
82
+ }
83
+ export { giveVerdict, reviewStateOf };
84
+
85
+ //# sourceMappingURL=build-reviews.handlers.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-reviews.handlers.mjs","names":[],"sources":["../../src/routers/build-reviews.handlers.ts"],"sourcesContent":["/**\n * Build-level verdicts (ADR 0013, decision 3): giving one and reading where a build stands. The\n * routes are classed `review`, so `requireReview` answers 404 for a project with review off.\n */\nimport { publishingOf } from \"@storyshelf/core/capture\";\nimport {\n BuildModel,\n BuildReviewModel,\n deriveReviewStatus,\n latestVerdicts,\n} from \"@storyshelf/core/models\";\nimport type { Build, BuildReview, Project } from \"@storyshelf/core/schema\";\nimport type { BuildStatus, ReviewVerdict } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { requestBuildState } from \"../git-status.ts\";\nimport { REVIEW_EVENTS, announceReview } from \"../review-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { announceBuildStatus } from \"./builds.handlers.ts\";\n\n/** Statuses the verdicts may move a build between; a failed, cancelled or capturing build is not theirs to decide. */\nconst VERDICT_STATUSES: ReadonlySet<BuildStatus> = new Set([\"pending\", \"approved\", \"rejected\"]);\n\n/** Where a build stands in review. */\nexport interface ReviewState {\n status: BuildStatus;\n approvalsRequired: number;\n approvals: number;\n latest: BuildReview[];\n history: BuildReview[];\n}\n\n/** The verdicts of a build and what they make of it. */\nexport async function reviewStateOf(project: Project, build: Build): Promise<ReviewState> {\n const history = await new BuildReviewModel(getStore().db).listByBuild(build.id);\n const latest = latestVerdicts(history);\n return {\n status: build.status,\n approvalsRequired: approvalsRequired(project),\n approvals: latest.filter((review) => review.verdict === \"approved\").length,\n latest,\n history,\n };\n}\n\n/**\n * Record `userId`'s verdict and let the verdicts decide the build. With `testing` on the verdict is\n * stored as a sign-off and the snapshot rollup keeps deciding. Every write asks for the build's\n * state to be shown on its git channels; a status the verdicts moved to `approved` or `rejected`\n * is announced like any other (webhook and notifications). Every verdict raises `review:verdict`.\n */\nexport async function giveVerdict(\n project: Project,\n build: Build,\n userId: string | null,\n input: { verdict: ReviewVerdict; body?: string | undefined },\n): Promise<BuildReview> {\n await assertReviewable(project, build, userId);\n const review = await new BuildReviewModel(getStore().db).add(\n build.id,\n userId,\n input.verdict,\n input.body,\n );\n const status = await decideBuild(project, build);\n await announceReview(\n project,\n REVIEW_EVENTS.verdict,\n {\n buildId: build.id,\n gitBranch: build.gitBranch,\n verdict: review.verdict,\n userId,\n body: review.body?.slice(0, 500) ?? null,\n status: status ?? build.status,\n },\n build.id,\n );\n // Every move is news: `approved` and `rejected` as ever, and `pending` when a rewritten verdict\n // sends the build back; a verdict that moves nothing only refreshes the git channels.\n if (status === null) {\n await requestBuildState(build.id);\n } else {\n await announceBuildStatus(build.id, status);\n }\n return review;\n}\n\n/**\n * A verdict needs a build that is neither `failed` nor `cancelled`, whose publishing has ended (a publish that later\n * failed would otherwise leave an approved build without statics), and, when it has no user (an\n * instance without authentication), a project that needs one approval: the anonymous reviewer is\n * one person, however many verdicts they give.\n */\nasync function assertReviewable(\n project: Project,\n build: Build,\n userId: string | null,\n): Promise<void> {\n if (build.status === \"failed\" || build.status === \"cancelled\") {\n throw new HTTPException(409, {\n message: `The build ${build.status === \"failed\" ? \"failed\" : \"was cancelled\"}; there is nothing to review`,\n });\n }\n if ((await publishingOf(getStore().storage, build)) !== \"success\") {\n throw new HTTPException(409, {\n message: \"The build is still publishing; give a verdict once its Storybook is published\",\n });\n }\n if (userId === null && approvalsRequired(project) > 1) {\n throw new HTTPException(409, {\n message: `Verdicts without authentication count as one reviewer, but this project needs ${String(approvalsRequired(project))} approvals; enable authentication to review`,\n });\n }\n}\n\n/** Move the build to the status its verdicts give it; the new status, or null when it did not move. */\nasync function decideBuild(\n project: Project,\n build: Build,\n): Promise<\"approved\" | \"rejected\" | \"pending\" | null> {\n const { db, testing } = getStore();\n const status = deriveReviewStatus({\n testing,\n review: true,\n approvalsRequired: approvalsRequired(project),\n reviews: await new BuildReviewModel(db).listByBuild(build.id),\n });\n if (status === null || !VERDICT_STATUSES.has(build.status) || status === build.status) {\n return null;\n }\n await new BuildModel(db).setStatus(build.id, status);\n return status;\n}\n\nfunction approvalsRequired(project: Project): number {\n return Math.max(1, project.reviewApprovals ?? 1);\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoBA,MAAM,mCAA6C,IAAI,IAAI;CAAC;CAAW;CAAY;AAAU,CAAC;;AAY9F,eAAsB,cAAc,SAAkB,OAAoC;CACxF,MAAM,UAAU,MAAM,IAAI,iBAAiB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CAC9E,MAAM,SAAS,eAAe,OAAO;CACrC,OAAO;EACL,QAAQ,MAAM;EACd,mBAAmB,kBAAkB,OAAO;EAC5C,WAAW,OAAO,QAAQ,WAAW,OAAO,YAAY,UAAU,CAAC,CAAC;EACpE;EACA;CACF;AACF;;;;;;;AAQA,eAAsB,YACpB,SACA,OACA,QACA,OACsB;CACtB,MAAM,iBAAiB,SAAS,OAAO,MAAM;CAC7C,MAAM,SAAS,MAAM,IAAI,iBAAiB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IACvD,MAAM,IACN,QACA,MAAM,SACN,MAAM,IACR;CACA,MAAM,SAAS,MAAM,YAAY,SAAS,KAAK;CAC/C,MAAM,eACJ,SACA,cAAc,SACd;EACE,SAAS,MAAM;EACf,WAAW,MAAM;EACjB,SAAS,OAAO;EAChB;EACA,MAAM,OAAO,MAAM,MAAM,GAAG,GAAG,KAAK;EACpC,QAAQ,UAAU,MAAM;CAC1B,GACA,MAAM,EACR;CAGA,IAAI,WAAW,MACb,MAAM,kBAAkB,MAAM,EAAE;MAEhC,MAAM,oBAAoB,MAAM,IAAI,MAAM;CAE5C,OAAO;AACT;;;;;;;AAQA,eAAe,iBACb,SACA,OACA,QACe;CACf,IAAI,MAAM,WAAW,YAAY,MAAM,WAAW,aAChD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,aAAa,MAAM,WAAW,WAAW,WAAW,gBAAgB,8BAC/E,CAAC;CAEH,IAAK,MAAM,aAAa,SAAS,CAAC,CAAC,SAAS,KAAK,MAAO,WACtD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,gFACX,CAAC;CAEH,IAAI,WAAW,QAAQ,kBAAkB,OAAO,IAAI,GAClD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,iFAAiF,OAAO,kBAAkB,OAAO,CAAC,EAAE,6CAC/H,CAAC;AAEL;;AAGA,eAAe,YACb,SACA,OACqD;CACrD,MAAM,EAAE,IAAI,YAAY,SAAS;CACjC,MAAM,SAAS,mBAAmB;EAChC;EACA,QAAQ;EACR,mBAAmB,kBAAkB,OAAO;EAC5C,SAAS,MAAM,IAAI,iBAAiB,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CAC9D,CAAC;CACD,IAAI,WAAW,QAAQ,CAAC,iBAAiB,IAAI,MAAM,MAAM,KAAK,WAAW,MAAM,QAC7E,OAAO;CAET,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,UAAU,MAAM,IAAI,MAAM;CACnD,OAAO;AACT;AAEA,SAAS,kBAAkB,SAA0B;CACnD,OAAO,KAAK,IAAI,GAAG,QAAQ,mBAAmB,CAAC;AACjD"}