@reddoorla/maintenance 0.95.0 → 0.96.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 (272) hide show
  1. package/README.md +37 -2
  2. package/dist/airtable-SJV4ZSMS.js +9 -0
  3. package/dist/{announce-PGT4SZZB.js → announce-45HVP4J4.js} +7 -7
  4. package/dist/{blux-4KNXUP2K.js → blux-RUFMNOUJ.js} +5 -5
  5. package/dist/{bump-deps-C5G2EFBB.js → bump-deps-EL3TKMIL.js} +6 -6
  6. package/dist/{chunk-XN37VTDB.js → chunk-37B4LVB3.js} +2 -2
  7. package/dist/{chunk-BVEDKMNG.js → chunk-3EA43S3R.js} +28 -10
  8. package/dist/chunk-3EA43S3R.js.map +1 -0
  9. package/dist/{chunk-XHHMM5HX.js → chunk-43SW3FGW.js} +63 -1
  10. package/dist/chunk-43SW3FGW.js.map +1 -0
  11. package/dist/{chunk-WMBLR5VA.js → chunk-44SDGPGQ.js} +3 -3
  12. package/dist/chunk-4UTJ4G6P.js +52 -0
  13. package/dist/chunk-4UTJ4G6P.js.map +1 -0
  14. package/dist/{chunk-R53VL3RI.js → chunk-5CJVUPJU.js} +1 -1
  15. package/dist/{chunk-R53VL3RI.js.map → chunk-5CJVUPJU.js.map} +1 -1
  16. package/dist/{chunk-4AIYWWKV.js → chunk-62YWOFVN.js} +36 -5
  17. package/dist/chunk-62YWOFVN.js.map +1 -0
  18. package/dist/{chunk-22GKJO5F.js → chunk-6INOBNRO.js} +2 -2
  19. package/dist/{chunk-BYGPUUCT.js → chunk-72FJZ6LO.js} +43 -11
  20. package/dist/chunk-72FJZ6LO.js.map +1 -0
  21. package/dist/{chunk-5GUJ36TL.js → chunk-74QRWE6G.js} +2 -2
  22. package/dist/chunk-A5QKNOXW.js +72 -0
  23. package/dist/chunk-A5QKNOXW.js.map +1 -0
  24. package/dist/{chunk-2NQUCS7O.js → chunk-BIYQN7FH.js} +3 -1
  25. package/dist/{chunk-2NQUCS7O.js.map → chunk-BIYQN7FH.js.map} +1 -1
  26. package/dist/{chunk-S7RYFUIK.js → chunk-CA6ADGOC.js} +2 -2
  27. package/dist/chunk-CGQZFQAY.js +38 -0
  28. package/dist/chunk-CGQZFQAY.js.map +1 -0
  29. package/dist/{chunk-YLYXW5PH.js → chunk-CXICCLNM.js} +34 -4
  30. package/dist/chunk-CXICCLNM.js.map +1 -0
  31. package/dist/{chunk-KZRR4NFG.js → chunk-D3CTOCUR.js} +3 -3
  32. package/dist/{chunk-CWXTNPNZ.js → chunk-DVJNYPFR.js} +3 -3
  33. package/dist/chunk-DVJNYPFR.js.map +1 -0
  34. package/dist/{chunk-AVEQUR44.js → chunk-EASYCD6F.js} +60 -17
  35. package/dist/chunk-EASYCD6F.js.map +1 -0
  36. package/dist/{chunk-BCKEP5S6.js → chunk-ECDJDHKT.js} +2 -2
  37. package/dist/{chunk-DMPHP7UP.js → chunk-EQQ4H6L3.js} +2 -2
  38. package/dist/{chunk-WVNDEYXL.js → chunk-GEUC5VGM.js} +11 -5
  39. package/dist/chunk-GEUC5VGM.js.map +1 -0
  40. package/dist/{chunk-Z2K4JPQ3.js → chunk-H445EH33.js} +2 -2
  41. package/dist/chunk-IHHAYXKY.js +351 -0
  42. package/dist/chunk-IHHAYXKY.js.map +1 -0
  43. package/dist/{chunk-FUWFLAWQ.js → chunk-IMYL67JV.js} +3 -3
  44. package/dist/{chunk-3Z6X3BHU.js → chunk-KN52PM63.js} +7 -2
  45. package/dist/{chunk-3Z6X3BHU.js.map → chunk-KN52PM63.js.map} +1 -1
  46. package/dist/{chunk-WJMPCVOR.js → chunk-LAJ3FF54.js} +12 -4
  47. package/dist/chunk-LAJ3FF54.js.map +1 -0
  48. package/dist/{chunk-5UINJNAJ.js → chunk-MZZDMBDT.js} +16 -3
  49. package/dist/chunk-MZZDMBDT.js.map +1 -0
  50. package/dist/{chunk-DFKBTBO3.js → chunk-NVLREB5R.js} +35 -6
  51. package/dist/chunk-NVLREB5R.js.map +1 -0
  52. package/dist/{chunk-AEUABPAJ.js → chunk-OTEVKRAH.js} +3 -3
  53. package/dist/{chunk-KQB2BX4V.js → chunk-P4A6D6HO.js} +4 -4
  54. package/dist/chunk-P7323YVW.js +134 -0
  55. package/dist/chunk-P7323YVW.js.map +1 -0
  56. package/dist/{chunk-4763KQ6O.js → chunk-PXRN2MER.js} +2 -2
  57. package/dist/{chunk-TR3HFH6J.js → chunk-QFSL2L2B.js} +67 -10
  58. package/dist/chunk-QFSL2L2B.js.map +1 -0
  59. package/dist/{chunk-2LYOAB2E.js → chunk-RRE74VWI.js} +2 -2
  60. package/dist/{chunk-LM2NHRCJ.js → chunk-SHGIDTYF.js} +4 -4
  61. package/dist/chunk-SHGIDTYF.js.map +1 -0
  62. package/dist/{chunk-ZQV7UQNE.js → chunk-SQ4FSNYL.js} +2 -2
  63. package/dist/{chunk-OCNSIBMP.js → chunk-T76MA2FH.js} +135 -1
  64. package/dist/{chunk-OCNSIBMP.js.map → chunk-T76MA2FH.js.map} +1 -1
  65. package/dist/{chunk-6R56OKJZ.js → chunk-UIITN37J.js} +2 -2
  66. package/dist/{chunk-CJ2RMTT7.js → chunk-VEIIUCYL.js} +2 -2
  67. package/dist/{chunk-SVAAONYB.js → chunk-WQLNUEZL.js} +12 -2
  68. package/dist/{chunk-SVAAONYB.js.map → chunk-WQLNUEZL.js.map} +1 -1
  69. package/dist/{chunk-WSJ7RO54.js → chunk-XED2DNAI.js} +23 -5
  70. package/dist/chunk-XED2DNAI.js.map +1 -0
  71. package/dist/{chunk-FSGICFXB.js → chunk-XG2A6GIG.js} +17 -5
  72. package/dist/chunk-XG2A6GIG.js.map +1 -0
  73. package/dist/{chunk-TO7DJYLZ.js → chunk-XY7YV2YQ.js} +178 -39
  74. package/dist/chunk-XY7YV2YQ.js.map +1 -0
  75. package/dist/{chunk-HTABUCF4.js → chunk-Y5BVR4HW.js} +3 -3
  76. package/dist/chunk-Y6FCXRIC.js +3972 -0
  77. package/dist/chunk-Y6FCXRIC.js.map +1 -0
  78. package/dist/{chunk-2SLED3NB.js → chunk-YDZ7XGPZ.js} +1 -1
  79. package/dist/{chunk-2SLED3NB.js.map → chunk-YDZ7XGPZ.js.map} +1 -1
  80. package/dist/{chunk-QF6IQQFP.js → chunk-Z7FDJ35L.js} +18 -12
  81. package/dist/chunk-Z7FDJ35L.js.map +1 -0
  82. package/dist/{chunk-5EEMTJMT.js → chunk-ZH5LOQET.js} +4 -4
  83. package/dist/cli/bin.js +49 -31
  84. package/dist/cli/bin.js.map +1 -1
  85. package/dist/cli/commands/audit.js +18 -18
  86. package/dist/client-L7NAUG6J.js +11 -0
  87. package/dist/configs/eslint.js +27 -1
  88. package/dist/configs/eslint.js.map +1 -1
  89. package/dist/configs/playwright-a11y.d.ts +11 -1
  90. package/dist/configs/playwright-a11y.js +3 -1
  91. package/dist/{convert-to-pnpm-YTFREUJK.js → convert-to-pnpm-4DON7IFS.js} +6 -6
  92. package/dist/{db-BBEM7CBX.js → db-5K4CDEGJ.js} +45 -20
  93. package/dist/db-5K4CDEGJ.js.map +1 -0
  94. package/dist/deadletter-G4M3HSVM.js +17 -0
  95. package/dist/{digest-65BOZ7AT.js → digest-5AJGWS33.js} +45 -22
  96. package/dist/digest-5AJGWS33.js.map +1 -0
  97. package/dist/{digest-collectors-EHFY2OQC.js → digest-collectors-MC5NWEUA.js} +12 -10
  98. package/dist/{digest-state-3KHZF7GD.js → digest-state-ZDPKR7MW.js} +18 -2
  99. package/dist/digest-state-ZDPKR7MW.js.map +1 -0
  100. package/dist/{ensure-site-WIDO6LGU.js → ensure-site-XESZHZEB.js} +39 -11
  101. package/dist/ensure-site-XESZHZEB.js.map +1 -0
  102. package/dist/{fleet-state-UEVZ3X3J.js → fleet-state-GJWGKTLD.js} +6 -4
  103. package/dist/{forms-notify-target-2LWF5C5B.js → forms-notify-target-4NPLX2UG.js} +5 -5
  104. package/dist/{freeze-A6MUCZU4.js → freeze-GUQ43EAU.js} +2 -2
  105. package/dist/{github-signals-GAHNOAHS.js → github-signals-DMU74SAX.js} +10 -10
  106. package/dist/{header-image-YN3AQRY3.js → header-image-BRTOLHJF.js} +6 -5
  107. package/dist/header-image-BRTOLHJF.js.map +1 -0
  108. package/dist/{health-endpoint-KVCJYWET.js → health-endpoint-UCLMSSCN.js} +8 -7
  109. package/dist/{health-endpoint-KVCJYWET.js.map → health-endpoint-UCLMSSCN.js.map} +1 -1
  110. package/dist/{health-mirror-EB76EERV.js → health-mirror-ONRT4DQ5.js} +7 -7
  111. package/dist/{import-airtable-TAKSN4NV.js → import-airtable-IIK2QUUF.js} +3 -3
  112. package/dist/index.d.ts +135 -4
  113. package/dist/index.js +68 -34
  114. package/dist/index.js.map +1 -1
  115. package/dist/{init-FFE4FCO2.js → init-3LMZTCVD.js} +19 -18
  116. package/dist/{init-FFE4FCO2.js.map → init-3LMZTCVD.js.map} +1 -1
  117. package/dist/{launch-4RVWPZGI.js → launch-XTDMC6LD.js} +209 -61
  118. package/dist/launch-XTDMC6LD.js.map +1 -0
  119. package/dist/{lighthouse-VHI77PFB.js → lighthouse-52WSPZ5D.js} +2 -2
  120. package/dist/{lighthouse-airtable-YMH4UA7R.js → lighthouse-airtable-3NOFB42B.js} +3 -3
  121. package/dist/{match-harness-2Q6ISEES.js → match-harness-OW45CB5H.js} +14 -7
  122. package/dist/match-harness-OW45CB5H.js.map +1 -0
  123. package/dist/migrate-NE3DUMTM.js +7 -0
  124. package/dist/{notify-IISTHNBR.js → notify-K3WVH3DM.js} +3 -3
  125. package/dist/{onboard-TM2KESGF.js → onboard-CZYBWVWH.js} +6 -6
  126. package/dist/{orchestrate-MWXENQOS.js → orchestrate-TGDFGY52.js} +8 -8
  127. package/dist/{parity-CAJ477PS.js → parity-AEKYSLN6.js} +5 -5
  128. package/dist/{pipeline-YROAY6DT.js → pipeline-UUPXOS2J.js} +3 -3
  129. package/dist/{preflight-4VQP7JJL.js → preflight-POZZI3GN.js} +9 -9
  130. package/dist/{prismic-ci-6GKKZGB5.js → prismic-ci-AHY5ULJR.js} +38 -8
  131. package/dist/prismic-ci-AHY5ULJR.js.map +1 -0
  132. package/dist/{prismic-models-W6CNLTWH.js → prismic-models-URYGODNY.js} +9 -9
  133. package/dist/prospect/types.d.ts +72 -0
  134. package/dist/prospect/types.js +1 -1
  135. package/dist/{prospect-audit-X4Z67LWW.js → prospect-audit-AJEE3Z4L.js} +16 -7
  136. package/dist/prospect-audit-AJEE3Z4L.js.map +1 -0
  137. package/dist/{prospect-audits-RVYEA4KH.js → prospect-audits-IZVQY3OQ.js} +12 -4
  138. package/dist/protection-audit-3J3UEOVT.js +413 -0
  139. package/dist/protection-audit-3J3UEOVT.js.map +1 -0
  140. package/dist/recipes/sync-configs.js +3 -3
  141. package/dist/{render-YGNGTX3W.js → render-MHUUHPLN.js} +36 -9
  142. package/dist/render-MHUUHPLN.js.map +1 -0
  143. package/dist/{render-from-row-IFSBHWDH.js → render-from-row-65DC6ZP3.js} +4 -4
  144. package/dist/{renovate-dispatch-U5Q52SKT.js → renovate-dispatch-ONJMBUHJ.js} +3 -3
  145. package/dist/{replay-PCSQOGCZ.js → replay-4FVFOMHH.js} +10 -23
  146. package/dist/replay-4FVFOMHH.js.map +1 -0
  147. package/dist/{report-4GFD4MW2.js → report-RC5LGPU3.js} +22 -20
  148. package/dist/report-RC5LGPU3.js.map +1 -0
  149. package/dist/{report-mirror-IP64R7B3.js → report-mirror-KKZZ6RMZ.js} +13 -9
  150. package/dist/{report-mirror-IP64R7B3.js.map → report-mirror-KKZZ6RMZ.js.map} +1 -1
  151. package/dist/{schema-2V7BGYVT.js → schema-KWUQEZ5O.js} +1 -1
  152. package/dist/schema-KWUQEZ5O.js.map +1 -0
  153. package/dist/{self-updating-MWXIZLL7.js → self-updating-RKKIYNV4.js} +8 -7
  154. package/dist/{self-updating-MWXIZLL7.js.map → self-updating-RKKIYNV4.js.map} +1 -1
  155. package/dist/{selftest-MAB2PBXQ.js → selftest-AFLR7YV7.js} +10 -10
  156. package/dist/site-lookup-DTOLJ4EA.js +27 -0
  157. package/dist/site-lookup-DTOLJ4EA.js.map +1 -0
  158. package/dist/{site-mirror-UKHREAD6.js → site-mirror-H2FYMN55.js} +23 -9
  159. package/dist/site-mirror-H2FYMN55.js.map +1 -0
  160. package/dist/{smoke-suite-7DGITCTZ.js → smoke-suite-BWHR3XX5.js} +8 -7
  161. package/dist/{smoke-suite-7DGITCTZ.js.map → smoke-suite-BWHR3XX5.js.map} +1 -1
  162. package/dist/{submissions-5GKJPCIH.js → submissions-II5J35QM.js} +4 -4
  163. package/dist/{submissions-3I3GAQAX.js → submissions-JDZP35JM.js} +49 -9
  164. package/dist/submissions-JDZP35JM.js.map +1 -0
  165. package/dist/{svelte-codemods-WSV2FGNG.js → svelte-codemods-QWGIRNN6.js} +6 -6
  166. package/dist/{sync-K3PKHVLH.js → sync-NKPDCMYW.js} +5 -5
  167. package/dist/{sync-configs-RRIHNLIJ.js → sync-configs-HGLQV55H.js} +6 -6
  168. package/dist/{upgrade-VTD6V4HS.js → upgrade-WZ44IARG.js} +6 -6
  169. package/dist/util/git.d.ts +48 -9
  170. package/dist/util/git.js +1 -1
  171. package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
  172. package/dist/{websites-TB4TCAWM.js → websites-6DHIHJ2D.js} +4 -2
  173. package/dist/{write-audits-to-airtable-53EVXKMU.js → write-audits-to-airtable-BIK6LNVC.js} +5 -5
  174. package/dist/write-audits-to-airtable-BIK6LNVC.js.map +1 -0
  175. package/package.json +3 -2
  176. package/dist/airtable-JGCPS7JH.js +0 -9
  177. package/dist/chunk-4AIYWWKV.js.map +0 -1
  178. package/dist/chunk-5UINJNAJ.js.map +0 -1
  179. package/dist/chunk-6VKYQATC.js +0 -1708
  180. package/dist/chunk-6VKYQATC.js.map +0 -1
  181. package/dist/chunk-7MKT4I5T.js +0 -62
  182. package/dist/chunk-7MKT4I5T.js.map +0 -1
  183. package/dist/chunk-AVEQUR44.js.map +0 -1
  184. package/dist/chunk-BVEDKMNG.js.map +0 -1
  185. package/dist/chunk-BYGPUUCT.js.map +0 -1
  186. package/dist/chunk-CWXTNPNZ.js.map +0 -1
  187. package/dist/chunk-DFKBTBO3.js.map +0 -1
  188. package/dist/chunk-FSGICFXB.js.map +0 -1
  189. package/dist/chunk-GASBX52O.js +0 -12
  190. package/dist/chunk-GASBX52O.js.map +0 -1
  191. package/dist/chunk-LM2NHRCJ.js.map +0 -1
  192. package/dist/chunk-QF6IQQFP.js.map +0 -1
  193. package/dist/chunk-TO7DJYLZ.js.map +0 -1
  194. package/dist/chunk-TR3HFH6J.js.map +0 -1
  195. package/dist/chunk-WJMPCVOR.js.map +0 -1
  196. package/dist/chunk-WSJ7RO54.js.map +0 -1
  197. package/dist/chunk-WVNDEYXL.js.map +0 -1
  198. package/dist/chunk-XHHMM5HX.js.map +0 -1
  199. package/dist/chunk-YLYXW5PH.js.map +0 -1
  200. package/dist/client-M2V6FHH5.js +0 -11
  201. package/dist/db-BBEM7CBX.js.map +0 -1
  202. package/dist/digest-65BOZ7AT.js.map +0 -1
  203. package/dist/digest-state-3KHZF7GD.js.map +0 -1
  204. package/dist/ensure-site-WIDO6LGU.js.map +0 -1
  205. package/dist/header-image-YN3AQRY3.js.map +0 -1
  206. package/dist/launch-4RVWPZGI.js.map +0 -1
  207. package/dist/match-harness-2Q6ISEES.js.map +0 -1
  208. package/dist/migrate-K4JETR36.js +0 -7
  209. package/dist/prismic-ci-6GKKZGB5.js.map +0 -1
  210. package/dist/prospect-audit-X4Z67LWW.js.map +0 -1
  211. package/dist/protection-audit-SJO53MEQ.js +0 -168
  212. package/dist/protection-audit-SJO53MEQ.js.map +0 -1
  213. package/dist/render-YGNGTX3W.js.map +0 -1
  214. package/dist/replay-PCSQOGCZ.js.map +0 -1
  215. package/dist/report-4GFD4MW2.js.map +0 -1
  216. package/dist/schema-2V7BGYVT.js.map +0 -1
  217. package/dist/site-mirror-UKHREAD6.js.map +0 -1
  218. package/dist/submissions-3I3GAQAX.js.map +0 -1
  219. /package/dist/{airtable-JGCPS7JH.js.map → airtable-SJV4ZSMS.js.map} +0 -0
  220. /package/dist/{announce-PGT4SZZB.js.map → announce-45HVP4J4.js.map} +0 -0
  221. /package/dist/{blux-4KNXUP2K.js.map → blux-RUFMNOUJ.js.map} +0 -0
  222. /package/dist/{bump-deps-C5G2EFBB.js.map → bump-deps-EL3TKMIL.js.map} +0 -0
  223. /package/dist/{chunk-XN37VTDB.js.map → chunk-37B4LVB3.js.map} +0 -0
  224. /package/dist/{chunk-WMBLR5VA.js.map → chunk-44SDGPGQ.js.map} +0 -0
  225. /package/dist/{chunk-22GKJO5F.js.map → chunk-6INOBNRO.js.map} +0 -0
  226. /package/dist/{chunk-5GUJ36TL.js.map → chunk-74QRWE6G.js.map} +0 -0
  227. /package/dist/{chunk-S7RYFUIK.js.map → chunk-CA6ADGOC.js.map} +0 -0
  228. /package/dist/{chunk-KZRR4NFG.js.map → chunk-D3CTOCUR.js.map} +0 -0
  229. /package/dist/{chunk-BCKEP5S6.js.map → chunk-ECDJDHKT.js.map} +0 -0
  230. /package/dist/{chunk-DMPHP7UP.js.map → chunk-EQQ4H6L3.js.map} +0 -0
  231. /package/dist/{chunk-Z2K4JPQ3.js.map → chunk-H445EH33.js.map} +0 -0
  232. /package/dist/{chunk-FUWFLAWQ.js.map → chunk-IMYL67JV.js.map} +0 -0
  233. /package/dist/{chunk-AEUABPAJ.js.map → chunk-OTEVKRAH.js.map} +0 -0
  234. /package/dist/{chunk-KQB2BX4V.js.map → chunk-P4A6D6HO.js.map} +0 -0
  235. /package/dist/{chunk-4763KQ6O.js.map → chunk-PXRN2MER.js.map} +0 -0
  236. /package/dist/{chunk-2LYOAB2E.js.map → chunk-RRE74VWI.js.map} +0 -0
  237. /package/dist/{chunk-ZQV7UQNE.js.map → chunk-SQ4FSNYL.js.map} +0 -0
  238. /package/dist/{chunk-6R56OKJZ.js.map → chunk-UIITN37J.js.map} +0 -0
  239. /package/dist/{chunk-CJ2RMTT7.js.map → chunk-VEIIUCYL.js.map} +0 -0
  240. /package/dist/{chunk-HTABUCF4.js.map → chunk-Y5BVR4HW.js.map} +0 -0
  241. /package/dist/{chunk-5EEMTJMT.js.map → chunk-ZH5LOQET.js.map} +0 -0
  242. /package/dist/{client-M2V6FHH5.js.map → client-L7NAUG6J.js.map} +0 -0
  243. /package/dist/{convert-to-pnpm-YTFREUJK.js.map → convert-to-pnpm-4DON7IFS.js.map} +0 -0
  244. /package/dist/{digest-collectors-EHFY2OQC.js.map → deadletter-G4M3HSVM.js.map} +0 -0
  245. /package/dist/{fleet-state-UEVZ3X3J.js.map → digest-collectors-MC5NWEUA.js.map} +0 -0
  246. /package/dist/{freeze-A6MUCZU4.js.map → fleet-state-GJWGKTLD.js.map} +0 -0
  247. /package/dist/{forms-notify-target-2LWF5C5B.js.map → forms-notify-target-4NPLX2UG.js.map} +0 -0
  248. /package/dist/{import-airtable-TAKSN4NV.js.map → freeze-GUQ43EAU.js.map} +0 -0
  249. /package/dist/{github-signals-GAHNOAHS.js.map → github-signals-DMU74SAX.js.map} +0 -0
  250. /package/dist/{health-mirror-EB76EERV.js.map → health-mirror-ONRT4DQ5.js.map} +0 -0
  251. /package/dist/{lighthouse-VHI77PFB.js.map → import-airtable-IIK2QUUF.js.map} +0 -0
  252. /package/dist/{lighthouse-airtable-YMH4UA7R.js.map → lighthouse-52WSPZ5D.js.map} +0 -0
  253. /package/dist/{migrate-K4JETR36.js.map → lighthouse-airtable-3NOFB42B.js.map} +0 -0
  254. /package/dist/{notify-IISTHNBR.js.map → migrate-NE3DUMTM.js.map} +0 -0
  255. /package/dist/{orchestrate-MWXENQOS.js.map → notify-K3WVH3DM.js.map} +0 -0
  256. /package/dist/{onboard-TM2KESGF.js.map → onboard-CZYBWVWH.js.map} +0 -0
  257. /package/dist/{parity-CAJ477PS.js.map → orchestrate-TGDFGY52.js.map} +0 -0
  258. /package/dist/{pipeline-YROAY6DT.js.map → parity-AEKYSLN6.js.map} +0 -0
  259. /package/dist/{prospect-audits-RVYEA4KH.js.map → pipeline-UUPXOS2J.js.map} +0 -0
  260. /package/dist/{preflight-4VQP7JJL.js.map → preflight-POZZI3GN.js.map} +0 -0
  261. /package/dist/{prismic-models-W6CNLTWH.js.map → prismic-models-URYGODNY.js.map} +0 -0
  262. /package/dist/{render-from-row-IFSBHWDH.js.map → prospect-audits-IZVQY3OQ.js.map} +0 -0
  263. /package/dist/{websites-TB4TCAWM.js.map → render-from-row-65DC6ZP3.js.map} +0 -0
  264. /package/dist/{renovate-dispatch-U5Q52SKT.js.map → renovate-dispatch-ONJMBUHJ.js.map} +0 -0
  265. /package/dist/{selftest-MAB2PBXQ.js.map → selftest-AFLR7YV7.js.map} +0 -0
  266. /package/dist/{submissions-5GKJPCIH.js.map → submissions-II5J35QM.js.map} +0 -0
  267. /package/dist/{svelte-codemods-WSV2FGNG.js.map → svelte-codemods-QWGIRNN6.js.map} +0 -0
  268. /package/dist/{sync-K3PKHVLH.js.map → sync-NKPDCMYW.js.map} +0 -0
  269. /package/dist/{sync-configs-RRIHNLIJ.js.map → sync-configs-HGLQV55H.js.map} +0 -0
  270. /package/dist/{upgrade-VTD6V4HS.js.map → upgrade-WZ44IARG.js.map} +0 -0
  271. /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
  272. /package/dist/{write-audits-to-airtable-53EVXKMU.js.map → websites-6DHIHJ2D.js.map} +0 -0
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-J7SZQUCW.js";
4
4
  import {
5
5
  withRecipe
6
- } from "./chunk-ZQV7UQNE.js";
6
+ } from "./chunk-SQ4FSNYL.js";
7
7
 
8
8
  // src/recipes/svelte-codemods.ts
9
9
  import { writeFile } from "fs/promises";
@@ -32,4 +32,4 @@ async function svelteCodemods(site) {
32
32
  export {
33
33
  svelteCodemods
34
34
  };
35
- //# sourceMappingURL=chunk-2LYOAB2E.js.map
35
+ //# sourceMappingURL=chunk-RRE74VWI.js.map
@@ -2,8 +2,9 @@ import {
2
2
  defaultSpawn
3
3
  } from "./chunk-LBYOLBW7.js";
4
4
  import {
5
+ isOwnerRepo,
5
6
  sameOwnerRepo
6
- } from "./chunk-AVEQUR44.js";
7
+ } from "./chunk-EASYCD6F.js";
7
8
 
8
9
  // src/cli/fleet/clone-if-needed.ts
9
10
  import { stat, readdir, mkdir } from "fs/promises";
@@ -61,11 +62,10 @@ async function assertCheckoutMatches(site, path, spawn) {
61
62
  );
62
63
  }
63
64
  }
64
- var GIT_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
65
65
  function resolveCloneUrl(site) {
66
66
  if (site.repoUrl) return site.repoUrl;
67
67
  if (!site.gitRepo) return void 0;
68
- if (!GIT_REPO_RE.test(site.gitRepo)) {
68
+ if (!isOwnerRepo(site.gitRepo)) {
69
69
  throw new Error(`unsafe gitRepo: expected "owner/repo" (got: ${JSON.stringify(site.gitRepo)})`);
70
70
  }
71
71
  return `https://github.com/${site.gitRepo}.git`;
@@ -150,4 +150,4 @@ export {
150
150
  appendSkipNotice,
151
151
  fleetWorkdir
152
152
  };
153
- //# sourceMappingURL=chunk-LM2NHRCJ.js.map
153
+ //# sourceMappingURL=chunk-SHGIDTYF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli/fleet/clone-if-needed.ts","../src/cli/fleet/prepare-sites.ts","../src/util/fleet-workdir.ts"],"sourcesContent":["import { stat, readdir, mkdir } from \"node:fs/promises\";\nimport { isAbsolute, join } from \"node:path\";\nimport type { Site } from \"../../types.js\";\nimport { defaultSpawn, type SpawnFn } from \"../../audits/util/spawn.js\";\nimport { isOwnerRepo, sameOwnerRepo } from \"../../util/git.js\";\n\nexport type CloneIfNeededOptions = {\n workdir: string;\n spawn?: SpawnFn;\n};\n\nfunction deriveNameFromRepoUrl(repoUrl: string): string {\n const slash = repoUrl.split(\"/\").pop() ?? repoUrl;\n return slash.replace(/\\.git$/, \"\");\n}\n\n/** Reject names that would let an inventory entry write outside `workdir`. */\nfunction assertSafeName(name: string): void {\n if (name.length === 0) {\n // `join(workdir, \"\")` collapses to the workdir root — the clone would land\n // there (and two empty-named sites would collide). An empty name is never a\n // legitimate checkout dir.\n throw new Error(\"unsafe site name (empty)\");\n }\n if (isAbsolute(name)) {\n throw new Error(`unsafe site name (absolute path not allowed): ${name}`);\n }\n if (name.includes(\"/\") || name.includes(\"\\\\\")) {\n throw new Error(`unsafe site name (path separator not allowed): ${name}`);\n }\n if (name.split(/[\\\\/]/).some((seg) => seg === \"..\")) {\n throw new Error(`unsafe site name (traversal segment not allowed): ${name}`);\n }\n}\n\n/**\n * Reject repo URLs that don't look like a normal clone target. Without this,\n * a `repoUrl` starting with `-` would be interpreted by git as a flag\n * (CVE-2017-1000117 family) — e.g. `--upload-pack=…` allows arbitrary command\n * execution. Inventory files are usually trusted, but `.mjs`/`.js` inventories\n * could pull from environments or external sources, so harden the boundary.\n *\n * Accepts: `https://`, `http://`, `ssh://`, `git://`, `file://`, and\n * `[user@]host:path` shorthand (e.g. `git@github.com:org/repo.git`).\n */\nfunction assertSafeRepoUrl(repoUrl: string): void {\n if (\n !/^(https?|ssh|git|file):\\/\\//.test(repoUrl) &&\n !/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:/.test(repoUrl)\n ) {\n throw new Error(\n `unsafe repoUrl: must start with a scheme (https://, ssh://, git://, file://) ` +\n `or use scp-style \"user@host:path\" (got: ${JSON.stringify(repoUrl)})`,\n );\n }\n}\n\nasync function isNonEmptyDir(path: string): Promise<boolean> {\n try {\n const s = await stat(path);\n if (!s.isDirectory()) return false;\n const entries = await readdir(path);\n return entries.length > 0;\n } catch {\n return false;\n }\n}\n\n/**\n * The repo identity (`owner/repo` or a clone URL) the site is *expected* to be.\n * `gitRepo` (Airtable's `owner/repo`) is canonical; fall back to `repoUrl`.\n * `undefined` when the inventory carries neither — there's nothing to verify\n * against, so a reused checkout can't be checked (we keep current behavior).\n */\nfunction expectedRepoRef(site: Site): string | undefined {\n return site.gitRepo ?? site.repoUrl ?? undefined;\n}\n\n/**\n * Verify an existing checkout at `path` is the SAME repo as the site expects.\n * The `siteSlug` that derives the path is lossy (distinct site names can\n * collapse to one slug → one path), so without this a fleet run could read,\n * audit, or commit against the WRONG repo's working tree.\n *\n * Reads `git -C <path> remote get-url origin` (via the injected spawn so tests\n * can fake it) and compares `owner/repo` on both sides, normalizing scheme,\n * host, `.git`, and case. Throws on a mismatch — never silently proceeds.\n * No-ops when the site carries no expected identity, or the dir isn't a git\n * checkout (no origin) — those are handled by the normal clone path.\n */\nasync function assertCheckoutMatches(site: Site, path: string, spawn: SpawnFn): Promise<void> {\n const expected = expectedRepoRef(site);\n if (!expected) return; // nothing to verify against\n\n const r = await spawn(\"git\", [\"-C\", path, \"remote\", \"get-url\", \"origin\"], {\n timeoutMs: 30_000,\n });\n // Not a git checkout / no origin remote → can't verify; let the clone path\n // (or the caller) deal with a non-repo directory rather than guessing.\n if (r.code !== 0) return;\n const origin = r.stdout.trim();\n if (origin.length === 0) return;\n\n if (!sameOwnerRepo(origin, expected)) {\n throw new Error(\n `checkout at ${path} is the wrong repo: origin is ${JSON.stringify(origin)} ` +\n `but site expects ${JSON.stringify(expected)} (slug collision?) — refusing to reuse it`,\n );\n }\n}\n\n/**\n * Resolve the URL to clone from. An explicit `repoUrl` wins; otherwise derive\n * one from `gitRepo` (`owner/repo` → `https://github.com/owner/repo.git`). The\n * Airtable inventory deliberately sets `gitRepo` and NOT `repoUrl` (a clone\n * source must never be the production `url`), so without this derivation every\n * checkout-based fleet recipe throws on the first site with an empty workdir.\n *\n * Returns `undefined` when neither is set; throws on a malformed `gitRepo`.\n */\nfunction resolveCloneUrl(site: Site): string | undefined {\n if (site.repoUrl) return site.repoUrl;\n if (!site.gitRepo) return undefined;\n // `isOwnerRepo` (util/git) is the ONE owner/repo validator (#724). The local\n // regex it replaces promised to block traversal but admitted `owner/..`,\n // because `.` is a legal repo character; the shared check rejects it outright.\n if (!isOwnerRepo(site.gitRepo)) {\n throw new Error(`unsafe gitRepo: expected \"owner/repo\" (got: ${JSON.stringify(site.gitRepo)})`);\n }\n return `https://github.com/${site.gitRepo}.git`;\n}\n\nexport async function cloneIfNeeded(site: Site, opts: CloneIfNeededOptions): Promise<Site> {\n const spawn = opts.spawn ?? defaultSpawn;\n\n if (await isNonEmptyDir(site.path)) {\n // Reusing an existing checkout: confirm it's actually this site's repo\n // before any audit/recipe operates on it (slug collisions can point two\n // sites at the same path). Throws on mismatch.\n await assertCheckoutMatches(site, site.path, spawn);\n return site;\n }\n\n const repoUrl = resolveCloneUrl(site);\n if (!repoUrl) {\n throw new Error(\n `site path does not exist (${site.path}) and no repoUrl or gitRepo is set — cannot clone`,\n );\n }\n\n const name = site.name || deriveNameFromRepoUrl(repoUrl); // `||`: empty name derives from the repo\n assertSafeName(name);\n assertSafeRepoUrl(repoUrl);\n const target = join(opts.workdir, name);\n await mkdir(opts.workdir, { recursive: true });\n\n if (await isNonEmptyDir(target)) {\n // Same guard for the workdir/<name> reuse path.\n await assertCheckoutMatches(site, target, spawn);\n return { ...site, name, path: target };\n }\n\n // `--` separator so git won't treat repoUrl as a flag if validation slips.\n const result = await spawn(\"git\", [\"clone\", \"--\", repoUrl, target], {\n cwd: opts.workdir,\n timeoutMs: 5 * 60_000,\n });\n if (result.code !== 0) {\n throw new Error(`git clone failed (code ${result.code}): ${result.stderr}`);\n }\n return { ...site, name, path: target };\n}\n","import type { Site } from \"../../types.js\";\nimport { cloneIfNeeded } from \"./clone-if-needed.js\";\n\n/** A fleet site that couldn't be prepared for a command, with the reason. */\nexport type SkippedSite = { site: string; reason: string };\nexport type FleetPrepResult = { prepared: Site[]; skipped: SkippedSite[] };\n\ntype CloneFn = (site: Site, opts: { workdir: string }) => Promise<Site>;\n\n/**\n * Prepare every fleet site for a command, tolerating per-site failures.\n *\n * A site is cloned into `workdir` unless `needsCheckout` says otherwise (the\n * default is \"always clone\" — recipe commands operate on a local checkout; the\n * audit command overrides this so a deployed-URL site skips the clone). The\n * critical property is ISOLATION: a per-site prep failure (no clone source,\n * clone error, the wrong-repo guard) is CAUGHT and recorded in `skipped` rather\n * than thrown. A single malformed inventory row can therefore never abort the\n * whole fleet run — a bare `Promise.all(map(cloneIfNeeded))` used to (one site\n * with no repo threw and rejected prep for EVERY site, so nothing ran). Each\n * site is prepared independently (parallel) so one slow/failed clone can't\n * serialize or sink the rest.\n *\n * The skipped-site label prefers `site.name` but uses a truthiness check (not\n * `??`) so an empty-string name (an Airtable row whose slug came out empty)\n * still falls back to the path instead of rendering blank.\n */\nexport async function prepareFleetSites(\n sites: Site[],\n opts: { workdir: string; needsCheckout?: (site: Site) => boolean; clone?: CloneFn },\n): Promise<FleetPrepResult> {\n const clone = opts.clone ?? cloneIfNeeded;\n const needsCheckout = opts.needsCheckout ?? (() => true);\n const settled = await Promise.all(\n sites.map(async (site): Promise<{ ok: true; site: Site } | ({ ok: false } & SkippedSite)> => {\n if (!needsCheckout(site)) return { ok: true, site };\n try {\n return { ok: true, site: await clone(site, { workdir: opts.workdir }) };\n } catch (e) {\n return { ok: false, site: site.name || site.path, reason: (e as Error).message };\n }\n }),\n );\n const prepared: Site[] = [];\n const skipped: SkippedSite[] = [];\n for (const r of settled) {\n if (r.ok) prepared.push(r.site);\n else skipped.push({ site: r.site, reason: r.reason });\n }\n return { prepared, skipped };\n}\n\n/** One-line operator notice for sites dropped during fleet prep; null when none\n * were skipped. The leading \"⚠ … site(s) skipped (could not prepare)\" token is\n * what the nightly fleet-lighthouse workflow greps to raise a `::warning::`, so\n * a tolerated skip stays visible in the Actions UI rather than buried. */\nexport function formatSkippedNotice(skipped: SkippedSite[]): string | null {\n if (skipped.length === 0) return null;\n const detail = skipped.map((s) => `${s.site} (${s.reason})`).join(\"; \");\n return `⚠ ${skipped.length} site(s) skipped (could not prepare): ${detail}`;\n}\n\n/** Append the skip notice to a command's output on a blank line, or return the\n * output unchanged when nothing was skipped. Keeps every fleet command's\n * return paths uniform. */\nexport function appendSkipNotice(output: string, skipped: SkippedSite[]): string {\n const notice = formatSkippedNotice(skipped);\n return notice ? `${output}\\n\\n${notice}` : output;\n}\n","import { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\n/** The default clone target for `--fleet` mode. Normally `~/.reddoor-maint/sites`.\n *\n * When `HOME` is unset or empty (cron, minimal CI containers) the old\n * `${HOME}/.reddoor-maint/sites` expression collapsed to `/.reddoor-maint/sites`\n * — a filesystem-root path the process almost never has permission to write,\n * and a dangerous one if it did. Fall back to a tmpdir-based path instead so a\n * HOME-less environment clones somewhere writable rather than at `/`.\n *\n * An explicit `--workdir` always wins; callers pass `opts.workdir ?? fleetWorkdir()`. */\nexport function fleetWorkdir(): string {\n const home = process.env.HOME?.trim();\n const base = home ? home : tmpdir();\n return join(base, \".reddoor-maint\", \"sites\");\n}\n"],"mappings":"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","names":["join"]}
@@ -10,7 +10,7 @@ import {
10
10
  deleteBranch,
11
11
  forceCheckoutBranch,
12
12
  isWorkingTreeClean
13
- } from "./chunk-AVEQUR44.js";
13
+ } from "./chunk-EASYCD6F.js";
14
14
 
15
15
  // src/recipes/_with-recipe.ts
16
16
  async function withRecipe(body) {
@@ -118,4 +118,4 @@ async function withRecipe(body) {
118
118
  export {
119
119
  withRecipe
120
120
  };
121
- //# sourceMappingURL=chunk-ZQV7UQNE.js.map
121
+ //# sourceMappingURL=chunk-SQ4FSNYL.js.map
@@ -383,6 +383,140 @@ var MIGRATIONS = [
383
383
  CREATE INDEX IF NOT EXISTS idx_prospect_audits_site_key
384
384
  ON prospect_audits (site_key, created_at DESC);
385
385
  `
386
+ },
387
+ {
388
+ // Operator edits on a prospect report. One JSON blob rather than a side
389
+ // table: the map is always read and written whole, and no query wants a
390
+ // single override in isolation. Same shape of decision as `result_json`.
391
+ //
392
+ // THREE MIGRATIONS, NOT ONE. Verified against migrate.ts: it runs
393
+ // `executeMultiple(m.sql)` inside a try, swallows any "duplicate column
394
+ // name" error, then records the marker unconditionally. That recovery is
395
+ // sound for ONE statement. With three, a crash partway through leaves the
396
+ // marker unwritten; the re-run's first statement then throws duplicate,
397
+ // executeMultiple aborts before the later statements, the catch swallows
398
+ // it, and the marker IS written — leaving the remaining columns
399
+ // permanently missing on a migration recorded as applied. Same rule as
400
+ // 0003 and 0013.
401
+ id: "0015_prospect_audits_overrides",
402
+ sql: `ALTER TABLE prospect_audits ADD COLUMN overrides_json TEXT;`
403
+ },
404
+ {
405
+ id: "0016_prospect_audits_edited_at",
406
+ sql: `ALTER TABLE prospect_audits ADD COLUMN edited_at TEXT;`
407
+ },
408
+ {
409
+ id: "0017_prospect_audits_opened_at",
410
+ sql: `ALTER TABLE prospect_audits ADD COLUMN opened_at TEXT;`
411
+ },
412
+ {
413
+ // #783. Resend's `email.bounced` payload carries `data.bounce = { message,
414
+ // subType, type }`; the webhook read only `data.email_id`, so the
415
+ // classification was dropped and a dead mailbox stored identically to a
416
+ // receiving server refusing our CONTENT. Espada sat under a CRITICAL
417
+ // "check the point-of-contact address" for three days in September 2026
418
+ // over an address that was fine — their inbound filter was refusing lead
419
+ // notifications whose spam_score (30 and 55) sat under our auto-filter line.
420
+ //
421
+ // Nullable and never backfilled: every row written before this migration
422
+ // has no classification, and `permanent` is counted only where the payload
423
+ // actually SAID so. Inferring a diagnosis for the historical rows would
424
+ // re-manufacture the exact wrong answer this change exists to stop.
425
+ //
426
+ // FOUR MIGRATIONS, NOT ONE — the same rule 0003, 0013 and 0015 carry, and
427
+ // for the reason spelled out on 0015: `migrate.ts` swallows "duplicate
428
+ // column name" and then records the marker unconditionally, which is sound
429
+ // for ONE statement. Batched, a crash partway leaves the marker unwritten,
430
+ // the re-run's first statement throws duplicate, `executeMultiple` aborts
431
+ // before the rest, the catch swallows it and the marker IS written —
432
+ // leaving the remaining columns permanently missing on a migration recorded
433
+ // as applied.
434
+ id: "0018_submissions_bounce_type",
435
+ sql: `ALTER TABLE submissions ADD COLUMN bounce_type TEXT;`
436
+ },
437
+ {
438
+ id: "0019_submissions_bounce_subtype",
439
+ sql: `ALTER TABLE submissions ADD COLUMN bounce_subtype TEXT;`
440
+ },
441
+ {
442
+ id: "0020_submissions_bounce_message",
443
+ sql: `ALTER TABLE submissions ADD COLUMN bounce_message TEXT;`
444
+ },
445
+ {
446
+ // #783 item 3: the operator's exit. Before this, a diagnosed false alarm
447
+ // could only be cleared by 14 days of aging or by a hand-written UPDATE
448
+ // against the production `submissions` table — which is what actually
449
+ // happened on 2026-09-14, flipping the two in-window rows from `bounced`
450
+ // back to `sent` and destroying the evidence in the process.
451
+ //
452
+ // A stamp on the ROW rather than an `ack_until` on `sites` (the issue
453
+ // offers both). The row marker clears the alarm EXACTLY — only the bounces
454
+ // the operator actually looked at stop counting — where a site-level
455
+ // window would mute every future bounce for its whole duration, including
456
+ // a genuinely dead address appearing the next day. It also keeps the bounce
457
+ // record intact, which the production workaround did not.
458
+ id: "0021_submissions_bounce_ack_at",
459
+ sql: `ALTER TABLE submissions ADD COLUMN bounce_ack_at TEXT;`
460
+ },
461
+ {
462
+ // #786, the other half of #785. That PR made `unknown-site` NON-terminal on
463
+ // replay so both recovery orders are safe (`ensure-site` then replay, or
464
+ // replay then `ensure-site`) — without it, replay-before-heal silently burns
465
+ // every queued lead, and that is the order a person reaches for first.
466
+ //
467
+ // The cost, named at the time: a slug that is genuinely dead but still
468
+ // deployed and posting grows the queue without bound, holds
469
+ // `db replay-deadletters` at exit 1, and leaves a standing CRITICAL cockpit
470
+ // item with no escape short of deleting rows by hand.
471
+ //
472
+ // `abandoned` is that escape — resolved by DECISION rather than by outcome.
473
+ // Deliberately NOT reusing `replayed_at`/`replay_outcome`: those mean "the
474
+ // pipeline gave this lead an answer", and a row marked replayed reads as a
475
+ // lead that was placed, which is the opposite of what happened to it.
476
+ //
477
+ // who/when/why are three columns because the decision has to outlive the
478
+ // person who made it; a bare timestamp would leave the next operator
479
+ // guessing why a client's lead was written off.
480
+ //
481
+ // THREE MIGRATIONS, NOT ONE — the rule 0003, 0013 and 0015 already carry:
482
+ // `migrate.ts` swallows "duplicate column name" then records the marker
483
+ // unconditionally, which is only sound for a single statement.
484
+ id: "0022_deadletter_abandoned_at",
485
+ sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_at TEXT;`
486
+ },
487
+ {
488
+ id: "0023_deadletter_abandoned_by",
489
+ sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_by TEXT;`
490
+ },
491
+ {
492
+ id: "0024_deadletter_abandoned_reason",
493
+ sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_reason TEXT;`
494
+ },
495
+ {
496
+ // #676. The searches behind "Where you stand" were chosen entirely by the
497
+ // analyze stage from what it read on the site. For a client we know, better
498
+ // ones can be written in two minutes — and a comparison over time needs the
499
+ // terms to stay FIXED, which a model re-reading the site cannot promise.
500
+ //
501
+ // Stored on the ROW rather than only inside `result_json` for the two
502
+ // reasons the issue names: a re-run can reuse them, and the /audits listing
503
+ // can show which audits used chosen terms — that listing deliberately never
504
+ // selects `result_json` (large, and useless to a list), so a value only
505
+ // present in the blob could not reach it.
506
+ //
507
+ // JSON arrays in TEXT, NULL when nothing was chosen. Null is the load-
508
+ // bearing state: it means the audit chose its own, which is not the same as
509
+ // an operator supplying an empty list.
510
+ //
511
+ // TWO MIGRATIONS, NOT ONE — the rule 0003, 0013, 0015 and 0018 carry:
512
+ // `migrate.ts` swallows "duplicate column name" and then records the marker
513
+ // unconditionally, which is sound only for a single statement.
514
+ id: "0025_prospect_audits_chosen_terms",
515
+ sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_terms TEXT;`
516
+ },
517
+ {
518
+ id: "0026_prospect_audits_chosen_questions",
519
+ sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_questions TEXT;`
386
520
  }
387
521
  ];
388
522
 
@@ -417,4 +551,4 @@ async function runMigrations(client) {
417
551
  export {
418
552
  runMigrations
419
553
  };
420
- //# sourceMappingURL=chunk-OCNSIBMP.js.map
554
+ //# sourceMappingURL=chunk-T76MA2FH.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/db/migrations.ts","../src/db/migrate.ts"],"sourcesContent":["/** Ordered, append-only list of standard-SQL migration scripts. Each runs once,\n * tracked by `id` in the `_migrations` table (see migrate.ts). Statements use\n * IF NOT EXISTS so even a partial re-apply is safe. Never edit a shipped script —\n * add a new one. Every statement in a script MUST be independently idempotent:\n * `migrate.ts` runs each script via `executeMultiple`, which is NOT transactional,\n * so a mid-script failure can leave earlier statements applied and the id unrecorded\n * — a re-run then re-executes the whole script. Standard SQLite SQL only (no\n * Turso-specific syntax) so the host stays a connection-string swap. */\nexport type Migration = { id: string; sql: string };\n\nexport const MIGRATIONS: Migration[] = [\n {\n id: \"0001_init\",\n sql: `\n CREATE TABLE IF NOT EXISTS submissions (\n id TEXT PRIMARY KEY,\n submission_id INTEGER,\n site_id TEXT NOT NULL,\n form_type TEXT NOT NULL,\n name TEXT NOT NULL,\n email TEXT NOT NULL,\n phone TEXT,\n message TEXT,\n extra_fields TEXT,\n source_url TEXT,\n utm TEXT,\n submitted_at TEXT,\n status TEXT NOT NULL DEFAULT 'new',\n notify_status TEXT NOT NULL DEFAULT 'skipped',\n resend_message_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted\n ON submissions (site_id, submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_status\n ON submissions (status);\n CREATE TABLE IF NOT EXISTS spam_screenouts (\n site_id TEXT NOT NULL,\n date TEXT NOT NULL,\n honeypot INTEGER NOT NULL DEFAULT 0,\n too_fast INTEGER NOT NULL DEFAULT 0,\n marked_spam INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (site_id, date)\n );\n `,\n },\n {\n id: \"0002_fleet_events\",\n sql: `\n CREATE TABLE IF NOT EXISTS fleet_events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n type TEXT NOT NULL,\n site_id TEXT,\n site_name TEXT,\n summary TEXT NOT NULL,\n data TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events (ts);\n `,\n },\n {\n id: \"0003_add_spam_score\",\n // SQLite `ADD COLUMN` has no `IF NOT EXISTS`, so it is NOT self-idempotent: if the\n // `_migrations` marker is lost after the column is added (crash between statement\n // and marker write), a re-run would throw `duplicate column name`. `migrate.ts`\n // recognizes that error as already-applied and records the marker, keeping the\n // runner idempotent (see `isAlreadyAppliedError`).\n sql: `ALTER TABLE submissions ADD COLUMN spam_score REAL;`,\n },\n {\n id: \"0004_add_spam_reason\",\n sql: `ALTER TABLE submissions ADD COLUMN spam_reason TEXT;`,\n },\n {\n // Outcome of the newsletter fan-out (site webhook + Mailchimp). Before this the\n // results were console.error-only, so a rotated Mailchimp key or an outage would\n // silently stop signups reaching the audience with no trace anywhere the operator\n // looks — the row still read `notify=sent`, healthy. Same non-idempotent\n // ADD COLUMN caveat as 0003.\n id: \"0005_add_fanout_status\",\n sql: `ALTER TABLE submissions ADD COLUMN fanout_status TEXT;`,\n },\n {\n // Phase 0 of the Airtable → Turso migration (#539): a lead whose SITE LOOKUP\n // fails must still land somewhere durable. On 2026-08-17 the Airtable quota\n // outage made `getWebsiteBySlug` throw before `createSubmission` ran, so every\n // lead in the window 502'd away unrecorded — while THIS store (which holds\n // submissions) was healthy the whole time. Rows here are written only on that\n // path and replayed through the normal pipeline once the lookup recovers\n // (`db replay-deadletters`), producing an ordinary submissions row with real\n // spam classification and notify. Deliberately a separate table rather than a\n // nullable `submissions.site_id`: site_id is NOT NULL, every reader assumes a\n // real rec id, and a sentinel would collapse dashboard grouping (the \"\" key\n // hazard audit.ts already documents).\n //\n // `turnstile` stores the verification COMPUTED AT RECEIPT ({outcome, hostname}\n // JSON) — tokens expire in 300s, so replay could never re-verify; it replays\n // with the answer we already had.\n id: \"0006_submission_deadletter\",\n sql: `\n CREATE TABLE IF NOT EXISTS submission_deadletter (\n id TEXT PRIMARY KEY,\n site_slug TEXT NOT NULL,\n payload TEXT NOT NULL,\n turnstile TEXT NOT NULL,\n error TEXT NOT NULL,\n received_at TEXT NOT NULL,\n replayed_at TEXT,\n replay_outcome TEXT,\n replay_submission_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_deadletter_unreplayed\n ON submission_deadletter (replayed_at);\n `,\n },\n {\n // Phase 1.2 of the Airtable → Turso migration (#539): the fleet-state tables.\n // Split by WRITER, not topic (design D2, confirmed by the 2026-08-23 writer\n // map — every code-written column has exactly one writer):\n // sites — operator (dashboard editor / console / launch flow)\n // site_health — the nightly audit write-back, one batched upsert\n // site_schedule — updateNextDueDates (report cron, derived)\n // reports — report drafting + the operator approve flow\n // PKs are the Airtable `rec…` ids (design D1): 278+ submissions rows already\n // reference them, and the parity harness diffs the two stores row-for-row.\n //\n // Naming: snake_case throughout; Airtable's misspellings (\"maintenence\") and\n // display quirks die here. `sites.legacy` is a JSON object holding the 33\n // populated-but-code-unreferenced columns (launch-era checklist, hosting\n // reference cells) keyed by their original Airtable column name — data kept,\n // schema not fossilized. The plaintext DNS/cms credential cells deliberately\n // do NOT migrate at all (operator ruling 2026-08-23); they live on only in\n // the frozen base. `site_health.analytics_soft_fail_at` is the column code\n // always wanted (\"Analytics soft-fail at\") but no operator ever created in\n // Airtable — it ships real here.\n //\n // reports.checklist is JSON keyed by the STABLE checklist key (\"deploy\",\n // \"cms\", …, from src/reports/checklist.ts), not the Airtable column name —\n // the importer translates, so \"Test: Verified After Updates\" stops leaking\n // its legacy name into a second store.\n id: \"0007_fleet_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS sites (\n id TEXT PRIMARY KEY,\n slug TEXT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n url TEXT,\n status TEXT,\n point_of_contact TEXT,\n maintenance_freq TEXT,\n testing_freq TEXT,\n maintenance_day TEXT,\n testing_day TEXT,\n ga4_property_id TEXT,\n search_query TEXT,\n search_console_property TEXT,\n git_repo TEXT,\n netlify_id TEXT,\n report_recipients_to TEXT,\n report_recipients_cc TEXT,\n copy_intro TEXT,\n copy_contact TEXT,\n copy_footer TEXT,\n newsletter_webhook TEXT,\n mailchimp_api_key TEXT,\n mailchimp_audience_id TEXT,\n notify_routing TEXT,\n require_turnstile INTEGER NOT NULL DEFAULT 0,\n accepted_watch_conditions TEXT,\n prismic_ack_until TEXT,\n launched_at TEXT,\n header_image BLOB,\n header_image_filename TEXT,\n header_image_type TEXT,\n header_image_generated_at TEXT,\n legacy TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_sites_status ON sites (status);\n\n CREATE TABLE IF NOT EXISTS site_health (\n site_id TEXT PRIMARY KEY,\n p_score INTEGER,\n r_score INTEGER,\n bp_score INTEGER,\n seo_score INTEGER,\n lighthouse_at TEXT,\n a11y_violations INTEGER,\n deps_drifted INTEGER,\n deps_major_behind INTEGER,\n deps_outdated INTEGER,\n deps_major_outdated INTEGER,\n vulns_critical INTEGER,\n vulns_high INTEGER,\n vulns_moderate INTEGER,\n vulns_low INTEGER,\n security_audit_at TEXT,\n security_advisories TEXT,\n auto_fix_attempts INTEGER,\n analytics_soft_fail_at TEXT,\n cert_days_remaining INTEGER,\n domain_checked_at TEXT,\n deploy_status TEXT,\n last_deploy_at TEXT,\n deploy_log_url TEXT,\n deploy_checked_at TEXT,\n function_health TEXT,\n cms_reachable TEXT,\n turnstile_widget TEXT,\n function_health_checked_at TEXT,\n crossbrowser_ok INTEGER,\n mobile_ok INTEGER,\n links_ok INTEGER,\n broken_links INTEGER,\n browser_checked_at TEXT,\n uptime_reachable TEXT,\n titles_meta_ok TEXT,\n smoke_ok TEXT,\n last_smoke_at TEXT,\n form_e2e_ok TEXT,\n form_e2e_checked_at TEXT,\n renovate_failing_cis INTEGER,\n default_branch_ci TEXT,\n last_commit_at TEXT,\n github_signals_at TEXT,\n prismic_models TEXT,\n prismic_models_checked_at TEXT,\n prismic_models_drift TEXT\n );\n\n CREATE TABLE IF NOT EXISTS site_schedule (\n site_id TEXT PRIMARY KEY,\n next_maintenance_at TEXT,\n next_testing_at TEXT,\n computed_at TEXT\n );\n\n CREATE TABLE IF NOT EXISTS reports (\n id TEXT PRIMARY KEY,\n site_id TEXT,\n report_id TEXT,\n report_type TEXT,\n period TEXT,\n period_start TEXT,\n period_end TEXT,\n completed_on TEXT,\n lighthouse_performance INTEGER,\n lighthouse_accessibility INTEGER,\n lighthouse_best_practices INTEGER,\n lighthouse_seo INTEGER,\n ga_users_current INTEGER,\n ga_users_previous INTEGER,\n search_found_page1 INTEGER,\n search_position REAL,\n last_tested_date TEXT,\n commentary TEXT,\n subject_override TEXT,\n draft_ready INTEGER NOT NULL DEFAULT 0,\n approved_to_send INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n approved_by TEXT,\n send_override INTEGER NOT NULL DEFAULT 0,\n override_reason TEXT,\n override_by TEXT,\n override_at TEXT,\n sent_at TEXT,\n delivery_status TEXT,\n resend_message_id TEXT,\n checklist TEXT,\n checklist_auto_evidence TEXT,\n rendered_html TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_reports_site ON reports (site_id, period_start DESC);\n `,\n },\n {\n // Indexes the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts) proved the\n // hot paths need — each serves a named query shape that otherwise full-scans\n // submissions, the one unbounded-growth table:\n // submitted_at — every windowed read (/submissions default page, digest\n // per-site counts, duplicate/repeat-sender scans)\n // resend_message_id — the resend-webhook bounce lookup (markNotifyBouncedByMessageId)\n // submission_id — createSubmission's MAX(submission_id)+1 display number,\n // O(1) off the index instead of a scan per insert\n // spam_reason (partial, covering) — listSpamReasonsFiltered's facet tally\n // reads ONLY spam_reason WHERE spam_reason IS NOT NULL;\n // partial keeps it to the spam subset\n // spam_screenouts(date) — listScreenOutsSince windows on date, which the\n // (site_id, date) PK can't serve\n id: \"0008_query_plan_indexes\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_submitted\n ON submissions (submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_spam_reason\n ON submissions (spam_reason) WHERE spam_reason IS NOT NULL;\n CREATE INDEX IF NOT EXISTS idx_submissions_resend_message\n ON submissions (resend_message_id);\n CREATE INDEX IF NOT EXISTS idx_submissions_display_number\n ON submissions (submission_id);\n CREATE INDEX IF NOT EXISTS idx_screenouts_date\n ON spam_screenouts (date);\n `,\n },\n {\n // Prospect audits (spec 2026-08-24): one row per `prospect-audit` CLI run.\n // `token` is the unguessable public handle for GET /r/{token}; `result_json`\n // is the full ProspectAuditResult, re-rendered on every request so report\n // styling updates apply to already-shared links.\n id: \"0009_prospect_audits\",\n sql: `\n CREATE TABLE IF NOT EXISTS prospect_audits (\n id TEXT PRIMARY KEY,\n token TEXT NOT NULL UNIQUE,\n url TEXT NOT NULL,\n business TEXT,\n created_at TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'complete',\n result_json TEXT NOT NULL\n );\n `,\n },\n {\n // Cockpit /audits listing page (spec 2026-08-25, Piece 5):\n // listRecentProspectAudits orders by created_at DESC with no WHERE clause —\n // without this index that's a raw SCAN of prospect_audits followed by a temp\n // b-tree sort, which the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts)\n // fails the build on. DESC matches the query's own ORDER BY so SQLite can walk\n // the index directly instead of sorting.\n id: \"0010_prospect_audits_created_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_created\n ON prospect_audits (created_at DESC);\n `,\n },\n {\n // #609 (#539 Phase 5): the digest's prior-run snapshot moves off Airtable.\n // Unlike the rest of Phase 5 this is a MIGRATION, not a dual-write — there\n // is no Airtable counterpart left afterwards, so parity does not cover it.\n //\n // A single row holding the whole snapshot as JSON, keyed by a constant id.\n // Both readers (runDigest's diff and the fleet homepage's NEW badges) need\n // the ENTIRE map, so a keyed table would buy nothing on reads and its\n // \"give me everything\" query would be a raw scan needing a justified entry\n // in the EXPLAIN gate's allowlist. One row by primary key needs neither.\n id: \"0011_digest_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS digest_state (\n id TEXT PRIMARY KEY,\n snapshot TEXT NOT NULL,\n updated_at TEXT\n );\n `,\n },\n {\n // form_type was the one SubmissionFilter key with an equality predicate and no\n // index — countSubmissionsFiltered({formType}) raw-scanned `submissions`, twice\n // per load of the submissions page. It shipped green because the EXPLAIN gate\n // named its scenarios by hand and never wrote one for this shape; the gate is\n // now driven by a filter matrix, which is what surfaced it.\n //\n // `submissions` is the one unbounded-growth table in the schema — append-only,\n // one row per fleet lead forever — and Turso meters ROW SCANS, so this is the\n // cost that grows without a ceiling.\n id: \"0012_submissions_form_type_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_form_type\n ON submissions (form_type);\n `,\n },\n {\n // `url` is stored exactly as audited, which makes it a record of the run and\n // a bad thing to group by: the table already holds beachfrontdentistry.com\n // and beachfrontdentistry.com/ as two histories of one practice, and both\n // spellings of ludlowkingsley.com. That costs nothing today because nothing\n // compares two audits of one site — and becomes a returning client with no\n // history the day the report offers a before and an after.\n //\n // Deliberately no SQL backfill. Doing it with lower()/replace()/rtrim()\n // would mangle any URL containing \"www.\" or \"http://\" away from its start,\n // and a key that is quietly wrong is worse than one that is null. Existing\n // rows are backfilled once with the real siteKey().\n // Same ADD COLUMN caveat as 0003: no IF NOT EXISTS in SQLite, so the runner\n // treats \"duplicate column name\" as already-applied. Kept as its own\n // migration, separate from its index below, so that recovery path stays a\n // single statement.\n id: \"0013_prospect_audits_site_key\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN site_key TEXT;`,\n },\n {\n id: \"0014_prospect_audits_site_key_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_site_key\n ON prospect_audits (site_key, created_at DESC);\n `,\n },\n];\n","import type { Client } from \"@libsql/client\";\nimport { MIGRATIONS } from \"./migrations.js\";\n\n/** True when an error means \"this DDL was already applied\" and re-applying is a\n * no-op we can safely ignore. `CREATE ... IF NOT EXISTS` never throws, but SQLite\n * `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, so a re-run after a lost\n * `_migrations` marker throws `duplicate column name: …`. Treating that as\n * already-applied keeps `runMigrations` idempotent for `ADD COLUMN` migrations —\n * without it a lost marker would boot-loop `openDb()` on every call. */\nfunction isAlreadyAppliedError(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /duplicate column name/i.test(message);\n}\n\n/** Apply every not-yet-applied migration in order against a raw libSQL client,\n * tracking applied ids in `_migrations`. Idempotent: ids already present are\n * skipped, the DDL uses IF NOT EXISTS where it can, and an already-applied\n * `ADD COLUMN` (recognized by its error) is swallowed so the marker is still\n * recorded. Returns the ids applied this run. Runs on every openDb() — cheap\n * (one indexed SELECT) and the gate every fresh Turso database needs before its\n * first write. */\nexport async function runMigrations(client: Client): Promise<string[]> {\n await client.execute(\n \"CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)\",\n );\n const existing = await client.execute(\"SELECT id FROM _migrations\");\n const applied = new Set(existing.rows.map((r) => String(r.id)));\n const ran: string[] = [];\n for (const m of MIGRATIONS) {\n if (applied.has(m.id)) continue;\n try {\n await client.executeMultiple(m.sql);\n } catch (err) {\n // A re-run after a lost marker can re-execute a non-idempotent `ADD COLUMN`;\n // treat \"already applied\" as success so we still record the marker below and\n // never boot-loop. Any other error propagates unchanged.\n if (!isAlreadyAppliedError(err)) throw err;\n }\n await client.execute({\n sql: \"INSERT INTO _migrations (id, applied_at) VALUES (?, ?)\",\n args: [m.id, new Date().toISOString()],\n });\n ran.push(m.id);\n }\n return ran;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/db/migrations.ts","../src/db/migrate.ts"],"sourcesContent":["/** Ordered, append-only list of standard-SQL migration scripts. Each runs once,\n * tracked by `id` in the `_migrations` table (see migrate.ts). Statements use\n * IF NOT EXISTS so even a partial re-apply is safe. Never edit a shipped script —\n * add a new one. Every statement in a script MUST be independently idempotent:\n * `migrate.ts` runs each script via `executeMultiple`, which is NOT transactional,\n * so a mid-script failure can leave earlier statements applied and the id unrecorded\n * — a re-run then re-executes the whole script. Standard SQLite SQL only (no\n * Turso-specific syntax) so the host stays a connection-string swap. */\nexport type Migration = { id: string; sql: string };\n\nexport const MIGRATIONS: Migration[] = [\n {\n id: \"0001_init\",\n sql: `\n CREATE TABLE IF NOT EXISTS submissions (\n id TEXT PRIMARY KEY,\n submission_id INTEGER,\n site_id TEXT NOT NULL,\n form_type TEXT NOT NULL,\n name TEXT NOT NULL,\n email TEXT NOT NULL,\n phone TEXT,\n message TEXT,\n extra_fields TEXT,\n source_url TEXT,\n utm TEXT,\n submitted_at TEXT,\n status TEXT NOT NULL DEFAULT 'new',\n notify_status TEXT NOT NULL DEFAULT 'skipped',\n resend_message_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted\n ON submissions (site_id, submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_status\n ON submissions (status);\n CREATE TABLE IF NOT EXISTS spam_screenouts (\n site_id TEXT NOT NULL,\n date TEXT NOT NULL,\n honeypot INTEGER NOT NULL DEFAULT 0,\n too_fast INTEGER NOT NULL DEFAULT 0,\n marked_spam INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (site_id, date)\n );\n `,\n },\n {\n id: \"0002_fleet_events\",\n sql: `\n CREATE TABLE IF NOT EXISTS fleet_events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n type TEXT NOT NULL,\n site_id TEXT,\n site_name TEXT,\n summary TEXT NOT NULL,\n data TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events (ts);\n `,\n },\n {\n id: \"0003_add_spam_score\",\n // SQLite `ADD COLUMN` has no `IF NOT EXISTS`, so it is NOT self-idempotent: if the\n // `_migrations` marker is lost after the column is added (crash between statement\n // and marker write), a re-run would throw `duplicate column name`. `migrate.ts`\n // recognizes that error as already-applied and records the marker, keeping the\n // runner idempotent (see `isAlreadyAppliedError`).\n sql: `ALTER TABLE submissions ADD COLUMN spam_score REAL;`,\n },\n {\n id: \"0004_add_spam_reason\",\n sql: `ALTER TABLE submissions ADD COLUMN spam_reason TEXT;`,\n },\n {\n // Outcome of the newsletter fan-out (site webhook + Mailchimp). Before this the\n // results were console.error-only, so a rotated Mailchimp key or an outage would\n // silently stop signups reaching the audience with no trace anywhere the operator\n // looks — the row still read `notify=sent`, healthy. Same non-idempotent\n // ADD COLUMN caveat as 0003.\n id: \"0005_add_fanout_status\",\n sql: `ALTER TABLE submissions ADD COLUMN fanout_status TEXT;`,\n },\n {\n // Phase 0 of the Airtable → Turso migration (#539): a lead whose SITE LOOKUP\n // fails must still land somewhere durable. On 2026-08-17 the Airtable quota\n // outage made `getWebsiteBySlug` throw before `createSubmission` ran, so every\n // lead in the window 502'd away unrecorded — while THIS store (which holds\n // submissions) was healthy the whole time. Rows here are written only on that\n // path and replayed through the normal pipeline once the lookup recovers\n // (`db replay-deadletters`), producing an ordinary submissions row with real\n // spam classification and notify. Deliberately a separate table rather than a\n // nullable `submissions.site_id`: site_id is NOT NULL, every reader assumes a\n // real rec id, and a sentinel would collapse dashboard grouping (the \"\" key\n // hazard audit.ts already documents).\n //\n // `turnstile` stores the verification COMPUTED AT RECEIPT ({outcome, hostname}\n // JSON) — tokens expire in 300s, so replay could never re-verify; it replays\n // with the answer we already had.\n id: \"0006_submission_deadletter\",\n sql: `\n CREATE TABLE IF NOT EXISTS submission_deadletter (\n id TEXT PRIMARY KEY,\n site_slug TEXT NOT NULL,\n payload TEXT NOT NULL,\n turnstile TEXT NOT NULL,\n error TEXT NOT NULL,\n received_at TEXT NOT NULL,\n replayed_at TEXT,\n replay_outcome TEXT,\n replay_submission_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_deadletter_unreplayed\n ON submission_deadletter (replayed_at);\n `,\n },\n {\n // Phase 1.2 of the Airtable → Turso migration (#539): the fleet-state tables.\n // Split by WRITER, not topic (design D2, confirmed by the 2026-08-23 writer\n // map — every code-written column has exactly one writer):\n // sites — operator (dashboard editor / console / launch flow)\n // site_health — the nightly audit write-back, one batched upsert\n // site_schedule — updateNextDueDates (report cron, derived)\n // reports — report drafting + the operator approve flow\n // PKs are the Airtable `rec…` ids (design D1): 278+ submissions rows already\n // reference them, and the parity harness diffs the two stores row-for-row.\n //\n // Naming: snake_case throughout; Airtable's misspellings (\"maintenence\") and\n // display quirks die here. `sites.legacy` is a JSON object holding the 33\n // populated-but-code-unreferenced columns (launch-era checklist, hosting\n // reference cells) keyed by their original Airtable column name — data kept,\n // schema not fossilized. The plaintext DNS/cms credential cells deliberately\n // do NOT migrate at all (operator ruling 2026-08-23); they live on only in\n // the frozen base. `site_health.analytics_soft_fail_at` is the column code\n // always wanted (\"Analytics soft-fail at\") but no operator ever created in\n // Airtable — it ships real here.\n //\n // reports.checklist is JSON keyed by the STABLE checklist key (\"deploy\",\n // \"cms\", …, from src/reports/checklist.ts), not the Airtable column name —\n // the importer translates, so \"Test: Verified After Updates\" stops leaking\n // its legacy name into a second store.\n id: \"0007_fleet_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS sites (\n id TEXT PRIMARY KEY,\n slug TEXT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n url TEXT,\n status TEXT,\n point_of_contact TEXT,\n maintenance_freq TEXT,\n testing_freq TEXT,\n maintenance_day TEXT,\n testing_day TEXT,\n ga4_property_id TEXT,\n search_query TEXT,\n search_console_property TEXT,\n git_repo TEXT,\n netlify_id TEXT,\n report_recipients_to TEXT,\n report_recipients_cc TEXT,\n copy_intro TEXT,\n copy_contact TEXT,\n copy_footer TEXT,\n newsletter_webhook TEXT,\n mailchimp_api_key TEXT,\n mailchimp_audience_id TEXT,\n notify_routing TEXT,\n require_turnstile INTEGER NOT NULL DEFAULT 0,\n accepted_watch_conditions TEXT,\n prismic_ack_until TEXT,\n launched_at TEXT,\n header_image BLOB,\n header_image_filename TEXT,\n header_image_type TEXT,\n header_image_generated_at TEXT,\n legacy TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_sites_status ON sites (status);\n\n CREATE TABLE IF NOT EXISTS site_health (\n site_id TEXT PRIMARY KEY,\n p_score INTEGER,\n r_score INTEGER,\n bp_score INTEGER,\n seo_score INTEGER,\n lighthouse_at TEXT,\n a11y_violations INTEGER,\n deps_drifted INTEGER,\n deps_major_behind INTEGER,\n deps_outdated INTEGER,\n deps_major_outdated INTEGER,\n vulns_critical INTEGER,\n vulns_high INTEGER,\n vulns_moderate INTEGER,\n vulns_low INTEGER,\n security_audit_at TEXT,\n security_advisories TEXT,\n auto_fix_attempts INTEGER,\n analytics_soft_fail_at TEXT,\n cert_days_remaining INTEGER,\n domain_checked_at TEXT,\n deploy_status TEXT,\n last_deploy_at TEXT,\n deploy_log_url TEXT,\n deploy_checked_at TEXT,\n function_health TEXT,\n cms_reachable TEXT,\n turnstile_widget TEXT,\n function_health_checked_at TEXT,\n crossbrowser_ok INTEGER,\n mobile_ok INTEGER,\n links_ok INTEGER,\n broken_links INTEGER,\n browser_checked_at TEXT,\n uptime_reachable TEXT,\n titles_meta_ok TEXT,\n smoke_ok TEXT,\n last_smoke_at TEXT,\n form_e2e_ok TEXT,\n form_e2e_checked_at TEXT,\n renovate_failing_cis INTEGER,\n default_branch_ci TEXT,\n last_commit_at TEXT,\n github_signals_at TEXT,\n prismic_models TEXT,\n prismic_models_checked_at TEXT,\n prismic_models_drift TEXT\n );\n\n CREATE TABLE IF NOT EXISTS site_schedule (\n site_id TEXT PRIMARY KEY,\n next_maintenance_at TEXT,\n next_testing_at TEXT,\n computed_at TEXT\n );\n\n CREATE TABLE IF NOT EXISTS reports (\n id TEXT PRIMARY KEY,\n site_id TEXT,\n report_id TEXT,\n report_type TEXT,\n period TEXT,\n period_start TEXT,\n period_end TEXT,\n completed_on TEXT,\n lighthouse_performance INTEGER,\n lighthouse_accessibility INTEGER,\n lighthouse_best_practices INTEGER,\n lighthouse_seo INTEGER,\n ga_users_current INTEGER,\n ga_users_previous INTEGER,\n search_found_page1 INTEGER,\n search_position REAL,\n last_tested_date TEXT,\n commentary TEXT,\n subject_override TEXT,\n draft_ready INTEGER NOT NULL DEFAULT 0,\n approved_to_send INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n approved_by TEXT,\n send_override INTEGER NOT NULL DEFAULT 0,\n override_reason TEXT,\n override_by TEXT,\n override_at TEXT,\n sent_at TEXT,\n delivery_status TEXT,\n resend_message_id TEXT,\n checklist TEXT,\n checklist_auto_evidence TEXT,\n rendered_html TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_reports_site ON reports (site_id, period_start DESC);\n `,\n },\n {\n // Indexes the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts) proved the\n // hot paths need — each serves a named query shape that otherwise full-scans\n // submissions, the one unbounded-growth table:\n // submitted_at — every windowed read (/submissions default page, digest\n // per-site counts, duplicate/repeat-sender scans)\n // resend_message_id — the resend-webhook bounce lookup (markNotifyBouncedByMessageId)\n // submission_id — createSubmission's MAX(submission_id)+1 display number,\n // O(1) off the index instead of a scan per insert\n // spam_reason (partial, covering) — listSpamReasonsFiltered's facet tally\n // reads ONLY spam_reason WHERE spam_reason IS NOT NULL;\n // partial keeps it to the spam subset\n // spam_screenouts(date) — listScreenOutsSince windows on date, which the\n // (site_id, date) PK can't serve\n id: \"0008_query_plan_indexes\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_submitted\n ON submissions (submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_spam_reason\n ON submissions (spam_reason) WHERE spam_reason IS NOT NULL;\n CREATE INDEX IF NOT EXISTS idx_submissions_resend_message\n ON submissions (resend_message_id);\n CREATE INDEX IF NOT EXISTS idx_submissions_display_number\n ON submissions (submission_id);\n CREATE INDEX IF NOT EXISTS idx_screenouts_date\n ON spam_screenouts (date);\n `,\n },\n {\n // Prospect audits (spec 2026-08-24): one row per `prospect-audit` CLI run.\n // `token` is the unguessable public handle for GET /r/{token}; `result_json`\n // is the full ProspectAuditResult, re-rendered on every request so report\n // styling updates apply to already-shared links.\n id: \"0009_prospect_audits\",\n sql: `\n CREATE TABLE IF NOT EXISTS prospect_audits (\n id TEXT PRIMARY KEY,\n token TEXT NOT NULL UNIQUE,\n url TEXT NOT NULL,\n business TEXT,\n created_at TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'complete',\n result_json TEXT NOT NULL\n );\n `,\n },\n {\n // Cockpit /audits listing page (spec 2026-08-25, Piece 5):\n // listRecentProspectAudits orders by created_at DESC with no WHERE clause —\n // without this index that's a raw SCAN of prospect_audits followed by a temp\n // b-tree sort, which the EXPLAIN-query-plan gate (tests/db/query-plans.test.ts)\n // fails the build on. DESC matches the query's own ORDER BY so SQLite can walk\n // the index directly instead of sorting.\n id: \"0010_prospect_audits_created_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_created\n ON prospect_audits (created_at DESC);\n `,\n },\n {\n // #609 (#539 Phase 5): the digest's prior-run snapshot moves off Airtable.\n // Unlike the rest of Phase 5 this is a MIGRATION, not a dual-write — there\n // is no Airtable counterpart left afterwards, so parity does not cover it.\n //\n // A single row holding the whole snapshot as JSON, keyed by a constant id.\n // Both readers (runDigest's diff and the fleet homepage's NEW badges) need\n // the ENTIRE map, so a keyed table would buy nothing on reads and its\n // \"give me everything\" query would be a raw scan needing a justified entry\n // in the EXPLAIN gate's allowlist. One row by primary key needs neither.\n id: \"0011_digest_state\",\n sql: `\n CREATE TABLE IF NOT EXISTS digest_state (\n id TEXT PRIMARY KEY,\n snapshot TEXT NOT NULL,\n updated_at TEXT\n );\n `,\n },\n {\n // form_type was the one SubmissionFilter key with an equality predicate and no\n // index — countSubmissionsFiltered({formType}) raw-scanned `submissions`, twice\n // per load of the submissions page. It shipped green because the EXPLAIN gate\n // named its scenarios by hand and never wrote one for this shape; the gate is\n // now driven by a filter matrix, which is what surfaced it.\n //\n // `submissions` is the one unbounded-growth table in the schema — append-only,\n // one row per fleet lead forever — and Turso meters ROW SCANS, so this is the\n // cost that grows without a ceiling.\n id: \"0012_submissions_form_type_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_submissions_form_type\n ON submissions (form_type);\n `,\n },\n {\n // `url` is stored exactly as audited, which makes it a record of the run and\n // a bad thing to group by: the table already holds beachfrontdentistry.com\n // and beachfrontdentistry.com/ as two histories of one practice, and both\n // spellings of ludlowkingsley.com. That costs nothing today because nothing\n // compares two audits of one site — and becomes a returning client with no\n // history the day the report offers a before and an after.\n //\n // Deliberately no SQL backfill. Doing it with lower()/replace()/rtrim()\n // would mangle any URL containing \"www.\" or \"http://\" away from its start,\n // and a key that is quietly wrong is worse than one that is null. Existing\n // rows are backfilled once with the real siteKey().\n // Same ADD COLUMN caveat as 0003: no IF NOT EXISTS in SQLite, so the runner\n // treats \"duplicate column name\" as already-applied. Kept as its own\n // migration, separate from its index below, so that recovery path stays a\n // single statement.\n id: \"0013_prospect_audits_site_key\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN site_key TEXT;`,\n },\n {\n id: \"0014_prospect_audits_site_key_index\",\n sql: `\n CREATE INDEX IF NOT EXISTS idx_prospect_audits_site_key\n ON prospect_audits (site_key, created_at DESC);\n `,\n },\n {\n // Operator edits on a prospect report. One JSON blob rather than a side\n // table: the map is always read and written whole, and no query wants a\n // single override in isolation. Same shape of decision as `result_json`.\n //\n // THREE MIGRATIONS, NOT ONE. Verified against migrate.ts: it runs\n // `executeMultiple(m.sql)` inside a try, swallows any \"duplicate column\n // name\" error, then records the marker unconditionally. That recovery is\n // sound for ONE statement. With three, a crash partway through leaves the\n // marker unwritten; the re-run's first statement then throws duplicate,\n // executeMultiple aborts before the later statements, the catch swallows\n // it, and the marker IS written — leaving the remaining columns\n // permanently missing on a migration recorded as applied. Same rule as\n // 0003 and 0013.\n id: \"0015_prospect_audits_overrides\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN overrides_json TEXT;`,\n },\n {\n id: \"0016_prospect_audits_edited_at\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN edited_at TEXT;`,\n },\n {\n id: \"0017_prospect_audits_opened_at\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN opened_at TEXT;`,\n },\n {\n // #783. Resend's `email.bounced` payload carries `data.bounce = { message,\n // subType, type }`; the webhook read only `data.email_id`, so the\n // classification was dropped and a dead mailbox stored identically to a\n // receiving server refusing our CONTENT. Espada sat under a CRITICAL\n // \"check the point-of-contact address\" for three days in September 2026\n // over an address that was fine — their inbound filter was refusing lead\n // notifications whose spam_score (30 and 55) sat under our auto-filter line.\n //\n // Nullable and never backfilled: every row written before this migration\n // has no classification, and `permanent` is counted only where the payload\n // actually SAID so. Inferring a diagnosis for the historical rows would\n // re-manufacture the exact wrong answer this change exists to stop.\n //\n // FOUR MIGRATIONS, NOT ONE — the same rule 0003, 0013 and 0015 carry, and\n // for the reason spelled out on 0015: `migrate.ts` swallows \"duplicate\n // column name\" and then records the marker unconditionally, which is sound\n // for ONE statement. Batched, a crash partway leaves the marker unwritten,\n // the re-run's first statement throws duplicate, `executeMultiple` aborts\n // before the rest, the catch swallows it and the marker IS written —\n // leaving the remaining columns permanently missing on a migration recorded\n // as applied.\n id: \"0018_submissions_bounce_type\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_type TEXT;`,\n },\n {\n id: \"0019_submissions_bounce_subtype\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_subtype TEXT;`,\n },\n {\n id: \"0020_submissions_bounce_message\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_message TEXT;`,\n },\n {\n // #783 item 3: the operator's exit. Before this, a diagnosed false alarm\n // could only be cleared by 14 days of aging or by a hand-written UPDATE\n // against the production `submissions` table — which is what actually\n // happened on 2026-09-14, flipping the two in-window rows from `bounced`\n // back to `sent` and destroying the evidence in the process.\n //\n // A stamp on the ROW rather than an `ack_until` on `sites` (the issue\n // offers both). The row marker clears the alarm EXACTLY — only the bounces\n // the operator actually looked at stop counting — where a site-level\n // window would mute every future bounce for its whole duration, including\n // a genuinely dead address appearing the next day. It also keeps the bounce\n // record intact, which the production workaround did not.\n id: \"0021_submissions_bounce_ack_at\",\n sql: `ALTER TABLE submissions ADD COLUMN bounce_ack_at TEXT;`,\n },\n {\n // #786, the other half of #785. That PR made `unknown-site` NON-terminal on\n // replay so both recovery orders are safe (`ensure-site` then replay, or\n // replay then `ensure-site`) — without it, replay-before-heal silently burns\n // every queued lead, and that is the order a person reaches for first.\n //\n // The cost, named at the time: a slug that is genuinely dead but still\n // deployed and posting grows the queue without bound, holds\n // `db replay-deadletters` at exit 1, and leaves a standing CRITICAL cockpit\n // item with no escape short of deleting rows by hand.\n //\n // `abandoned` is that escape — resolved by DECISION rather than by outcome.\n // Deliberately NOT reusing `replayed_at`/`replay_outcome`: those mean \"the\n // pipeline gave this lead an answer\", and a row marked replayed reads as a\n // lead that was placed, which is the opposite of what happened to it.\n //\n // who/when/why are three columns because the decision has to outlive the\n // person who made it; a bare timestamp would leave the next operator\n // guessing why a client's lead was written off.\n //\n // THREE MIGRATIONS, NOT ONE — the rule 0003, 0013 and 0015 already carry:\n // `migrate.ts` swallows \"duplicate column name\" then records the marker\n // unconditionally, which is only sound for a single statement.\n id: \"0022_deadletter_abandoned_at\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_at TEXT;`,\n },\n {\n id: \"0023_deadletter_abandoned_by\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_by TEXT;`,\n },\n {\n id: \"0024_deadletter_abandoned_reason\",\n sql: `ALTER TABLE submission_deadletter ADD COLUMN abandoned_reason TEXT;`,\n },\n {\n // #676. The searches behind \"Where you stand\" were chosen entirely by the\n // analyze stage from what it read on the site. For a client we know, better\n // ones can be written in two minutes — and a comparison over time needs the\n // terms to stay FIXED, which a model re-reading the site cannot promise.\n //\n // Stored on the ROW rather than only inside `result_json` for the two\n // reasons the issue names: a re-run can reuse them, and the /audits listing\n // can show which audits used chosen terms — that listing deliberately never\n // selects `result_json` (large, and useless to a list), so a value only\n // present in the blob could not reach it.\n //\n // JSON arrays in TEXT, NULL when nothing was chosen. Null is the load-\n // bearing state: it means the audit chose its own, which is not the same as\n // an operator supplying an empty list.\n //\n // TWO MIGRATIONS, NOT ONE — the rule 0003, 0013, 0015 and 0018 carry:\n // `migrate.ts` swallows \"duplicate column name\" and then records the marker\n // unconditionally, which is sound only for a single statement.\n id: \"0025_prospect_audits_chosen_terms\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_terms TEXT;`,\n },\n {\n id: \"0026_prospect_audits_chosen_questions\",\n sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_questions TEXT;`,\n },\n];\n","import type { Client } from \"@libsql/client\";\nimport { MIGRATIONS } from \"./migrations.js\";\n\n/** True when an error means \"this DDL was already applied\" and re-applying is a\n * no-op we can safely ignore. `CREATE ... IF NOT EXISTS` never throws, but SQLite\n * `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, so a re-run after a lost\n * `_migrations` marker throws `duplicate column name: …`. Treating that as\n * already-applied keeps `runMigrations` idempotent for `ADD COLUMN` migrations —\n * without it a lost marker would boot-loop `openDb()` on every call. */\nfunction isAlreadyAppliedError(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /duplicate column name/i.test(message);\n}\n\n/** Apply every not-yet-applied migration in order against a raw libSQL client,\n * tracking applied ids in `_migrations`. Idempotent: ids already present are\n * skipped, the DDL uses IF NOT EXISTS where it can, and an already-applied\n * `ADD COLUMN` (recognized by its error) is swallowed so the marker is still\n * recorded. Returns the ids applied this run. Runs on every openDb() — cheap\n * (one indexed SELECT) and the gate every fresh Turso database needs before its\n * first write. */\nexport async function runMigrations(client: Client): Promise<string[]> {\n await client.execute(\n \"CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)\",\n );\n const existing = await client.execute(\"SELECT id FROM _migrations\");\n const applied = new Set(existing.rows.map((r) => String(r.id)));\n const ran: string[] = [];\n for (const m of MIGRATIONS) {\n if (applied.has(m.id)) continue;\n try {\n await client.executeMultiple(m.sql);\n } catch (err) {\n // A re-run after a lost marker can re-execute a non-idempotent `ADD COLUMN`;\n // treat \"already applied\" as success so we still record the marker below and\n // never boot-loop. Any other error propagates unchanged.\n if (!isAlreadyAppliedError(err)) throw err;\n }\n await client.execute({\n sql: \"INSERT INTO _migrations (id, applied_at) VALUES (?, ?)\",\n args: [m.id, new Date().toISOString()],\n });\n ran.push(m.id);\n }\n return ran;\n}\n"],"mappings":"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","names":[]}
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-VAD43MOR.js";
5
5
  import {
6
6
  siteSlug
7
- } from "./chunk-3Z6X3BHU.js";
7
+ } from "./chunk-KN52PM63.js";
8
8
 
9
9
  // src/db/import-airtable.ts
10
10
  var EXCLUDED_WEBSITE_FIELDS = /* @__PURE__ */ new Set([
@@ -412,4 +412,4 @@ export {
412
412
  reapRefusal,
413
413
  importFleetState
414
414
  };
415
- //# sourceMappingURL=chunk-6R56OKJZ.js.map
415
+ //# sourceMappingURL=chunk-UIITN37J.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withRecipe
3
- } from "./chunk-ZQV7UQNE.js";
3
+ } from "./chunk-SQ4FSNYL.js";
4
4
  import {
5
5
  defaultSpawn
6
6
  } from "./chunk-LBYOLBW7.js";
@@ -76,4 +76,4 @@ async function bumpDeps(site, opts = {}) {
76
76
  export {
77
77
  bumpDeps
78
78
  };
79
- //# sourceMappingURL=chunk-CJ2RMTT7.js.map
79
+ //# sourceMappingURL=chunk-VEIIUCYL.js.map
@@ -1,8 +1,9 @@
1
1
  // src/db/freeze.ts
2
2
  var TURSO_IS_AUTHORITATIVE = true;
3
3
  async function mirrorWrite(label, run, strict = TURSO_IS_AUTHORITATIVE) {
4
+ let matched;
4
5
  try {
5
- await run();
6
+ matched = await run();
6
7
  } catch (err) {
7
8
  if (strict) {
8
9
  throw new Error(
@@ -11,6 +12,15 @@ async function mirrorWrite(label, run, strict = TURSO_IS_AUTHORITATIVE) {
11
12
  );
12
13
  }
13
14
  console.error(`[${label}] Turso mirror failed (sync will converge it): ${String(err)}`);
15
+ return;
16
+ }
17
+ if (matched === false) {
18
+ console.error(`[${label}] Turso mirror matched no row (mirrored=missed)`);
19
+ if (strict) {
20
+ throw new Error(
21
+ `[${label}] Turso mirror matched no row and Turso is authoritative \u2014 no such row in Turso, and nothing converges a row that was never inserted`
22
+ );
23
+ }
14
24
  }
15
25
  }
16
26
 
@@ -18,4 +28,4 @@ export {
18
28
  TURSO_IS_AUTHORITATIVE,
19
29
  mirrorWrite
20
30
  };
21
- //# sourceMappingURL=chunk-SVAAONYB.js.map
31
+ //# sourceMappingURL=chunk-WQLNUEZL.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/db/freeze.ts"],"sourcesContent":["/** #612 (#539 Phase 5): the one switch that says which store is allowed to fail.\n *\n * ## Why a switch exists at all\n *\n * Through Phase 5 the mirrors are best-effort by design: Airtable is\n * authoritative, so a Turso write that fails is caught, logged and swallowed,\n * and the hourly `fleet-db-sync` import converges whatever it missed. That is\n * correct — right up until the freeze stops the import.\n *\n * After that, nothing converges anything. The same swallowed failure becomes\n * permanent data loss announced only by a log line nobody is grepping. Three\n * outcomes change meaning at the flip:\n *\n * | outcome | before the freeze | after |\n * |-------------------|--------------------------------|------------------------------|\n * | `mirrored=0` | the sync will fix it | that write is gone |\n * | `mirrored=missed` | the site isn't imported yet | impossible, therefore a bug |\n * | `mirrored=absent` | no creds; Airtable still has it| every write was discarded |\n *\n * So the freeze is not a config change. It is: invert which store is allowed\n * to fail.\n *\n * ## Why a code constant rather than an env var\n *\n * The same built artifact runs in Netlify functions and in Actions runners. An\n * env var could be set in one and missed in the other, giving a PARTIAL freeze\n * — half the fleet treating Turso as authoritative and half not — which is a\n * worse state than either end. A constant is uniform by construction, and\n * flipping it is a one-line pull request with a diff a reviewer can see.\n *\n * This is the same shape the site-status vocabulary migration used: code\n * handles both worlds, then one reviewed commit flips the switch.\n *\n * ## Reading this correctly\n *\n * Consumers take it as a DEFAULT parameter rather than reading it inline, so\n * tests can exercise both sides as fixtures. Exactly one test asserts the\n * shipped value; every behavioural test injects. A suite that only ever read\n * the shipped constant would prove nothing about the state it is not in.\n */\n\n/** `true` since the freeze (flipped 2026-08-31, the same PR that retired the\n * hourly `fleet-db-sync` import — the two must move together, and did): Turso\n * is the store that must succeed. The Airtable write is kept as the shadow for\n * the one-week rollback window — still written, and DELIBERATELY still allowed\n * to fail its caller: a shadow you might roll back to is one you keep\n * trustworthy, so an Airtable outage still reds writes until Phase 6 deletes\n * the shadow and the Airtable client layer with it.\n *\n * Go/no-go recorded on #612: `FLEET_PARITY sites=44 health=44 schedule=44\n * reports=17 mismatches=0` immediately before the flip. */\nexport const TURSO_IS_AUTHORITATIVE = true;\n\n/**\n * Run one Turso mirror write with the error semantics the current world calls\n * for: swallowed while Airtable is authoritative, fatal once Turso is.\n *\n * The mirror FACTORIES (`makeSiteMirror`, `makeReportMirror`, the health mirrors)\n * already do this internally. The Netlify request handlers do not use those\n * factories — `approve-report`, `report-commentary`, `resend-webhook` and\n * `site-details` each call `mirrorReportPatch` / `mirrorSiteField` directly,\n * wrapped in a hand-rolled `try { … } catch { console.error }`. Four independent\n * copies of the swallow, none of which consulted the switch, each carrying a\n * comment saying \"the sync converges it\".\n *\n * **That comment stops being true at the freeze.** With the hourly import\n * stopped, a swallowed mirror failure on an approve, a commentary edit, a\n * delivery-status webhook or a site-detail edit is permanent data loss whose only\n * trace is a log line nobody greps — and the Airtable write it shadows will have\n * already succeeded, so the two stores diverge silently.\n *\n * Under `strict` the failure is raised instead. The caller's own error handling\n * decides what that means for the response; what it must not mean is \"logged and\n * forgotten\". `strict` is a parameter with the constant as its default so tests\n * exercise both worlds as fixtures rather than only the shipped one.\n */\nexport async function mirrorWrite(\n label: string,\n run: () => Promise<void>,\n strict: boolean = TURSO_IS_AUTHORITATIVE,\n): Promise<void> {\n try {\n await run();\n } catch (err) {\n if (strict) {\n throw new Error(\n `[${label}] Turso mirror failed and Turso is authoritative — ` +\n `the write is NOT recoverable by the hourly sync: ${String(err)}`,\n { cause: err },\n );\n }\n console.error(`[${label}] Turso mirror failed (sync will converge it): ${String(err)}`);\n }\n}\n"],"mappings":";AAmDO,IAAM,yBAAyB;AAyBtC,eAAsB,YACpB,OACA,KACA,SAAkB,wBACH;AACf,MAAI;AACF,UAAM,IAAI;AAAA,EACZ,SAAS,KAAK;AACZ,QAAI,QAAQ;AACV,YAAM,IAAI;AAAA,QACR,IAAI,KAAK,4GAC6C,OAAO,GAAG,CAAC;AAAA,QACjE,EAAE,OAAO,IAAI;AAAA,MACf;AAAA,IACF;AACA,YAAQ,MAAM,IAAI,KAAK,kDAAkD,OAAO,GAAG,CAAC,EAAE;AAAA,EACxF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/db/freeze.ts"],"sourcesContent":["/** #612 (#539 Phase 5): the one switch that says which store is allowed to fail.\n *\n * ## Why a switch exists at all\n *\n * Through Phase 5 the mirrors are best-effort by design: Airtable is\n * authoritative, so a Turso write that fails is caught, logged and swallowed,\n * and the hourly `fleet-db-sync` import converges whatever it missed. That is\n * correct — right up until the freeze stops the import.\n *\n * After that, nothing converges anything. The same swallowed failure becomes\n * permanent data loss announced only by a log line nobody is grepping. Three\n * outcomes change meaning at the flip:\n *\n * | outcome | before the freeze | after |\n * |-------------------|--------------------------------|------------------------------|\n * | `mirrored=0` | the sync will fix it | that write is gone |\n * | `mirrored=missed` | the site isn't imported yet | impossible, therefore a bug |\n * | `mirrored=absent` | no creds; Airtable still has it| every write was discarded |\n *\n * So the freeze is not a config change. It is: invert which store is allowed\n * to fail.\n *\n * ## Why a code constant rather than an env var\n *\n * The same built artifact runs in Netlify functions and in Actions runners. An\n * env var could be set in one and missed in the other, giving a PARTIAL freeze\n * — half the fleet treating Turso as authoritative and half not — which is a\n * worse state than either end. A constant is uniform by construction, and\n * flipping it is a one-line pull request with a diff a reviewer can see.\n *\n * This is the same shape the site-status vocabulary migration used: code\n * handles both worlds, then one reviewed commit flips the switch.\n *\n * ## Reading this correctly\n *\n * Consumers take it as a DEFAULT parameter rather than reading it inline, so\n * tests can exercise both sides as fixtures. Exactly one test asserts the\n * shipped value; every behavioural test injects. A suite that only ever read\n * the shipped constant would prove nothing about the state it is not in.\n */\n\n/** `true` since the freeze (flipped 2026-08-31, the same PR that retired the\n * hourly `fleet-db-sync` import — the two must move together, and did): Turso\n * is the store that must succeed. The Airtable write is kept as the shadow for\n * the one-week rollback window — still written, and DELIBERATELY still allowed\n * to fail its caller: a shadow you might roll back to is one you keep\n * trustworthy, so an Airtable outage still reds writes until Phase 6 deletes\n * the shadow and the Airtable client layer with it.\n *\n * Go/no-go recorded on #612: `FLEET_PARITY sites=44 health=44 schedule=44\n * reports=17 mismatches=0` immediately before the flip. */\nexport const TURSO_IS_AUTHORITATIVE = true;\n\n/**\n * Run one Turso mirror write with the error semantics the current world calls\n * for: swallowed while Airtable is authoritative, fatal once Turso is.\n *\n * The mirror FACTORIES (`makeSiteMirror`, `makeReportMirror`, the health mirrors)\n * already do this internally. The Netlify request handlers do not use those\n * factories — `approve-report`, `report-commentary`, `resend-webhook` and\n * `site-details` each call `mirrorReportPatch` / `mirrorSiteField` directly,\n * wrapped in a hand-rolled `try { … } catch { console.error }`. Four independent\n * copies of the swallow, none of which consulted the switch, each carrying a\n * comment saying \"the sync converges it\".\n *\n * **That comment stops being true at the freeze.** With the hourly import\n * stopped, a swallowed mirror failure on an approve, a commentary edit, a\n * delivery-status webhook or a site-detail edit is permanent data loss whose only\n * trace is a log line nobody greps — and the Airtable write it shadows will have\n * already succeeded, so the two stores diverge silently.\n *\n * Under `strict` the failure is raised instead. The caller's own error handling\n * decides what that means for the response; what it must not mean is \"logged and\n * forgotten\". `strict` is a parameter with the constant as its default so tests\n * exercise both worlds as fixtures rather than only the shipped one.\n */\nexport async function mirrorWrite(\n label: string,\n /** Resolves `false` when the write's UPDATE matched no row (#647) — the\n * writers that report a row count (`mirrorReportPatch`, `mirrorSiteFields`,\n * …) hand it straight through; a writer that reports nothing resolves void,\n * which counts as landed. */\n run: () => Promise<void | boolean>,\n strict: boolean = TURSO_IS_AUTHORITATIVE,\n): Promise<void> {\n let matched: void | boolean;\n try {\n matched = await run();\n } catch (err) {\n if (strict) {\n throw new Error(\n `[${label}] Turso mirror failed and Turso is authoritative — ` +\n `the write is NOT recoverable by the hourly sync: ${String(err)}`,\n { cause: err },\n );\n }\n console.error(`[${label}] Turso mirror failed (sync will converge it): ${String(err)}`);\n return;\n }\n // `missed` is its own outcome, distinct from both success and failure (#647):\n // the UPDATE ran fine and matched no row. Before the freeze the hourly sync\n // would import the row and the next write would land; after it nothing\n // converges the miss, so a green run here would be claiming a write that\n // never happened. Same rule `makeSiteMirror` already applies.\n if (matched === false) {\n console.error(`[${label}] Turso mirror matched no row (mirrored=missed)`);\n if (strict) {\n throw new Error(\n `[${label}] Turso mirror matched no row and Turso is authoritative — ` +\n `no such row in Turso, and nothing converges a row that was never inserted`,\n );\n }\n }\n}\n"],"mappings":";AAmDO,IAAM,yBAAyB;AAyBtC,eAAsB,YACpB,OAKA,KACA,SAAkB,wBACH;AACf,MAAI;AACJ,MAAI;AACF,cAAU,MAAM,IAAI;AAAA,EACtB,SAAS,KAAK;AACZ,QAAI,QAAQ;AACV,YAAM,IAAI;AAAA,QACR,IAAI,KAAK,4GAC6C,OAAO,GAAG,CAAC;AAAA,QACjE,EAAE,OAAO,IAAI;AAAA,MACf;AAAA,IACF;AACA,YAAQ,MAAM,IAAI,KAAK,kDAAkD,OAAO,GAAG,CAAC,EAAE;AACtF;AAAA,EACF;AAMA,MAAI,YAAY,OAAO;AACrB,YAAQ,MAAM,IAAI,KAAK,iDAAiD;AACxE,QAAI,QAAQ;AACV,YAAM,IAAI;AAAA,QACR,IAAI,KAAK;AAAA,MAEX;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
@@ -1,10 +1,16 @@
1
1
  import {
2
2
  PRETTIER_FLAG_NOTE,
3
- formatWithPrettier
4
- } from "./chunk-R53VL3RI.js";
3
+ formatWithPrettier,
4
+ resolveTargetPrettier
5
+ } from "./chunk-5CJVUPJU.js";
6
+ import {
7
+ RESTORED_NOTE,
8
+ refusedByGit,
9
+ undoRefusedWrites
10
+ } from "./chunk-4UTJ4G6P.js";
5
11
  import {
6
12
  withRecipe
7
- } from "./chunk-ZQV7UQNE.js";
13
+ } from "./chunk-SQ4FSNYL.js";
8
14
  import {
9
15
  defaultSpawn
10
16
  } from "./chunk-LBYOLBW7.js";
@@ -91,6 +97,7 @@ export const GET: RequestHandler = async () => {
91
97
  `;
92
98
 
93
99
  // src/recipes/health-endpoint/index.ts
100
+ var PRETTIER_TIMEOUT_MS = 6e4;
94
101
  var PRISMICIO_CANDIDATES = [
95
102
  "src/lib/prismicio.ts",
96
103
  "src/lib/prismicio.js",
@@ -130,10 +137,21 @@ async function healthEndpoint(site, deps = { spawn: defaultSpawn }) {
130
137
  if (!planned.hasPrismic) {
131
138
  notes.push("no $lib/prismicio \u2014 wrote Prismic-free /health (prismic: skipped)");
132
139
  }
133
- if (!await formatWithPrettier(deps.spawn, cwd, [HEALTH_ENDPOINT_RELATIVE])) {
140
+ const bin = await (deps.resolvePrettier ?? resolveTargetPrettier)(cwd);
141
+ if (bin === null) {
142
+ notes.push(PRETTIER_FLAG_NOTE);
143
+ } else if (!await formatWithPrettier(deps.spawn, cwd, [HEALTH_ENDPOINT_RELATIVE], {
144
+ bin,
145
+ timeoutMs: PRETTIER_TIMEOUT_MS
146
+ })) {
134
147
  notes.push(PRETTIER_FLAG_NOTE);
135
148
  }
136
149
  await commit("feat: add /health endpoint (function-health probe)");
150
+ const refusal = await refusedByGit(cwd, [HEALTH_ENDPOINT_RELATIVE], "the /health endpoint");
151
+ if (refusal) {
152
+ await undoRefusedWrites(cwd, /* @__PURE__ */ new Map([[HEALTH_ENDPOINT_RELATIVE, null]]), refusal.missing);
153
+ return { kind: "failed", notes: refusal.notes + RESTORED_NOTE };
154
+ }
137
155
  return notes.length > 0 ? { kind: "ok", notes: notes.join("; ") } : { kind: "ok" };
138
156
  }
139
157
  });
@@ -142,4 +160,4 @@ async function healthEndpoint(site, deps = { spawn: defaultSpawn }) {
142
160
  export {
143
161
  healthEndpoint
144
162
  };
145
- //# sourceMappingURL=chunk-WSJ7RO54.js.map
163
+ //# sourceMappingURL=chunk-XED2DNAI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/health-endpoint/index.ts","../src/recipes/health-endpoint/template.ts"],"sourcesContent":["import { access, mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../../types.js\";\nimport { withRecipe } from \"../_with-recipe.js\";\nimport { defaultSpawn, type SpawnFn } from \"../../audits/util/spawn.js\";\nimport { formatWithPrettier, resolveTargetPrettier, PRETTIER_FLAG_NOTE } from \"../_prettier.js\";\nimport { refusedByGit, undoRefusedWrites, RESTORED_NOTE } from \"../_head-guard.js\";\nimport {\n HEALTH_ENDPOINT_RELATIVE,\n HEALTH_ENDPOINT_TEMPLATE,\n HEALTH_ENDPOINT_TEMPLATE_NO_PRISMIC,\n} from \"./template.js\";\n\nexport type HealthEndpointDeps = {\n spawn: SpawnFn;\n /** Resolve the TARGET repo's own prettier. Injected so a test can assert the\n * absolute-path spawn without a populated `node_modules`. */\n resolvePrettier?: (repoRoot: string) => Promise<string | null>;\n};\n\n/** Same budget as prismic-ci and match-harness. Without one the default spawn\n * never detaches and never kills, so a hung formatter runs unbounded. */\nconst PRETTIER_TIMEOUT_MS = 60_000;\n\n/** Where a Prismic client module lives across the fleet — `.ts` on sources,\n * `.js`/`.mjs` on some compiled clones. Existence of ANY picks the Prismic\n * variant; none => the import-free variant (a static import of a missing module\n * breaks the Vite build, which feature-detection can't rescue). */\nconst PRISMICIO_CANDIDATES = [\n \"src/lib/prismicio.ts\",\n \"src/lib/prismicio.js\",\n \"src/lib/prismicio.mjs\",\n];\n\nasync function fileExists(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function siteHasPrismic(sitePath: string): Promise<boolean> {\n for (const rel of PRISMICIO_CANDIDATES) {\n if (await fileExists(join(sitePath, rel))) return true;\n }\n return false;\n}\n\n/**\n * Writes a resilient `src/routes/health/+server.ts` if the route doesn't already\n * exist. The function-health audit fetches this endpoint; existing sites need it\n * or the Report Health Gate blocks their Maintenance reports on \"unknown\". Operator\n * edits to an existing endpoint are never clobbered (noop on existing file).\n *\n * Two heterogeneity fixes the fleet rollout exposed: (1) it detects whether the\n * site has a `$lib/prismicio` module and writes the Prismic-free variant when it\n * doesn't (a static import of a missing module fails the build); (2) it runs the\n * SITE's own prettier on the written file before committing, so the file matches\n * that site's formatting config (tabs/spaces, quote style vary fleet-wide) and\n * doesn't red the CI format check. Prettier is best-effort — a site without it\n * installed commits unformatted with a flag note rather than failing the recipe.\n */\nexport async function healthEndpoint(\n site: Site,\n deps: HealthEndpointDeps = { spawn: defaultSpawn },\n): Promise<RecipeResult> {\n const target = join(site.path, HEALTH_ENDPOINT_RELATIVE);\n return withRecipe<{ target: string; template: string; hasPrismic: boolean }>({\n name: \"health-endpoint\",\n site,\n plan: async () => {\n if (await fileExists(target)) {\n return { kind: \"noop\", notes: `${HEALTH_ENDPOINT_RELATIVE} already exists` };\n }\n const hasPrismic = await siteHasPrismic(site.path);\n const template = hasPrismic ? HEALTH_ENDPOINT_TEMPLATE : HEALTH_ENDPOINT_TEMPLATE_NO_PRISMIC;\n return { kind: \"apply\", plan: { target, template, hasPrismic } };\n },\n apply: async (planned, { commit, cwd }) => {\n await mkdir(dirname(planned.target), { recursive: true });\n await writeFile(planned.target, planned.template, \"utf-8\");\n\n const notes: string[] = [];\n if (!planned.hasPrismic) {\n notes.push(\"no $lib/prismicio — wrote Prismic-free /health (prismic: skipped)\");\n }\n\n // Format to the SITE's own prettier config so CI's format check stays green\n // across the heterogeneous fleet. Best-effort: a site without prettier just\n // commits unformatted with a flag note (never fails the /health rollout).\n // — but run the SITE's own prettier, resolved POSITIVELY and invoked by\n // absolute path. `pnpm exec prettier` here would first run a full, unbounded\n // `pnpm install` in the client's checkout (the NORMAL path on `--fleet`,\n // because `prepareFleetSites` clones and never installs) and then, in a\n // repo whose install left no prettier of its own, fall through to the\n // CALLING repo's binary and exit 0. A clone with no prettier gets the flag\n // note and no spawn at all (#737).\n const bin = await (deps.resolvePrettier ?? resolveTargetPrettier)(cwd);\n if (bin === null) {\n notes.push(PRETTIER_FLAG_NOTE);\n } else if (\n !(await formatWithPrettier(deps.spawn, cwd, [HEALTH_ENDPOINT_RELATIVE], {\n bin,\n timeoutMs: PRETTIER_TIMEOUT_MS,\n }))\n ) {\n notes.push(PRETTIER_FLAG_NOTE);\n }\n\n await commit(\"feat: add /health endpoint (function-health probe)\");\n\n // Did git TAKE it? `git add -A` honours the site's .gitignore and exits 0\n // either way, so without this the recipe reports the site done while the\n // endpoint the function-health audit fetches is in no commit — and the\n // Report Health Gate goes on blocking that site's report on \"unknown\"\n // (#741).\n const refusal = await refusedByGit(cwd, [HEALTH_ENDPOINT_RELATIVE], \"the /health endpoint\");\n if (refusal) {\n await undoRefusedWrites(cwd, new Map([[HEALTH_ENDPOINT_RELATIVE, null]]), refusal.missing);\n return { kind: \"failed\", notes: refusal.notes + RESTORED_NOTE };\n }\n\n return notes.length > 0 ? { kind: \"ok\", notes: notes.join(\"; \") } : { kind: \"ok\" };\n },\n });\n}\n","/** Relative path inside a site where the /health endpoint lives. Deploys as a\n * Netlify function (adapter-netlify v6); the function-health audit fetches it. */\nexport const HEALTH_ENDPOINT_RELATIVE = \"src/routes/health/+server.ts\";\n\n/** Resilient /health for existing sites. Unlike the starter's, it does NOT\n * statically import { createClient, isPlaceholderRepo } from \"$lib/prismicio\" —\n * older clones lack `isPlaceholderRepo`, and a missing named import breaks the\n * Vite build. Instead it namespace-imports and feature-detects: `createClient`\n * is universal to Prismic SvelteKit sites; a missing one => prismic:\"skipped\"\n * (the gate treats CMS as \"never ran\" and keeps blocking — never a false green).\n * Any probe error => \"error\" (reds CMS, fail-safe). The gate keys off ok + prismic. */\nexport const HEALTH_ENDPOINT_TEMPLATE = `import { json } from \"@sveltejs/kit\";\nimport { env as privateEnv } from \"$env/dynamic/private\";\nimport { env as publicEnv } from \"$env/dynamic/public\";\nimport * as prismicio from \"$lib/prismicio\";\nimport type { RequestHandler } from \"./$types\";\n\n// A live probe — must never be prerendered.\nexport const prerender = false;\n\ntype PrismicHealth = \"ok\" | \"error\" | \"skipped\";\n\ntype PrismicClient = { getRepository: () => Promise<unknown> };\ntype PrismicModule = {\n createClient?: (opts: { fetch: typeof globalThis.fetch }) => PrismicClient;\n isPlaceholderRepo?: boolean;\n};\n\n// Server-side Prismic reachability probe. Hits the PUBLIC repository-metadata\n// endpoint (getRepository — no token), time-boxed, returning ONLY a status\n// string; the repository body is never included (/health is public).\nasync function probePrismic(fetch: typeof globalThis.fetch): Promise<PrismicHealth> {\n const mod = prismicio as PrismicModule;\n const isPlaceholder = mod.isPlaceholderRepo ?? false;\n if (isPlaceholder || typeof mod.createClient !== \"function\") return \"skipped\";\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n const client = mod.createClient({ fetch });\n const timeout = new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new Error(\"prismic health probe timed out\")), 5000);\n });\n await Promise.race([client.getRepository(), timeout]);\n return \"ok\";\n } catch {\n return \"error\";\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\nexport const GET: RequestHandler = async ({ fetch }) => {\n const prismic = await probePrismic(fetch);\n const forms = {\n ingestUrl: !!privateEnv.FORMS_INGEST_URL,\n ingestToken: !!privateEnv.FORMS_INGEST_TOKEN,\n turnstile: !!publicEnv.PUBLIC_TURNSTILE_SITE_KEY?.trim(),\n };\n // We're inside the handler, so the function ran; ok is false only when the\n // Prismic probe actively errored.\n const ok = prismic !== \"error\";\n return json({ ok, prismic, forms });\n};\n`;\n\n/** Prismic-free /health for sites WITHOUT a `$lib/prismicio` module (non-Prismic\n * sites, e.g. a static/bespoke build). Feature-detection can survive a missing\n * *export*, but a static `import * as prismicio from \"$lib/prismicio\"` fails the\n * Vite build when the *module* doesn't exist — so those sites need a variant with\n * no import at all. CMS is reported as a constant \"skipped\" (the gate treats it as\n * \"never ran\" and never false-greens it). Same JSON shape as the Prismic template\n * (ok/prismic/forms) so the function-health audit parses both identically. */\nexport const HEALTH_ENDPOINT_TEMPLATE_NO_PRISMIC = `import { json } from \"@sveltejs/kit\";\nimport { env as privateEnv } from \"$env/dynamic/private\";\nimport { env as publicEnv } from \"$env/dynamic/public\";\nimport type { RequestHandler } from \"./$types\";\n\n// A live probe — must never be prerendered.\nexport const prerender = false;\n\nexport const GET: RequestHandler = async () => {\n // This site has no $lib/prismicio module, so there is no CMS to probe: report\n // \"skipped\" (the gate treats CMS as \"never ran\", never a false green).\n const prismic = \"skipped\" as const;\n const forms = {\n ingestUrl: !!privateEnv.FORMS_INGEST_URL,\n ingestToken: !!privateEnv.FORMS_INGEST_TOKEN,\n turnstile: !!publicEnv.PUBLIC_TURNSTILE_SITE_KEY?.trim(),\n };\n // We're inside the handler, so the function ran.\n const ok = true;\n return json({ ok, prismic, forms });\n};\n`;\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA,SAAS,QAAQ,OAAO,iBAAiB;AACzC,SAAS,SAAS,YAAY;;;ACCvB,IAAM,2BAA2B;AASjC,IAAM,2BAA2B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4DjC,IAAM,sCAAsC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ADjDnD,IAAM,sBAAsB;AAM5B,IAAM,uBAAuB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AACF;AAEA,eAAe,WAAW,MAAgC;AACxD,MAAI;AACF,UAAM,OAAO,IAAI;AACjB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,eAAe,UAAoC;AAChE,aAAW,OAAO,sBAAsB;AACtC,QAAI,MAAM,WAAW,KAAK,UAAU,GAAG,CAAC,EAAG,QAAO;AAAA,EACpD;AACA,SAAO;AACT;AAgBA,eAAsB,eACpB,MACA,OAA2B,EAAE,OAAO,aAAa,GAC1B;AACvB,QAAM,SAAS,KAAK,KAAK,MAAM,wBAAwB;AACvD,SAAO,WAAsE;AAAA,IAC3E,MAAM;AAAA,IACN;AAAA,IACA,MAAM,YAAY;AAChB,UAAI,MAAM,WAAW,MAAM,GAAG;AAC5B,eAAO,EAAE,MAAM,QAAQ,OAAO,GAAG,wBAAwB,kBAAkB;AAAA,MAC7E;AACA,YAAM,aAAa,MAAM,eAAe,KAAK,IAAI;AACjD,YAAM,WAAW,aAAa,2BAA2B;AACzD,aAAO,EAAE,MAAM,SAAS,MAAM,EAAE,QAAQ,UAAU,WAAW,EAAE;AAAA,IACjE;AAAA,IACA,OAAO,OAAO,SAAS,EAAE,QAAQ,IAAI,MAAM;AACzC,YAAM,MAAM,QAAQ,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;AACxD,YAAM,UAAU,QAAQ,QAAQ,QAAQ,UAAU,OAAO;AAEzD,YAAM,QAAkB,CAAC;AACzB,UAAI,CAAC,QAAQ,YAAY;AACvB,cAAM,KAAK,wEAAmE;AAAA,MAChF;AAYA,YAAM,MAAM,OAAO,KAAK,mBAAmB,uBAAuB,GAAG;AACrE,UAAI,QAAQ,MAAM;AAChB,cAAM,KAAK,kBAAkB;AAAA,MAC/B,WACE,CAAE,MAAM,mBAAmB,KAAK,OAAO,KAAK,CAAC,wBAAwB,GAAG;AAAA,QACtE;AAAA,QACA,WAAW;AAAA,MACb,CAAC,GACD;AACA,cAAM,KAAK,kBAAkB;AAAA,MAC/B;AAEA,YAAM,OAAO,oDAAoD;AAOjE,YAAM,UAAU,MAAM,aAAa,KAAK,CAAC,wBAAwB,GAAG,sBAAsB;AAC1F,UAAI,SAAS;AACX,cAAM,kBAAkB,KAAK,oBAAI,IAAI,CAAC,CAAC,0BAA0B,IAAI,CAAC,CAAC,GAAG,QAAQ,OAAO;AACzF,eAAO,EAAE,MAAM,UAAU,OAAO,QAAQ,QAAQ,cAAc;AAAA,MAChE;AAEA,aAAO,MAAM,SAAS,IAAI,EAAE,MAAM,MAAM,OAAO,MAAM,KAAK,IAAI,EAAE,IAAI,EAAE,MAAM,KAAK;AAAA,IACnF;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -4,6 +4,11 @@ import {
4
4
  healRuleset,
5
5
  rulesetGaps
6
6
  } from "./chunk-MNDIED4M.js";
7
+ import {
8
+ RESTORED_NOTE,
9
+ refusedByGit,
10
+ undoRefusedWrites
11
+ } from "./chunk-4UTJ4G6P.js";
7
12
  import {
8
13
  RENOVATE_ACTION_CONFIG,
9
14
  renovateActionGaps,
@@ -12,10 +17,10 @@ import {
12
17
  } from "./chunk-SLFG7Y76.js";
13
18
  import {
14
19
  readGitHubConfig
15
- } from "./chunk-GASBX52O.js";
20
+ } from "./chunk-CGQZFQAY.js";
16
21
  import {
17
22
  makeGitHub
18
- } from "./chunk-XHHMM5HX.js";
23
+ } from "./chunk-43SW3FGW.js";
19
24
  import {
20
25
  siteLabel
21
26
  } from "./chunk-XXTZBPUY.js";
@@ -28,10 +33,10 @@ import {
28
33
  isWorkingTreeClean,
29
34
  push,
30
35
  resolveOwnerRepo
31
- } from "./chunk-AVEQUR44.js";
36
+ } from "./chunk-EASYCD6F.js";
32
37
 
33
38
  // src/recipes/self-updating/index.ts
34
- import { mkdir, writeFile } from "fs/promises";
39
+ import { mkdir, readFile, writeFile } from "fs/promises";
35
40
  import { dirname, join } from "path";
36
41
  var SELF_UPDATING_CONFIGS = ["renovate-action", "renovate-config"];
37
42
  var REQUIRED_CHECK = "ci / ci";
@@ -96,15 +101,22 @@ async function selfUpdating(site, deps = {}) {
96
101
  }
97
102
  maintBranch = branchName("self-updating");
98
103
  await createBranch(site.path, maintBranch);
104
+ const before = /* @__PURE__ */ new Map();
99
105
  for (const t of templates) {
100
106
  if (!drifted.includes(t.path)) continue;
101
107
  const dest = join(site.path, t.path);
108
+ before.set(t.path, await readFile(dest, "utf-8").catch(() => null));
102
109
  await mkdir(dirname(dest), { recursive: true });
103
110
  const contents = t.config === RENOVATE_ACTION_CONFIG ? withRenovatePinsFrom(t.contents, currentContents.get(t.path) ?? null) : t.contents;
104
111
  await writeFile(dest, contents, "utf-8");
105
112
  }
106
113
  const sha = await commit(site.path, "ci: enable self-updating (Renovate auto-merge)");
107
114
  if (sha) commits.push(sha);
115
+ const refusal = await refusedByGit(site.path, drifted, "the Renovate configuration");
116
+ if (refusal) {
117
+ await undoRefusedWrites(site.path, before, refusal.missing);
118
+ return resultOf(site, "failed", refusal.notes + RESTORED_NOTE, commits);
119
+ }
108
120
  await (deps.pushBranch ?? push)(site.path, maintBranch);
109
121
  const pr = await github.openPullRequest(repo, {
110
122
  head: maintBranch,
@@ -165,4 +177,4 @@ async function selfUpdating(site, deps = {}) {
165
177
  export {
166
178
  selfUpdating
167
179
  };
168
- //# sourceMappingURL=chunk-FSGICFXB.js.map
180
+ //# sourceMappingURL=chunk-XG2A6GIG.js.map