@reddoorla/maintenance 0.96.0 → 0.98.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 +5 -6
  2. package/dist/{announce-45HVP4J4.js → announce-2FH2KBQQ.js} +39 -31
  3. package/dist/announce-2FH2KBQQ.js.map +1 -0
  4. package/dist/attachments-JFGFQRXQ.js +10 -0
  5. package/dist/{blux-RUFMNOUJ.js → blux-NXQLMS4W.js} +5 -5
  6. package/dist/{bump-deps-EL3TKMIL.js → bump-deps-54K4ECP3.js} +6 -6
  7. package/dist/{chunk-IMYL67JV.js → chunk-3RLDX3Q2.js} +5 -5
  8. package/dist/{chunk-EQQ4H6L3.js → chunk-3YALLSBF.js} +2 -2
  9. package/dist/{chunk-LAJ3FF54.js → chunk-5UI4FERC.js} +16 -5
  10. package/dist/chunk-5UI4FERC.js.map +1 -0
  11. package/dist/{chunk-CA6ADGOC.js → chunk-6PGXKN6F.js} +2 -2
  12. package/dist/{chunk-Y5BVR4HW.js → chunk-6THEZQLA.js} +3 -3
  13. package/dist/chunk-ASLDCWBF.js +41 -0
  14. package/dist/chunk-ASLDCWBF.js.map +1 -0
  15. package/dist/{chunk-DVJNYPFR.js → chunk-AYDQQBNG.js} +2 -2
  16. package/dist/{chunk-OTEVKRAH.js → chunk-B2BRFWJY.js} +7 -16
  17. package/dist/chunk-B2BRFWJY.js.map +1 -0
  18. package/dist/{chunk-GEUC5VGM.js → chunk-BINFZQ2T.js} +49 -4
  19. package/dist/chunk-BINFZQ2T.js.map +1 -0
  20. package/dist/{chunk-T76MA2FH.js → chunk-CP6MVZ5X.js} +27 -1
  21. package/dist/{chunk-T76MA2FH.js.map → chunk-CP6MVZ5X.js.map} +1 -1
  22. package/dist/{chunk-72FJZ6LO.js → chunk-DRNZVORX.js} +26 -22
  23. package/dist/chunk-DRNZVORX.js.map +1 -0
  24. package/dist/{chunk-XY7YV2YQ.js → chunk-DXZRWW2F.js} +65 -10
  25. package/dist/chunk-DXZRWW2F.js.map +1 -0
  26. package/dist/{chunk-SHGIDTYF.js → chunk-DYTYPKFN.js} +2 -2
  27. package/dist/chunk-DZZ5KRTB.js +23 -0
  28. package/dist/chunk-DZZ5KRTB.js.map +1 -0
  29. package/dist/{chunk-NVLREB5R.js → chunk-EN6CTBUN.js} +7 -7
  30. package/dist/{chunk-ZH5LOQET.js → chunk-FHFXJ3EM.js} +7 -5
  31. package/dist/chunk-FHFXJ3EM.js.map +1 -0
  32. package/dist/{chunk-3EA43S3R.js → chunk-GRF2RYEO.js} +12 -12
  33. package/dist/{chunk-RRE74VWI.js → chunk-HSMN35LU.js} +2 -2
  34. package/dist/chunk-ICSRWW5Q.js +148 -0
  35. package/dist/chunk-ICSRWW5Q.js.map +1 -0
  36. package/dist/{chunk-XG2A6GIG.js → chunk-IDBPII6G.js} +4 -4
  37. package/dist/{chunk-XG2A6GIG.js.map → chunk-IDBPII6G.js.map} +1 -1
  38. package/dist/{chunk-ECDJDHKT.js → chunk-JHJNNJLG.js} +2 -2
  39. package/dist/{chunk-QFSL2L2B.js → chunk-L5F3WYIY.js} +81 -31
  40. package/dist/chunk-L5F3WYIY.js.map +1 -0
  41. package/dist/{chunk-XED2DNAI.js → chunk-LAG6IP7Y.js} +7 -7
  42. package/dist/{chunk-UIITN37J.js → chunk-LF2TJN4X.js} +2 -2
  43. package/dist/chunk-LF2TJN4X.js.map +1 -0
  44. package/dist/{chunk-SQ4FSNYL.js → chunk-LUF52EIY.js} +2 -2
  45. package/dist/{chunk-MZZDMBDT.js → chunk-LZMAG73U.js} +19 -19
  46. package/dist/chunk-LZMAG73U.js.map +1 -0
  47. package/dist/{chunk-6INOBNRO.js → chunk-MNRWHSFS.js} +2 -2
  48. package/dist/{chunk-CGQZFQAY.js → chunk-MVKNU4QD.js} +2 -3
  49. package/dist/chunk-MVKNU4QD.js.map +1 -0
  50. package/dist/{chunk-62YWOFVN.js → chunk-ORTOMEYR.js} +3 -3
  51. package/dist/{chunk-62YWOFVN.js.map → chunk-ORTOMEYR.js.map} +1 -1
  52. package/dist/{chunk-44SDGPGQ.js → chunk-PZZ6HM5T.js} +3 -3
  53. package/dist/chunk-RQJFGK3A.js +51 -0
  54. package/dist/chunk-RQJFGK3A.js.map +1 -0
  55. package/dist/{chunk-H445EH33.js → chunk-S2PLX4XK.js} +10 -7
  56. package/dist/chunk-S2PLX4XK.js.map +1 -0
  57. package/dist/{chunk-KN52PM63.js → chunk-TC4GANMX.js} +47 -149
  58. package/dist/chunk-TC4GANMX.js.map +1 -0
  59. package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
  60. package/dist/chunk-TEMQPSCM.js.map +1 -0
  61. package/dist/{chunk-4UTJ4G6P.js → chunk-TJLKWQOX.js} +2 -2
  62. package/dist/{chunk-A5QKNOXW.js → chunk-TTT6HYPW.js} +47 -10
  63. package/dist/chunk-TTT6HYPW.js.map +1 -0
  64. package/dist/{chunk-VEIIUCYL.js → chunk-TV76A6GN.js} +2 -2
  65. package/dist/{chunk-D3CTOCUR.js → chunk-TVZ6QCOE.js} +3 -3
  66. package/dist/chunk-UPE6D4AT.js +69 -0
  67. package/dist/chunk-UPE6D4AT.js.map +1 -0
  68. package/dist/{chunk-BIYQN7FH.js → chunk-VCPTOZ56.js} +31 -1
  69. package/dist/{chunk-BIYQN7FH.js.map → chunk-VCPTOZ56.js.map} +1 -1
  70. package/dist/chunk-VQMAAQ5M.js +53 -0
  71. package/dist/chunk-VQMAAQ5M.js.map +1 -0
  72. package/dist/{chunk-EASYCD6F.js → chunk-WMF5S2MB.js} +1 -1
  73. package/dist/{chunk-EASYCD6F.js.map → chunk-WMF5S2MB.js.map} +1 -1
  74. package/dist/{chunk-37B4LVB3.js → chunk-X56LAKPR.js} +3 -3
  75. package/dist/chunk-X56LAKPR.js.map +1 -0
  76. package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
  77. package/dist/chunk-YJBSB4LP.js.map +1 -0
  78. package/dist/{chunk-P4A6D6HO.js → chunk-ZEE5WQYU.js} +32 -18
  79. package/dist/chunk-ZEE5WQYU.js.map +1 -0
  80. package/dist/cli/bin.js +92 -50
  81. package/dist/cli/bin.js.map +1 -1
  82. package/dist/cli/commands/audit.d.ts +33 -3
  83. package/dist/cli/commands/audit.js +26 -23
  84. package/dist/cli/commands/audit.js.map +1 -1
  85. package/dist/client-3XGSZMLU.js +11 -0
  86. package/dist/{convert-to-pnpm-4DON7IFS.js → convert-to-pnpm-U2CRNKNY.js} +6 -6
  87. package/dist/{db-5K4CDEGJ.js → db-7XYTBIPH.js} +25 -18
  88. package/dist/db-7XYTBIPH.js.map +1 -0
  89. package/dist/{deadletter-G4M3HSVM.js → deadletter-A7RQVNG3.js} +8 -4
  90. package/dist/{digest-5AJGWS33.js → digest-AMXRGWZA.js} +34 -33
  91. package/dist/digest-AMXRGWZA.js.map +1 -0
  92. package/dist/{digest-collectors-MC5NWEUA.js → digest-collectors-CX2NCGS7.js} +15 -12
  93. package/dist/ensure-site-F2WFCWWA.js +70 -0
  94. package/dist/ensure-site-F2WFCWWA.js.map +1 -0
  95. package/dist/ensure-site-JEKV2ODE.js +105 -0
  96. package/dist/ensure-site-JEKV2ODE.js.map +1 -0
  97. package/dist/{ensure-site-XESZHZEB.js → ensure-site-OO4UPWNQ.js} +19 -42
  98. package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
  99. package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
  100. package/dist/{fleet-state-GJWGKTLD.js → fleet-state-RJROYXKO.js} +17 -7
  101. package/dist/{forms-notify-target-4NPLX2UG.js → forms-notify-target-DK5R326N.js} +17 -13
  102. package/dist/forms-notify-target-DK5R326N.js.map +1 -0
  103. package/dist/{github-signals-DMU74SAX.js → github-signals-RGRPYTUB.js} +20 -15
  104. package/dist/github-signals-RGRPYTUB.js.map +1 -0
  105. package/dist/{header-image-BRTOLHJF.js → header-image-43ZIEJKP.js} +13 -17
  106. package/dist/header-image-43ZIEJKP.js.map +1 -0
  107. package/dist/{health-endpoint-UCLMSSCN.js → health-endpoint-K5LXDPGE.js} +7 -7
  108. package/dist/{health-mirror-ONRT4DQ5.js → health-mirror-WZUHWCCV.js} +10 -10
  109. package/dist/{import-airtable-IIK2QUUF.js → import-airtable-UBF5GYAL.js} +3 -3
  110. package/dist/index.d.ts +102 -494
  111. package/dist/index.js +62 -52
  112. package/dist/index.js.map +1 -1
  113. package/dist/{init-3LMZTCVD.js → init-OFITGMOP.js} +16 -16
  114. package/dist/{launch-XTDMC6LD.js → launch-AJY3TGTQ.js} +37 -35
  115. package/dist/launch-AJY3TGTQ.js.map +1 -0
  116. package/dist/{lighthouse-52WSPZ5D.js → lighthouse-RFMFREEX.js} +2 -2
  117. package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
  118. package/dist/{chunk-IHHAYXKY.js → match-harness-CFELJA57.js} +70 -4
  119. package/dist/match-harness-CFELJA57.js.map +1 -0
  120. package/dist/migrate-EZL55AWA.js +7 -0
  121. package/dist/notify-K3WVH3DM.js +1 -1
  122. package/dist/{onboard-CZYBWVWH.js → onboard-F7RTLMDZ.js} +6 -6
  123. package/dist/{orchestrate-TGDFGY52.js → orchestrate-SAIFXZIW.js} +13 -10
  124. package/dist/parity-2JGVUCZD.js +14 -0
  125. package/dist/{pipeline-UUPXOS2J.js → pipeline-TCSNVDQZ.js} +3 -3
  126. package/dist/{preflight-POZZI3GN.js → preflight-6XGVVO2U.js} +35 -11
  127. package/dist/preflight-6XGVVO2U.js.map +1 -0
  128. package/dist/{prismic-ci-AHY5ULJR.js → prismic-ci-XMX7A5E4.js} +11 -11
  129. package/dist/prismic-ci-XMX7A5E4.js.map +1 -0
  130. package/dist/{prismic-models-URYGODNY.js → prismic-models-7GKFAAFA.js} +19 -16
  131. package/dist/prismic-models-7GKFAAFA.js.map +1 -0
  132. package/dist/prospect/types.d.ts +44 -2
  133. package/dist/prospect/types.js +1 -1
  134. package/dist/{prospect-audit-AJEE3Z4L.js → prospect-audit-KWP5P5U2.js} +37 -5
  135. package/dist/prospect-audit-KWP5P5U2.js.map +1 -0
  136. package/dist/{protection-audit-3J3UEOVT.js → protection-audit-YXJKFU6O.js} +3 -3
  137. package/dist/{protection-audit-3J3UEOVT.js.map → protection-audit-YXJKFU6O.js.map} +1 -1
  138. package/dist/recipes/sync-configs.js +3 -3
  139. package/dist/{render-MHUUHPLN.js → render-NWUAX6RN.js} +3 -3
  140. package/dist/{render-from-row-65DC6ZP3.js → render-from-row-75JTED2Z.js} +6 -4
  141. package/dist/{renovate-dispatch-ONJMBUHJ.js → renovate-dispatch-ZZQXT7DA.js} +13 -8
  142. package/dist/{renovate-dispatch-ONJMBUHJ.js.map → renovate-dispatch-ZZQXT7DA.js.map} +1 -1
  143. package/dist/replay-HB44TAII.js +77 -0
  144. package/dist/replay-HB44TAII.js.map +1 -0
  145. package/dist/{report-RC5LGPU3.js → report-MVPWS7WP.js} +102 -58
  146. package/dist/report-MVPWS7WP.js.map +1 -0
  147. package/dist/{report-mirror-KKZZ6RMZ.js → report-mirror-O6IDFDWU.js} +28 -10
  148. package/dist/report-mirror-O6IDFDWU.js.map +1 -0
  149. package/dist/roster-EJ3ILR52.js +19 -0
  150. package/dist/roster-EJ3ILR52.js.map +1 -0
  151. package/dist/{screenouts-7P5GOZMV.js → screenouts-7NQUWHOJ.js} +14 -1
  152. package/dist/screenouts-7NQUWHOJ.js.map +1 -0
  153. package/dist/{self-updating-RKKIYNV4.js → self-updating-KX5FMGEO.js} +7 -7
  154. package/dist/{selftest-AFLR7YV7.js → selftest-KABY2CYJ.js} +21 -17
  155. package/dist/{selftest-AFLR7YV7.js.map → selftest-KABY2CYJ.js.map} +1 -1
  156. package/dist/site-create-ZU3ACBTO.js +45 -0
  157. package/dist/site-create-ZU3ACBTO.js.map +1 -0
  158. package/dist/{site-mirror-H2FYMN55.js → site-mirror-TKTDWYP3.js} +10 -10
  159. package/dist/site-mirror-TKTDWYP3.js.map +1 -0
  160. package/dist/{smoke-suite-BWHR3XX5.js → smoke-suite-QQMV7TNA.js} +7 -7
  161. package/dist/{submissions-JDZP35JM.js → submissions-4I66W6QK.js} +11 -1
  162. package/dist/{submissions-JDZP35JM.js.map → submissions-4I66W6QK.js.map} +1 -1
  163. package/dist/{submissions-II5J35QM.js → submissions-K5UXGZUN.js} +12 -7
  164. package/dist/submissions-K5UXGZUN.js.map +1 -0
  165. package/dist/{svelte-codemods-QWGIRNN6.js → svelte-codemods-YFN4TEGV.js} +6 -6
  166. package/dist/{sync-NKPDCMYW.js → sync-F4USL4K5.js} +6 -6
  167. package/dist/{sync-configs-HGLQV55H.js → sync-configs-HBXS3D7C.js} +6 -6
  168. package/dist/turso-KQKBBQCK.js +24 -0
  169. package/dist/turso-KQKBBQCK.js.map +1 -0
  170. package/dist/{upgrade-WZ44IARG.js → upgrade-XOJMDIT4.js} +6 -6
  171. package/dist/util/git.d.ts +1 -1
  172. package/dist/util/git.js +1 -1
  173. package/dist/{websites-6DHIHJ2D.js → websites-Y6FN2XFD.js} +26 -21
  174. package/dist/{write-audits-to-airtable-BIK6LNVC.js → write-audits-to-airtable-TI3JMWSY.js} +6 -4
  175. package/package.json +1 -1
  176. package/dist/airtable-SJV4ZSMS.js +0 -9
  177. package/dist/announce-45HVP4J4.js.map +0 -1
  178. package/dist/attachments-NLZIJ5NU.js +0 -9
  179. package/dist/chunk-37B4LVB3.js.map +0 -1
  180. package/dist/chunk-72FJZ6LO.js.map +0 -1
  181. package/dist/chunk-74QRWE6G.js +0 -53
  182. package/dist/chunk-74QRWE6G.js.map +0 -1
  183. package/dist/chunk-A5QKNOXW.js.map +0 -1
  184. package/dist/chunk-CGQZFQAY.js.map +0 -1
  185. package/dist/chunk-F3JWOPTG.js.map +0 -1
  186. package/dist/chunk-GEUC5VGM.js.map +0 -1
  187. package/dist/chunk-H445EH33.js.map +0 -1
  188. package/dist/chunk-IHHAYXKY.js.map +0 -1
  189. package/dist/chunk-IY6256GK.js.map +0 -1
  190. package/dist/chunk-KN52PM63.js.map +0 -1
  191. package/dist/chunk-LAJ3FF54.js.map +0 -1
  192. package/dist/chunk-MZZDMBDT.js.map +0 -1
  193. package/dist/chunk-OTEVKRAH.js.map +0 -1
  194. package/dist/chunk-P4A6D6HO.js.map +0 -1
  195. package/dist/chunk-QFSL2L2B.js.map +0 -1
  196. package/dist/chunk-UIITN37J.js.map +0 -1
  197. package/dist/chunk-XY7YV2YQ.js.map +0 -1
  198. package/dist/chunk-YFAXOD7Z.js +0 -42
  199. package/dist/chunk-YFAXOD7Z.js.map +0 -1
  200. package/dist/chunk-ZH5LOQET.js.map +0 -1
  201. package/dist/client-L7NAUG6J.js +0 -11
  202. package/dist/db-5K4CDEGJ.js.map +0 -1
  203. package/dist/digest-5AJGWS33.js.map +0 -1
  204. package/dist/ensure-site-XESZHZEB.js.map +0 -1
  205. package/dist/fleet-events-cF2Om88w.d.ts +0 -24
  206. package/dist/forms-notify-target-4NPLX2UG.js.map +0 -1
  207. package/dist/github-signals-DMU74SAX.js.map +0 -1
  208. package/dist/header-image-BRTOLHJF.js.map +0 -1
  209. package/dist/launch-XTDMC6LD.js.map +0 -1
  210. package/dist/lighthouse-airtable-3NOFB42B.js +0 -12
  211. package/dist/match-harness-OW45CB5H.js +0 -77
  212. package/dist/match-harness-OW45CB5H.js.map +0 -1
  213. package/dist/migrate-NE3DUMTM.js +0 -7
  214. package/dist/parity-AEKYSLN6.js +0 -14
  215. package/dist/preflight-POZZI3GN.js.map +0 -1
  216. package/dist/prismic-ci-AHY5ULJR.js.map +0 -1
  217. package/dist/prismic-models-URYGODNY.js.map +0 -1
  218. package/dist/prospect-audit-AJEE3Z4L.js.map +0 -1
  219. package/dist/replay-4FVFOMHH.js +0 -54
  220. package/dist/replay-4FVFOMHH.js.map +0 -1
  221. package/dist/report-RC5LGPU3.js.map +0 -1
  222. package/dist/report-mirror-KKZZ6RMZ.js.map +0 -1
  223. package/dist/screenouts-7P5GOZMV.js.map +0 -1
  224. package/dist/site-mirror-H2FYMN55.js.map +0 -1
  225. package/dist/submissions-II5J35QM.js.map +0 -1
  226. package/dist/write-audits-to-airtable-BIK6LNVC.js.map +0 -1
  227. /package/dist/{airtable-SJV4ZSMS.js.map → attachments-JFGFQRXQ.js.map} +0 -0
  228. /package/dist/{blux-RUFMNOUJ.js.map → blux-NXQLMS4W.js.map} +0 -0
  229. /package/dist/{bump-deps-EL3TKMIL.js.map → bump-deps-54K4ECP3.js.map} +0 -0
  230. /package/dist/{chunk-IMYL67JV.js.map → chunk-3RLDX3Q2.js.map} +0 -0
  231. /package/dist/{chunk-EQQ4H6L3.js.map → chunk-3YALLSBF.js.map} +0 -0
  232. /package/dist/{chunk-CA6ADGOC.js.map → chunk-6PGXKN6F.js.map} +0 -0
  233. /package/dist/{chunk-Y5BVR4HW.js.map → chunk-6THEZQLA.js.map} +0 -0
  234. /package/dist/{chunk-DVJNYPFR.js.map → chunk-AYDQQBNG.js.map} +0 -0
  235. /package/dist/{chunk-SHGIDTYF.js.map → chunk-DYTYPKFN.js.map} +0 -0
  236. /package/dist/{chunk-NVLREB5R.js.map → chunk-EN6CTBUN.js.map} +0 -0
  237. /package/dist/{chunk-3EA43S3R.js.map → chunk-GRF2RYEO.js.map} +0 -0
  238. /package/dist/{chunk-RRE74VWI.js.map → chunk-HSMN35LU.js.map} +0 -0
  239. /package/dist/{chunk-ECDJDHKT.js.map → chunk-JHJNNJLG.js.map} +0 -0
  240. /package/dist/{chunk-XED2DNAI.js.map → chunk-LAG6IP7Y.js.map} +0 -0
  241. /package/dist/{chunk-SQ4FSNYL.js.map → chunk-LUF52EIY.js.map} +0 -0
  242. /package/dist/{chunk-6INOBNRO.js.map → chunk-MNRWHSFS.js.map} +0 -0
  243. /package/dist/{chunk-44SDGPGQ.js.map → chunk-PZZ6HM5T.js.map} +0 -0
  244. /package/dist/{chunk-4UTJ4G6P.js.map → chunk-TJLKWQOX.js.map} +0 -0
  245. /package/dist/{chunk-VEIIUCYL.js.map → chunk-TV76A6GN.js.map} +0 -0
  246. /package/dist/{chunk-D3CTOCUR.js.map → chunk-TVZ6QCOE.js.map} +0 -0
  247. /package/dist/{attachments-NLZIJ5NU.js.map → client-3XGSZMLU.js.map} +0 -0
  248. /package/dist/{convert-to-pnpm-4DON7IFS.js.map → convert-to-pnpm-U2CRNKNY.js.map} +0 -0
  249. /package/dist/{client-L7NAUG6J.js.map → deadletter-A7RQVNG3.js.map} +0 -0
  250. /package/dist/{deadletter-G4M3HSVM.js.map → digest-collectors-CX2NCGS7.js.map} +0 -0
  251. /package/dist/{digest-collectors-MC5NWEUA.js.map → fleet-state-RJROYXKO.js.map} +0 -0
  252. /package/dist/{health-endpoint-UCLMSSCN.js.map → health-endpoint-K5LXDPGE.js.map} +0 -0
  253. /package/dist/{health-mirror-ONRT4DQ5.js.map → health-mirror-WZUHWCCV.js.map} +0 -0
  254. /package/dist/{fleet-state-GJWGKTLD.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
  255. /package/dist/{init-3LMZTCVD.js.map → init-OFITGMOP.js.map} +0 -0
  256. /package/dist/{import-airtable-IIK2QUUF.js.map → lighthouse-RFMFREEX.js.map} +0 -0
  257. /package/dist/{lighthouse-52WSPZ5D.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
  258. /package/dist/{lighthouse-airtable-3NOFB42B.js.map → migrate-EZL55AWA.js.map} +0 -0
  259. /package/dist/{onboard-CZYBWVWH.js.map → onboard-F7RTLMDZ.js.map} +0 -0
  260. /package/dist/{migrate-NE3DUMTM.js.map → orchestrate-SAIFXZIW.js.map} +0 -0
  261. /package/dist/{orchestrate-TGDFGY52.js.map → parity-2JGVUCZD.js.map} +0 -0
  262. /package/dist/{parity-AEKYSLN6.js.map → pipeline-TCSNVDQZ.js.map} +0 -0
  263. /package/dist/{render-MHUUHPLN.js.map → render-NWUAX6RN.js.map} +0 -0
  264. /package/dist/{pipeline-UUPXOS2J.js.map → render-from-row-75JTED2Z.js.map} +0 -0
  265. /package/dist/{self-updating-RKKIYNV4.js.map → self-updating-KX5FMGEO.js.map} +0 -0
  266. /package/dist/{smoke-suite-BWHR3XX5.js.map → smoke-suite-QQMV7TNA.js.map} +0 -0
  267. /package/dist/{svelte-codemods-QWGIRNN6.js.map → svelte-codemods-YFN4TEGV.js.map} +0 -0
  268. /package/dist/{sync-NKPDCMYW.js.map → sync-F4USL4K5.js.map} +0 -0
  269. /package/dist/{sync-configs-HGLQV55H.js.map → sync-configs-HBXS3D7C.js.map} +0 -0
  270. /package/dist/{upgrade-WZ44IARG.js.map → upgrade-XOJMDIT4.js.map} +0 -0
  271. /package/dist/{render-from-row-65DC6ZP3.js.map → websites-Y6FN2XFD.js.map} +0 -0
  272. /package/dist/{websites-6DHIHJ2D.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withRecipe
3
- } from "./chunk-SQ4FSNYL.js";
3
+ } from "./chunk-LUF52EIY.js";
4
4
  import {
5
5
  bumpDep,
6
6
  readPackageJson,
@@ -150,4 +150,4 @@ export {
150
150
  selfCaretRange,
151
151
  onboard
152
152
  };
153
- //# sourceMappingURL=chunk-CA6ADGOC.js.map
153
+ //# sourceMappingURL=chunk-6PGXKN6F.js.map
@@ -9,10 +9,10 @@ import {
9
9
  } from "./chunk-KJGKZLCT.js";
10
10
  import {
11
11
  applyReportTypeHeadline
12
- } from "./chunk-MZZDMBDT.js";
12
+ } from "./chunk-LZMAG73U.js";
13
13
  import {
14
14
  siteSlug
15
- } from "./chunk-KN52PM63.js";
15
+ } from "./chunk-ICSRWW5Q.js";
16
16
 
17
17
  // src/reports/send/render-email.ts
18
18
  function toInlineAttachment(a) {
@@ -143,4 +143,4 @@ export {
143
143
  renderReportFromRow,
144
144
  windowDays
145
145
  };
146
- //# sourceMappingURL=chunk-Y5BVR4HW.js.map
146
+ //# sourceMappingURL=chunk-6THEZQLA.js.map
@@ -0,0 +1,41 @@
1
+ // src/reports/report-row.ts
2
+ var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
3
+ function toReportType(raw) {
4
+ if (raw && REPORT_TYPES.includes(raw)) return raw;
5
+ if (raw)
6
+ console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} \u2014 treating as Maintenance`);
7
+ return "Maintenance";
8
+ }
9
+ function isPendingApproval(r) {
10
+ return r.draftReady && !r.approvedToSend && r.sentAt === null;
11
+ }
12
+ function parseAutoEvidence(raw) {
13
+ if (typeof raw !== "string" || !raw.trim()) return null;
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(raw);
17
+ } catch {
18
+ return null;
19
+ }
20
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
21
+ const out = {};
22
+ for (const [field, v] of Object.entries(parsed)) {
23
+ if (!v || typeof v !== "object") continue;
24
+ const o = v;
25
+ if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown" && o.result !== "n/a")
26
+ continue;
27
+ out[field] = {
28
+ result: o.result,
29
+ checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
30
+ note: typeof o.note === "string" ? o.note : ""
31
+ };
32
+ }
33
+ return Object.keys(out).length > 0 ? out : null;
34
+ }
35
+
36
+ export {
37
+ toReportType,
38
+ isPendingApproval,
39
+ parseAutoEvidence
40
+ };
41
+ //# sourceMappingURL=chunk-ASLDCWBF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/report-row.ts"],"sourcesContent":["/**\n * The report-row model and its pure, vendor-neutral coercers.\n *\n * `ReportRow` is what BOTH readers return — `mapRow` over an Airtable record\n * (`src/reports/airtable/reports.ts`) and the Turso report reader in\n * `src/db/fleet-state.ts`. Moved out of the Airtable module in #539 Phase 6\n * step 1 (#646) so the Turso read path does not depend on the directory Phase 6\n * deletes. `src/reports/airtable/reports.ts` re-exports every name.\n */\nimport type { ReportType, LighthouseScores } from \"./types.js\";\nimport type { EvidenceRecord } from \"./auto-tick.js\";\n\nconst REPORT_TYPES: readonly ReportType[] = [\"Maintenance\", \"Testing\", \"Launch\", \"Announcement\"];\n\n/** Coerce the Airtable `Report type` (a single-select string) to a known\n * ReportType. A bare `as ReportType` cast is a compile-time lie: if the\n * single-select gains an unexpected option, the bad value flows to render.ts,\n * where `reportType === \"Launch\"` silently falls through to the Maintenance\n * template. Validate at the boundary; warn + default to \"Maintenance\" so an\n * unknown type is VISIBLE in the logs rather than silently mis-templated. */\nexport function toReportType(raw: string | undefined): ReportType {\n if (raw && (REPORT_TYPES as readonly string[]).includes(raw)) return raw as ReportType;\n if (raw)\n console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} — treating as Maintenance`);\n return \"Maintenance\";\n}\n\nexport type DeliveryStatus = \"pending\" | \"delivered\" | \"bounced\" | \"complained\";\n\nexport type ReportRow = {\n id: string;\n reportId: string;\n siteId: string;\n reportType: ReportType;\n /** UTC `YYYY-MM` recurrence key (idempotency for search-before-create). Null on legacy rows. */\n period: string | null;\n periodStart: string | null;\n periodEnd: string | null;\n completedOn: string | null;\n lighthouse: LighthouseScores | null;\n gaUsersCurrent: number | null;\n gaUsersPrevious: number | null;\n searchFoundPage1: boolean | null;\n searchPosition: number | null;\n lastTestedDate: string | null;\n commentary: string | null;\n subjectOverride: string | null;\n draftReady: boolean;\n approvedToSend: boolean;\n sentAt: string | null;\n approvedAt: string | null;\n approvedBy: string | null;\n deliveryStatus: DeliveryStatus;\n renderedHtmlAttachment: { url: string; filename: string } | null;\n /** Read out of the Resend response and stored in a hidden field; needed for webhook reconciliation. */\n resendMessageId: string | null;\n /** The 12 operator-checklist checkboxes, keyed by their Airtable column name (ALL_CHECKLIST_FIELDS);\n * missing/false cells read false. Maintenance/Testing reports gate approve+send on the relevant\n * subset (see src/reports/checklist.ts). */\n checklist: Record<string, boolean>;\n /** Snapshot of the auto-tick evidence at draft time, keyed by checklist field → evidence\n * record. Null when the report predates auto-tick or carried no auto-checked items. This is the\n * health gate's source of truth: `isHealthGateClear`/`gatingHealth` (src/reports/checklist.ts)\n * read these evidence records, NOT the checklist booleans — and also drive the dashboard badges. */\n autoEvidence: Record<string, EvidenceRecord> | null;\n /** Logged send-anyway override (Phase 10). When `sendOverride` is true AND `overrideReason` is\n * non-empty, the health gate is bypassed for THIS report; `overrideBy`/`overrideAt` are the\n * audit trail (parallel to approvedBy/approvedAt). Missing cells read false/null. */\n sendOverride: boolean;\n overrideReason: string | null;\n overrideBy: string | null;\n overrideAt: string | null;\n};\n\n/**\n * The \"Ready for your yes\" gate: Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n * The single source of truth for \"pending the operator's approval\" — `listPendingApproval`,\n * `runDigest`'s ready-list, the per-site dashboard, and the fleet cockpit all key off this\n * one predicate so the surfaces can't drift.\n */\nexport function isPendingApproval(r: ReportRow): boolean {\n return r.draftReady && !r.approvedToSend && r.sentAt === null;\n}\n\n/**\n * Parse the `Checklist auto-evidence` JSON field → a field→EvidenceRecord map, or null when\n * absent/malformed. Permissive on inner shape (it's display-only — a bad blob just yields null,\n * never throws), mirroring `parseNotifyRouting`.\n */\nexport function parseAutoEvidence(raw: unknown): Record<string, EvidenceRecord> | null {\n if (typeof raw !== \"string\" || !raw.trim()) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n // Validate each entry's inner shape (it drives display) — drop any record whose `result` isn't\n // one of the four literals, and coerce `note`/`checkedAt` to safe types. A garbage blob yields\n // null rather than a record that would render \"auto: undefined\" on the dashboard.\n const out: Record<string, EvidenceRecord> = {};\n for (const [field, v] of Object.entries(parsed as Record<string, unknown>)) {\n if (!v || typeof v !== \"object\") continue;\n const o = v as Record<string, unknown>;\n if (o.result !== \"pass\" && o.result !== \"fail\" && o.result !== \"unknown\" && o.result !== \"n/a\")\n continue;\n out[field] = {\n result: o.result,\n checkedAt: typeof o.checkedAt === \"string\" ? o.checkedAt : null,\n note: typeof o.note === \"string\" ? o.note : \"\",\n };\n }\n return Object.keys(out).length > 0 ? out : null;\n}\n"],"mappings":";AAYA,IAAM,eAAsC,CAAC,eAAe,WAAW,UAAU,cAAc;AAQxF,SAAS,aAAa,KAAqC;AAChE,MAAI,OAAQ,aAAmC,SAAS,GAAG,EAAG,QAAO;AACrE,MAAI;AACF,YAAQ,KAAK,iCAAiC,KAAK,UAAU,GAAG,CAAC,iCAA4B;AAC/F,SAAO;AACT;AAuDO,SAAS,kBAAkB,GAAuB;AACvD,SAAO,EAAE,cAAc,CAAC,EAAE,kBAAkB,EAAE,WAAW;AAC3D;AAOO,SAAS,kBAAkB,KAAqD;AACrF,MAAI,OAAO,QAAQ,YAAY,CAAC,IAAI,KAAK,EAAG,QAAO;AACnD,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM,GAAG;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,YAAY,MAAM,QAAQ,MAAM,EAAG,QAAO;AAI3E,QAAM,MAAsC,CAAC;AAC7C,aAAW,CAAC,OAAO,CAAC,KAAK,OAAO,QAAQ,MAAiC,GAAG;AAC1E,QAAI,CAAC,KAAK,OAAO,MAAM,SAAU;AACjC,UAAM,IAAI;AACV,QAAI,EAAE,WAAW,UAAU,EAAE,WAAW,UAAU,EAAE,WAAW,aAAa,EAAE,WAAW;AACvF;AACF,QAAI,KAAK,IAAI;AAAA,MACX,QAAQ,EAAE;AAAA,MACV,WAAW,OAAO,EAAE,cAAc,WAAW,EAAE,YAAY;AAAA,MAC3D,MAAM,OAAO,EAAE,SAAS,WAAW,EAAE,OAAO;AAAA,IAC9C;AAAA,EACF;AACA,SAAO,OAAO,KAAK,GAAG,EAAE,SAAS,IAAI,MAAM;AAC7C;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withRecipe
3
- } from "./chunk-SQ4FSNYL.js";
3
+ } from "./chunk-LUF52EIY.js";
4
4
  import {
5
5
  readPackageJson,
6
6
  writePackageJson
@@ -96,4 +96,4 @@ async function convertToPnpm(site, opts = {}) {
96
96
  export {
97
97
  convertToPnpm
98
98
  };
99
- //# sourceMappingURL=chunk-DVJNYPFR.js.map
99
+ //# sourceMappingURL=chunk-AYDQQBNG.js.map
@@ -5,22 +5,14 @@ import {
5
5
  import {
6
6
  isProbablyEmail,
7
7
  parseAddresses
8
- } from "./chunk-P4A6D6HO.js";
9
- import {
10
- listAllReports
11
- } from "./chunk-F3JWOPTG.js";
8
+ } from "./chunk-ZEE5WQYU.js";
12
9
  import {
13
10
  gatingHealth,
14
11
  isSendOverridden
15
12
  } from "./chunk-VAD43MOR.js";
16
13
  import {
17
- openBase,
18
- readAirtableConfig
19
- } from "./chunk-HN6I2XWI.js";
20
- import {
21
- listWebsites,
22
14
  siteSlug
23
- } from "./chunk-KN52PM63.js";
15
+ } from "./chunk-ICSRWW5Q.js";
24
16
 
25
17
  // src/reports/preflight.ts
26
18
  var OPERATOR_DOMAINS = ["reddoorla.com", "tuckerlemos.com"];
@@ -150,13 +142,13 @@ function preflightSite(site, reports, type, now) {
150
142
  findings.push({
151
143
  level: "warn",
152
144
  check: "scores-missing",
153
- message: "Websites row is missing Lighthouse scores \u2014 `announce` will skip this site; run `audit lighthouse --write-airtable` first"
145
+ message: "Websites row is missing Lighthouse scores \u2014 `announce` will skip this site; run `audit lighthouse --write-back` first"
154
146
  });
155
147
  } else {
156
148
  findings.push({
157
149
  level: "fail",
158
150
  check: "scores-missing",
159
- message: "Websites row is missing Lighthouse scores \u2014 drafting will throw (scoresFromWebsite); run `audit lighthouse --write-airtable` first"
151
+ message: "Websites row is missing Lighthouse scores \u2014 drafting will throw (scoresFromWebsite); run `audit lighthouse --write-back` first"
160
152
  });
161
153
  }
162
154
  }
@@ -240,12 +232,11 @@ function preflightFleet(sites) {
240
232
  return findings;
241
233
  }
242
234
  async function preflight(deps) {
243
- const base = deps?.base ?? openBase(readAirtableConfig());
244
235
  const type = deps?.type ?? "Announcement";
245
236
  const now = deps?.now ?? /* @__PURE__ */ new Date();
246
- const websites = await listWebsites(base);
237
+ const websites = await deps.roster();
247
238
  const selected = deps?.site ? websites.filter((w) => siteSlug(w.name) === siteSlug(deps.site)) : type === "Announcement" ? websites.filter((w) => w.status === "maintained") : websites.filter((w) => w.status === null || ELIGIBLE_STATUSES.has(w.status));
248
- const allReports = selected.length > 0 ? await listAllReports(base) : [];
239
+ const allReports = selected.length > 0 ? await deps.allReports() : [];
249
240
  const results = selected.map(
250
241
  (site) => preflightSite(
251
242
  site,
@@ -341,4 +332,4 @@ export {
341
332
  preflight,
342
333
  approveBlockers
343
334
  };
344
- //# sourceMappingURL=chunk-OTEVKRAH.js.map
335
+ //# sourceMappingURL=chunk-B2BRFWJY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/preflight.ts"],"sourcesContent":["// #646 step 4: preflight reads through the readers its caller hands it, so this\n// file no longer touches the Airtable layer at all — its row helpers come from\n// the vendor-neutral modules step 1 moved them to.\nimport { siteSlug } from \"../fleet/site-row.js\";\nimport type { WebsiteRow } from \"../fleet/site-row.js\";\nimport type { ReportRow } from \"./report-row.js\";\nimport { parseAddresses, isProbablyEmail } from \"./send/orchestrate.js\";\nimport { ELIGIBLE_STATUSES, reportPeriodKey } from \"./due.js\";\nimport type { ReportType } from \"./types.js\";\nimport { gatingHealth, isSendOverridden } from \"./checklist.js\";\n\nexport type PreflightLevel = \"fail\" | \"warn\" | \"info\";\n\nexport type PreflightFinding = {\n level: PreflightLevel;\n /** Stable machine-readable check id, e.g. \"recipients-missing\". */\n check: string;\n message: string;\n};\n\nexport type PreflightSiteResult = {\n site: string;\n findings: PreflightFinding[];\n};\n\n/** Domains whose addresses are the OPERATOR's, not a client's. A client site whose\n * resolved To contains one of these is almost always a test-send leftover — the\n * exact misconfiguration that would silently divert a client announcement. */\nconst OPERATOR_DOMAINS = [\"reddoorla.com\", \"tuckerlemos.com\"];\n\n/** Anchors older than this, WHEN the anchor is actually the schedule's base date\n * (no newer Sent-at — see nextDueDate's `lastSent ?? fallback`), mean the next\n * `report --due` run drafts a surprise back-dated report. */\nconst STALE_ANCHOR_DAYS = 396; // ~13 months\n\nfunction domainOf(addr: string): string {\n const at = addr.lastIndexOf(\"@\");\n return at === -1 ? \"\" : addr.slice(at + 1).toLowerCase();\n}\n\nfunction isOperatorSite(site: WebsiteRow): boolean {\n if (!site.url) return false;\n try {\n // Airtable `url` cells aren't guaranteed a scheme; retry with one before giving up.\n const host = new URL(\n /^[a-z][a-z0-9+.-]*:\\/\\//i.test(site.url) ? site.url : `https://${site.url}`,\n ).hostname.toLowerCase();\n return OPERATOR_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));\n } catch {\n return false;\n }\n}\n\nfunction lastSentForType(reports: ReportRow[], type: ReportType): string | null {\n const sent = reports\n .filter((r) => r.reportType === type && r.sentAt !== null)\n .map((r) => r.sentAt!)\n .sort();\n return sent[sent.length - 1] ?? null;\n}\n\nfunction checkFrequency(\n site: WebsiteRow,\n which: \"maintenance\" | \"testing\",\n reports: ReportRow[],\n findings: PreflightFinding[],\n now: Date,\n): void {\n // Validate the RAW Airtable cell, not the coerced Frequency: toFrequency trims\n // whitespace (so \"Monthly \" schedules), then coerces any still-unrecognized value\n // (typo, renamed option) to \"None\" with a console.warn. That warn only lands in\n // logs — this finding is the structured, surfaced version of the same failure.\n const raw = which === \"maintenance\" ? site.maintenanceFreqRaw : site.testingFreqRaw;\n const coerced = which === \"maintenance\" ? site.maintenanceFreq : site.testingFreq;\n if (raw !== null && raw !== \"\" && coerced === \"None\" && raw.trim() !== \"None\") {\n findings.push({\n level: \"fail\",\n check: \"frequency-unrecognized\",\n message: `${which} frequency cell is '${raw}' — not a recognized Monthly/Quarterly/Yearly/None (even after trimming), so the scheduler treats it as None and the site drops off the calendar; fix the Airtable value`,\n });\n return;\n }\n if (coerced === \"None\") return;\n\n const anchor = which === \"maintenance\" ? site.maintenanceDay : site.testingDay;\n if (!anchor) return;\n // nextDueDate uses `lastSent ?? anchor`: once anything has been sent, the anchor\n // is inert and an old one is normal on every healthy mature site — only warn when\n // the anchor is genuinely the base date the scheduler would use.\n const type: ReportType = which === \"maintenance\" ? \"Maintenance\" : \"Testing\";\n const lastSent = lastSentForType(reports, type);\n if (lastSent && new Date(lastSent).getTime() >= new Date(anchor).getTime()) return;\n const ageDays = (now.getTime() - new Date(anchor).getTime()) / 86_400_000;\n if (ageDays > STALE_ANCHOR_DAYS) {\n findings.push({\n level: \"warn\",\n check: \"anchor-stale\",\n message: `${which} day anchor is ${anchor} (>13 months old) and nothing newer has been sent — \\`report --due\\` will draft a back-dated overdue report; reset the anchor or clear the schedule`,\n });\n }\n}\n\n/**\n * Pure per-site preflight: every check that, left unfixed, makes a report send fail,\n * go to the wrong inbox, or surprise the operator. Read-only over data already\n * fetched; no Airtable handle, no network — trivially testable.\n *\n * Mirrors the send/draft-time validation (recipients + header image in\n * send/orchestrate.ts, Websites-row scores in draft.ts) so problems surface BEFORE\n * draft/approve instead of exploding at `report --send-ready`, and adds the checks\n * send time cannot do: operator-address leftovers, To-override shadowing,\n * pending-draft races, schedule hygiene against the RAW Airtable values.\n */\nexport function preflightSite(\n site: WebsiteRow,\n reports: ReportRow[],\n type: ReportType,\n now: Date,\n): PreflightSiteResult {\n const findings: PreflightFinding[] = [];\n\n // A site that isn't on the checked calendar never drafts, so its send\n // requirements (recipients / header image / scores) can't hurt anyone —\n // checking them anyway would drown the fleet run in fails for legacy hosting\n // rows. Frequency hygiene and pending-draft checks still run: a typo'd cell\n // is exactly HOW a site falls off the calendar, and an already-drafted report\n // can still be approved + sent regardless of schedule.\n const scheduledForType =\n type === \"Announcement\"\n ? true\n : type === \"Maintenance\"\n ? site.maintenanceFreq !== \"None\"\n : site.testingFreq !== \"None\";\n if (!scheduledForType) {\n findings.push({\n level: \"info\",\n check: \"not-scheduled\",\n message: `no ${type} schedule (frequency is None/blank) — send-requirement checks skipped; schedule + pending-draft checks still apply`,\n });\n }\n\n // --- Recipients: same resolution order as the send path (To override, else contact).\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = scheduledForType ? (explicitTo ?? fallbackTo ?? []) : [];\n if (scheduledForType && to.length === 0) {\n findings.push({\n level: \"fail\",\n check: \"recipients-missing\",\n message:\n \"no recipients — Report recipients (To) and point of contact are both empty; the send will throw\",\n });\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `recipient '${addr}' is malformed — use bare addresses only in Report recipients (To) / point of contact`,\n });\n }\n }\n const cc = scheduledForType ? (parseAddresses(site.reportRecipientsCc) ?? []) : [];\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `CC '${addr}' is malformed — fix Report recipients (CC)`,\n });\n }\n }\n if (!isOperatorSite(site)) {\n const operatorAddrs = to.filter((a) => OPERATOR_DOMAINS.includes(domainOf(a)));\n if (operatorAddrs.length > 0) {\n const allOperator = operatorAddrs.length === to.length;\n findings.push({\n level: \"warn\",\n check: \"recipient-operator-address\",\n message: allOperator\n ? `resolved To is ONLY operator address(es) ${operatorAddrs.join(\", \")} — probably a test-send leftover; the client will NOT receive this report`\n : `resolved To includes operator address(es) ${operatorAddrs.join(\", \")} alongside the client's — confirm that's intentional (the ops inbox is already CC'd on every send)`,\n });\n }\n }\n if (scheduledForType && explicitTo && fallbackTo) {\n const a = [...explicitTo]\n .map((x) => x.toLowerCase())\n .sort()\n .join(\",\");\n const b = [...fallbackTo]\n .map((x) => x.toLowerCase())\n .sort()\n .join(\",\");\n if (a !== b) {\n findings.push({\n level: \"info\",\n check: \"to-override-shadows-contact\",\n message: `Report recipients (To) [${explicitTo.join(\", \")}] overrides point of contact [${fallbackTo.join(\", \")}] — the contact won't receive this unless also listed`,\n });\n }\n }\n\n // --- Hard requirements of the draft/send paths, surfaced early.\n if (scheduledForType && !site.headerImage) {\n findings.push({\n level: \"fail\",\n check: \"header-image-missing\",\n message: \"no Header image on the Websites row — the send will throw\",\n });\n } else if (scheduledForType && site.headerImage && !site.headerImage.type.startsWith(\"image/\")) {\n findings.push({\n level: \"fail\",\n check: \"header-image-not-image\",\n message: `Header image attachment is '${site.headerImage.type}' (${site.headerImage.filename}) — not a decodable image; the send throws in prepareHeaderImage`,\n });\n }\n const scores = [site.pScore, site.rScore, site.bpScore, site.seoScore];\n if (scheduledForType && scores.some((s) => s === null)) {\n if (type === \"Announcement\") {\n // announce() skips score-less sites (skipped-no-scores) rather than throwing.\n findings.push({\n level: \"warn\",\n check: \"scores-missing\",\n message:\n \"Websites row is missing Lighthouse scores — `announce` will skip this site; run `audit lighthouse --write-back` first\",\n });\n } else {\n // report <site> / report --due HARD-THROW here (scoresFromWebsite in draft.ts).\n findings.push({\n level: \"fail\",\n check: \"scores-missing\",\n message:\n \"Websites row is missing Lighthouse scores — drafting will throw (scoresFromWebsite); run `audit lighthouse --write-back` first\",\n });\n }\n }\n if (type === \"Announcement\" && site.status !== \"maintained\") {\n findings.push({\n level: \"info\",\n check: \"status-not-maintenance\",\n message: `status is '${site.status ?? \"(blank)\"}' — \\`announce --all\\` only drafts for maintenance sites`,\n });\n }\n\n // --- Pending drafts. For an Announcement these always race (the queue tier rule\n // supersedes or blocks). For Maintenance/Testing, the CURRENT cycle's own draft\n // sitting ready on send day IS the payload — that's the steady state, not a\n // problem — so only genuinely stale/foreign drafts get the warn.\n const pending = reports.filter((r) => r.draftReady && r.sentAt === null);\n if (pending.length > 0) {\n const currentPeriod = reportPeriodKey(now);\n const isExpectedPayload = (r: ReportRow): boolean =>\n type !== \"Announcement\" && r.reportType === type && r.period === currentPeriod;\n const expected = pending.filter(isExpectedPayload);\n const stale = pending.filter((r) => !isExpectedPayload(r));\n if (expected.length > 0) {\n findings.push({\n level: \"info\",\n check: \"pending-drafts\",\n message: `current-cycle ${type} draft is queued${expected[0]!.approvedToSend ? \" and approved\" : \" (awaiting approval)\"} — this is the report \\`--send-ready\\` will deliver`,\n });\n }\n if (stale.length > 0) {\n const summary = stale\n .map(\n (r) =>\n `${r.reportType} ${r.period ?? \"(no period)\"}${r.approvedToSend ? \" [APPROVED]\" : \"\"}`,\n )\n .join(\", \");\n findings.push({\n level: \"warn\",\n check: \"pending-drafts\",\n message: `${stale.length} unsent draft(s) queued: ${summary} — resolve before sending so the client doesn't get the wrong report first`,\n });\n }\n }\n\n // --- Schedule hygiene (validates the RAW Airtable frequency cells).\n checkFrequency(site, \"maintenance\", reports, findings, now);\n checkFrequency(site, \"testing\", reports, findings, now);\n if (type === \"Announcement\") {\n const anySent = lastSentForType(reports, \"Maintenance\") ?? lastSentForType(reports, \"Testing\");\n if (!site.maintenanceDay && !site.testingDay && !anySent) {\n findings.push({\n level: \"info\",\n check: \"anchor-missing\",\n message:\n \"no maintenance/testing day anchors and nothing ever sent — set both anchors to the announcement send date (the Sonder playbook) so recurrence starts from the announcement\",\n });\n }\n }\n\n return { site: site.name, findings };\n}\n\n/**\n * Fleet-level heuristics that need the whole selection. Airtable column renames\n * don't error — the mapper reads null forever (its own header comment admits this).\n * A load-bearing column empty on EVERY selected site is far more likely a rename\n * than N coincidences; say so. Likewise two different sites resolving to one\n * recipient is worth one eyeball (it can be legitimate — same owner, two sites).\n */\nexport function preflightFleet(sites: WebsiteRow[]): PreflightFinding[] {\n if (sites.length < 3) return []; // too few rows to distinguish rename from data\n const findings: PreflightFinding[] = [];\n const allEmpty = (get: (s: WebsiteRow) => unknown, column: string): void => {\n if (sites.every((s) => get(s) === null || get(s) === \"\")) {\n findings.push({\n level: \"warn\",\n check: \"column-possibly-renamed\",\n message: `'${column}' is empty on all ${sites.length} selected sites — if that column was renamed in Airtable the code reads null silently; verify the column name`,\n });\n }\n };\n allEmpty((s) => s.pointOfContact, \"point of contact\");\n allEmpty((s) => s.maintenanceFreqRaw, \"maintenence freq\");\n allEmpty((s) => s.headerImage, \"Header image\");\n\n const byContact = new Map<string, string[]>();\n for (const s of sites) {\n const to = parseAddresses(s.reportRecipientsTo) ?? parseAddresses(s.pointOfContact) ?? [];\n for (const addr of to) {\n const key = addr.toLowerCase();\n byContact.set(key, [...(byContact.get(key) ?? []), s.name]);\n }\n }\n for (const [addr, names] of byContact) {\n if (names.length > 1 && !OPERATOR_DOMAINS.includes(domainOf(addr))) {\n findings.push({\n level: \"info\",\n check: \"duplicate-contact\",\n message: `${names.join(\" and \")} resolve to the same recipient (${addr}) — fine if one person owns both; otherwise a copy-paste error on one row`,\n });\n }\n }\n return findings;\n}\n\nexport type PreflightDeps = {\n /** The fleet roster. Since #646 step 4 preflight reads it from TURSO — read by\n * the command that calls this — so a `site_<ULID>` site is checked like any\n * other. Required, so nothing can reach a store this was not handed. */\n roster: () => Promise<WebsiteRow[]>;\n /** Every report row, on the same contract as `roster`. Called only when the\n * selection is non-empty (it was one whole-table read before, and still is). */\n allReports: () => Promise<ReportRow[]>;\n /** Slug of a single site (matched via siteSlug). Mutually exclusive with `all`. */\n site?: string;\n /** Fleet mode: for Announcement, maintenance-status sites (announce's own filter);\n * for Maintenance/Testing, everything `report --due` schedules — ELIGIBLE_STATUSES\n * plus null-status rows (due.ts treats null as active for legacy rows). */\n all?: boolean;\n type?: ReportType;\n now?: Date;\n};\n\nexport type PreflightResult = {\n results: PreflightSiteResult[];\n fleet: PreflightFinding[];\n};\n\n/**\n * Read-only rollout preflight over the fleet store. One roster read, one reports\n * read (the --due path does the same, and for the same reason: the whole table), then\n * {@link preflightSite} per selected site plus {@link preflightFleet} across the\n * selection. NEVER writes and NEVER sends.\n */\nexport async function preflight(deps: PreflightDeps): Promise<PreflightResult> {\n const type: ReportType = deps?.type ?? \"Announcement\";\n const now = deps?.now ?? new Date();\n\n const websites = await deps.roster();\n const selected = deps?.site\n ? websites.filter((w) => siteSlug(w.name) === siteSlug(deps.site!))\n : type === \"Announcement\"\n ? websites.filter((w) => w.status === \"maintained\")\n : websites.filter((w) => w.status === null || ELIGIBLE_STATUSES.has(w.status));\n\n const allReports = selected.length > 0 ? await deps.allReports() : [];\n const results = selected.map((site) =>\n preflightSite(\n site,\n allReports.filter((r) => r.siteId === site.id),\n type,\n now,\n ),\n );\n return { results, fleet: deps?.site ? [] : preflightFleet(selected) };\n}\n\n/**\n * Health-gate blockers for one report: every GATING field whose evidence is not `pass`/`n/a`\n * (i.e. `fail`, `unknown`, or absent) becomes a fail-level finding, keyed `health-gate`. PURE.\n * Folded into {@link approveBlockers} so health failures ride the existing send-blocked reason +\n * 409 + dashboard chip (the second gate — no third gate is added).\n *\n * A logged send-anyway override ({@link isSendOverridden}) suppresses these findings — but never\n * the REAL send blockers (recipients/header/scores), which are computed outside this function and\n * still gate an overridden send.\n */\nexport function healthBlockers(report: ReportRow): PreflightFinding[] {\n if (isSendOverridden(report)) return [];\n const findings: PreflightFinding[] = [];\n for (const { field, status } of gatingHealth({\n reportType: report.reportType,\n autoEvidence: report.autoEvidence ?? {},\n })) {\n if (status === \"pass\" || status === \"n/a\") continue;\n // `||`, not `??`: an explicit empty-string note (no message recorded) must\n // also fall back to the placeholder, not render as a trailing blank.\n const note = report.autoEvidence?.[field]?.note || \"no signal yet\";\n findings.push({\n level: \"fail\",\n check: \"health-gate\",\n message:\n status === \"fail\"\n ? `${field}: failing — ${note}`\n : `${field}: not yet green (${status}) — ${note}`,\n });\n }\n return findings;\n}\n\n/**\n * The send-blocking subset for ONE report, at approve time: exactly the\n * conditions that make `sendOne` throw (no recipients, malformed To/CC, no\n * header image, no report-level Lighthouse scores) plus the wrong-inbox warn\n * (operator address resolved as a client's To) plus every {@link healthBlockers}\n * finding (a gating checklist item whose auto-evidence isn't `pass`/`n/a` fails\n * the health gate). PURE — the approve gate, the dashboard's pending-row chip,\n * and the digest collector all call this one function so \"approvable\" can't\n * drift from \"sendable\".\n *\n * Deliberately narrower than {@link preflightSite}: schedule hygiene and\n * pending-draft races are fleet/site concerns — blocking THIS report's approval\n * on them would be over-gating.\n */\nexport function approveBlockers(site: WebsiteRow, report: ReportRow): PreflightFinding[] {\n const findings: PreflightFinding[] = [];\n\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n findings.push({\n level: \"fail\",\n check: \"recipients-missing\",\n message:\n \"no recipients — Report recipients (To) and point of contact are both empty; the send will throw\",\n });\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `recipient '${addr}' is malformed — fix Report recipients (To) / point of contact in Airtable`,\n });\n }\n }\n for (const addr of parseAddresses(site.reportRecipientsCc) ?? []) {\n if (!isProbablyEmail(addr)) {\n findings.push({\n level: \"fail\",\n check: \"recipients-malformed\",\n message: `CC '${addr}' is malformed — fix Report recipients (CC) in Airtable`,\n });\n }\n }\n if (!site.headerImage) {\n findings.push({\n level: \"fail\",\n check: \"header-image-missing\",\n message: \"no Header image on the Websites row — the send will throw\",\n });\n } else if (!site.headerImage.type.startsWith(\"image/\")) {\n findings.push({\n level: \"fail\",\n check: \"header-image-not-image\",\n message: `Header image attachment is '${site.headerImage.type}' (${site.headerImage.filename}) — not a decodable image; the send throws in prepareHeaderImage`,\n });\n }\n // sendOne throws on a null report.lighthouse (one blank/non-numeric cell nulls\n // all four) — the REPORT's snapshot, not the Websites-row scores.\n if (report.lighthouse === null) {\n findings.push({\n level: \"fail\",\n check: \"report-scores-missing\",\n message:\n \"the Reports row has no Lighthouse scores (one blank/non-numeric cell nulls all four) — the send will throw\",\n });\n }\n if (!isOperatorSite(site)) {\n const operatorAddrs = to.filter((a) => OPERATOR_DOMAINS.includes(domainOf(a)));\n if (operatorAddrs.length > 0 && operatorAddrs.length === to.length) {\n findings.push({\n level: \"warn\",\n check: \"recipient-operator-address\",\n message: `resolved To is ONLY operator address(es) ${operatorAddrs.join(\", \")} — the client will NOT receive this report`,\n });\n }\n }\n findings.push(...healthBlockers(report));\n return findings;\n}\n\n/** Convenience: just the fail-level blockers, formatted for gate messages. */\nexport function formatBlockers(findings: PreflightFinding[]): string[] {\n return findings.filter((f) => f.level === \"fail\").map((f) => `${f.check}: ${f.message}`);\n}\n"],"mappings":"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","names":[]}
@@ -5,11 +5,11 @@ import {
5
5
  mapWebsiteRecord,
6
6
  scheduleColumnFor,
7
7
  siteValueFor
8
- } from "./chunk-UIITN37J.js";
8
+ } from "./chunk-LF2TJN4X.js";
9
9
  import {
10
10
  parseAutoEvidence,
11
11
  toReportType
12
- } from "./chunk-F3JWOPTG.js";
12
+ } from "./chunk-ASLDCWBF.js";
13
13
  import {
14
14
  MAINTENANCE_CHECKLIST,
15
15
  TESTING_CHECKLIST
@@ -22,7 +22,7 @@ import {
22
22
  toPrismicModelsVerdict,
23
23
  toVerdict,
24
24
  trimToNull
25
- } from "./chunk-KN52PM63.js";
25
+ } from "./chunk-ICSRWW5Q.js";
26
26
 
27
27
  // src/db/fleet-state.ts
28
28
  import { sql } from "kysely";
@@ -199,6 +199,35 @@ async function mirrorHealthFields(db, siteId, fields) {
199
199
  const res = await db.updateTable("site_health").set(patch).where("site_id", "=", siteId).executeTakeFirst();
200
200
  return res.numUpdatedRows > 0n;
201
201
  }
202
+ async function insertSiteRows(db, site, computedAt) {
203
+ await db.insertInto("sites").values({
204
+ id: site.id,
205
+ slug: site.slug,
206
+ name: site.name,
207
+ status: site.status,
208
+ url: site.url,
209
+ point_of_contact: site.pointOfContact,
210
+ git_repo: site.gitRepo,
211
+ require_turnstile: 0
212
+ }).execute();
213
+ await db.insertInto("site_health").values({ site_id: site.id }).execute();
214
+ await db.insertInto("site_schedule").values({
215
+ site_id: site.id,
216
+ next_maintenance_at: null,
217
+ next_testing_at: null,
218
+ computed_at: computedAt
219
+ }).execute();
220
+ }
221
+ async function updateSiteIdentity(db, siteId, patch) {
222
+ const set = {};
223
+ if (patch.name !== void 0) set.name = patch.name;
224
+ if (patch.url !== void 0) set.url = patch.url;
225
+ if (patch.pointOfContact !== void 0) set.point_of_contact = patch.pointOfContact;
226
+ if (patch.gitRepo !== void 0) set.git_repo = patch.gitRepo;
227
+ if (Object.keys(set).length === 0) return true;
228
+ const res = await db.updateTable("sites").set(set).where("id", "=", siteId).executeTakeFirst();
229
+ return res.numUpdatedRows > 0n;
230
+ }
202
231
  async function mirrorScheduleFields(db, siteId, fields, computedAt) {
203
232
  const patch = {};
204
233
  for (const [field, value] of Object.entries(fields)) {
@@ -332,6 +361,10 @@ async function listReportsForSite(db, siteId) {
332
361
  const rows = await db.selectFrom("reports").select(REPORT_LIST_COLUMNS).select(HAS_RENDERED_HTML).where("site_id", "=", siteId).orderBy("period_start", "desc").orderBy("id").execute();
333
362
  return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));
334
363
  }
364
+ async function listSendableReports(db) {
365
+ const rows = await db.selectFrom("reports").select(REPORT_LIST_COLUMNS).select(HAS_RENDERED_HTML).where("draft_ready", "=", 1).where("approved_to_send", "=", 1).where("sent_at", "is", null).orderBy("period_start", "desc").orderBy("id").execute();
366
+ return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));
367
+ }
335
368
  async function mirrorReportPatch(db, reportId, patch) {
336
369
  if (Object.keys(patch).length === 0) return true;
337
370
  const res = await db.updateTable("reports").set(patch).where("id", "=", reportId).executeTakeFirst();
@@ -343,6 +376,9 @@ async function mirrorReportInsert(db, rec) {
343
376
  void _rh;
344
377
  await db.insertInto("reports").values(row).onConflict((oc) => oc.column("id").doUpdateSet(rowSansHtml)).execute();
345
378
  }
379
+ async function insertReportRow(db, rec) {
380
+ await db.insertInto("reports").values(mapReportRecord(rec, null)).execute();
381
+ }
346
382
  async function storeRenderedHtml(db, reportId, html) {
347
383
  await db.updateTable("reports").set({ rendered_html: html }).where("id", "=", reportId).execute();
348
384
  }
@@ -350,6 +386,10 @@ async function getReportById(db, id) {
350
386
  const r = await db.selectFrom("reports").selectAll().where("id", "=", id).executeTakeFirst();
351
387
  return r ? reportRowFromDb(r, r.rendered_html !== null) : null;
352
388
  }
389
+ async function findReportByMessageId(db, messageId) {
390
+ const r = await db.selectFrom("reports").select(REPORT_LIST_COLUMNS).select(HAS_RENDERED_HTML).where("resend_message_id", "=", messageId).limit(1).executeTakeFirst();
391
+ return r ? reportRowFromDb(r, r.has_rendered_html !== 0) : null;
392
+ }
353
393
  async function getReportHtml(db, id) {
354
394
  const r = await db.selectFrom("reports").select(["rendered_html", "report_id"]).where("id", "=", id).executeTakeFirst();
355
395
  if (!r || r.rendered_html === null) return null;
@@ -361,6 +401,8 @@ export {
361
401
  mirrorSiteFields,
362
402
  mirrorSiteInsert,
363
403
  mirrorHealthFields,
404
+ insertSiteRows,
405
+ updateSiteIdentity,
364
406
  mirrorScheduleFields,
365
407
  getSiteBySlug,
366
408
  siteRowExists,
@@ -369,10 +411,13 @@ export {
369
411
  REPORT_LIST_COLUMNS,
370
412
  listAllReports,
371
413
  listReportsForSite,
414
+ listSendableReports,
372
415
  mirrorReportPatch,
373
416
  mirrorReportInsert,
417
+ insertReportRow,
374
418
  storeRenderedHtml,
375
419
  getReportById,
420
+ findReportByMessageId,
376
421
  getReportHtml
377
422
  };
378
- //# sourceMappingURL=chunk-GEUC5VGM.js.map
423
+ //# sourceMappingURL=chunk-BINFZQ2T.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/fleet-state.ts"],"sourcesContent":["/** Phase 2 of the Airtable → Turso migration (#539): the fleet-state READ layer.\n *\n * Returns the exact `WebsiteRow` shape the Airtable module returns, so every\n * repoint is an import-only swap at a composition root — the same trick\n * listSubmissionsForSite used for the hybrid-db cutover. The raw values live\n * in `sites`/`site_health`/`site_schedule` (written by the importer / hourly\n * sync); coercion to `WebsiteRow` happens HERE, reusing the same coercers the\n * Airtable module's `mapRow` applies (`toVerdict`, `toFrequency`,\n * `parseNotifyRouting`, `parseSecurityAdvisories`, `trimToNull`) so there is one\n * truth for each. Those live in `src/fleet/site-row.ts` — outside the Airtable\n * directory Phase 6 deletes (#646 step 1; pinned by\n * tests/db/no-airtable-value-imports.test.ts).\n *\n * The equivalence instrument (tests/db/fleet-state.test.ts) pins this module\n * to `mapRow` field-by-field: for a fixture record, `mapRow(rec)` must deep-\n * equal the row read back through here after an import. A new WebsiteRow\n * field fails that test until this module carries it.\n *\n * `headerImage` is the ONE deliberate exception (design D5): Airtable stopped\n * being its source — the bytes belong in `sites.header_image*`. Those columns\n * ARE now written: the header-image CLI dual-writes on every generation and the\n * one-shot backfill copied the rest, so as of 2026-08-25 production carries a\n * BLOB for 12 of the 13 maintained sites (the 13th, LA Homelessness Youth, has\n * no header image in Airtable either — its reports are blocked at approve for\n * exactly that). An earlier version of this comment said the columns were empty\n * fleet-wide; that was true when written and is no longer.\n *\n * The `url` is \"\" because the bytes live in the row itself, not behind a signed\n * URL — which is why `url` is NOT a usable handle. A consumer that needs the\n * image calls `loadHeaderImage(db, siteId)` for the bytes; the send path still\n * fetches the Airtable attachment, and moving it over is its own change.\n */\nimport { sql, type Selectable, type Updateable } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { ReportsTable, SiteHealthTable, SiteScheduleTable, SitesTable } from \"./schema.js\";\nimport {\n SITE_FIELDS,\n siteValueFor,\n healthColumnFor,\n scheduleColumnFor,\n mapReportRecord,\n mapWebsiteRecord,\n type RawRecord,\n} from \"./import-airtable.js\";\nimport type { AirtableCellValue } from \"../reports/airtable/websites.js\";\nimport {\n toReportType,\n parseAutoEvidence,\n type ReportRow,\n type DeliveryStatus,\n} from \"../reports/report-row.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\nimport { canonicalizeStatus } from \"../fleet/site-status.js\";\nimport {\n parseNotifyRouting,\n parseSecurityAdvisories,\n toFrequency,\n toPrismicModelsVerdict,\n toVerdict,\n trimToNull,\n type WebsiteRow,\n} from \"../fleet/site-row.js\";\n\ntype JoinedRow = Record<string, unknown>;\n\nconst str = (v: unknown): string | null => (typeof v === \"string\" ? v : null);\nconst num = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\n/** Stored 1/0/null (importer's b01n) → boolean | null. */\nconst bool = (v: unknown): boolean | null => (typeof v === \"number\" ? v !== 0 : null);\n\n/** Stored JSON array (importer-normalized) → the trimmed string[] mapRow yields. */\nfunction parseAwc(raw: unknown): string[] {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return [];\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n return parsed\n .filter((x): x is string => typeof x === \"string\")\n .map((s) => s.trim())\n .filter(Boolean);\n } catch {\n return [];\n }\n}\n\nfunction rowFromJoined(r: JoinedRow): WebsiteRow {\n const name = String(r.name ?? \"\");\n const headerFilename = str(r.header_image_filename);\n const headerType = str(r.header_image_type);\n return {\n id: String(r.id),\n name,\n url: str(r.url) ?? \"\",\n // `sites.status` holds the RAW Airtable cell (the importer stores it verbatim\n // so the hourly parity check compares raw-to-raw). Canonicalizing HERE — the\n // twin of mapRow's seam — is what keeps the #558 equivalence instrument green.\n status: canonicalizeStatus(r.status),\n statusRaw: str(r.status),\n pointOfContact: str(r.point_of_contact),\n maintenanceFreq: toFrequency(r.maintenance_freq, `${name} maintenance`),\n testingFreq: toFrequency(r.testing_freq, `${name} testing`),\n maintenanceFreqRaw: str(r.maintenance_freq),\n testingFreqRaw: str(r.testing_freq),\n maintenanceDay: str(r.maintenance_day),\n testingDay: str(r.testing_day),\n ga4PropertyId: str(r.ga4_property_id),\n searchQuery: str(r.search_query),\n searchConsoleProperty: str(r.search_console_property),\n analyticsSoftFailAt: str(r.analytics_soft_fail_at),\n gitRepo: str(r.git_repo),\n reportRecipientsTo: str(r.report_recipients_to),\n reportRecipientsCc: str(r.report_recipients_cc),\n acceptedWatchConditions: parseAwc(r.accepted_watch_conditions),\n headerImage:\n headerFilename !== null && headerType !== null\n ? { url: \"\", filename: headerFilename, type: headerType }\n : null,\n pScore: num(r.p_score),\n rScore: num(r.r_score),\n bpScore: num(r.bp_score),\n seoScore: num(r.seo_score),\n lastLighthouseAuditAt: str(r.lighthouse_at),\n a11yViolations: num(r.a11y_violations),\n depsDrifted: num(r.deps_drifted),\n depsMajorBehind: num(r.deps_major_behind),\n depsOutdated: num(r.deps_outdated),\n depsMajorOutdated: num(r.deps_major_outdated),\n securityVulnsCritical: num(r.vulns_critical),\n securityVulnsHigh: num(r.vulns_high),\n securityVulnsModerate: num(r.vulns_moderate),\n securityVulnsLow: num(r.vulns_low),\n securityAutoFixAttempts: num(r.auto_fix_attempts),\n lastSecurityAuditAt: str(r.security_audit_at),\n securityAdvisories: parseSecurityAdvisories(r.security_advisories),\n certDaysRemaining: num(r.cert_days_remaining),\n domainCheckedAt: str(r.domain_checked_at),\n netlifyId: trimToNull(r.netlify_id),\n deployStatus: str(r.deploy_status),\n lastDeployAt: str(r.last_deploy_at),\n deployLogUrl: str(r.deploy_log_url),\n deployCheckedAt: str(r.deploy_checked_at),\n functionHealth: toVerdict(r.function_health),\n cmsReachable: toVerdict(r.cms_reachable),\n turnstileWidget: toVerdict(r.turnstile_widget),\n functionHealthCheckedAt: str(r.function_health_checked_at),\n crossbrowserOk: bool(r.crossbrowser_ok),\n mobileOk: bool(r.mobile_ok),\n linksOk: bool(r.links_ok),\n brokenLinks: num(r.broken_links),\n browserCheckedAt: str(r.browser_checked_at),\n reachableOk: toVerdict(r.uptime_reachable),\n titleMetaOk: toVerdict(r.titles_meta_ok),\n copyIntro: trimToNull(r.copy_intro),\n copyContact: trimToNull(r.copy_contact),\n copyFooter: trimToNull(r.copy_footer),\n launchedAt: str(r.launched_at),\n newsletterWebhook: trimToNull(r.newsletter_webhook),\n notifyRouting: parseNotifyRouting(r.notify_routing),\n notifyRoutingRaw: trimToNull(r.notify_routing),\n mailchimpApiKey: trimToNull(r.mailchimp_api_key),\n mailchimpAudienceId: trimToNull(r.mailchimp_audience_id),\n requireTurnstile: typeof r.require_turnstile === \"number\" ? r.require_turnstile !== 0 : false,\n renovateFailingCis: num(r.renovate_failing_cis),\n defaultBranchCi: str(r.default_branch_ci),\n lastCommitAt: str(r.last_commit_at),\n githubSignalsAt: str(r.github_signals_at),\n smokeOk: toVerdict(r.smoke_ok),\n lastSmokeAt: str(r.last_smoke_at),\n formE2eOk: toVerdict(r.form_e2e_ok),\n formE2eCheckedAt: str(r.form_e2e_checked_at),\n prismicModels: toPrismicModelsVerdict(r.prismic_models),\n prismicModelsCheckedAt: str(r.prismic_models_checked_at),\n prismicModelsDrift: str(r.prismic_models_drift),\n prismicAckUntil: str(r.prismic_ack_until),\n nextMaintenanceAt: str(r.next_maintenance_at),\n nextTestingAt: str(r.next_testing_at),\n };\n}\n\n/** Every sites column EXCEPT the header-image BLOB. Since the 2026-08-24\n * backfill, header_image holds 0.7–3.5 MB JPEGs — a selectAll would haul\n * megabytes into every site lookup (per LEAD on the ingest path, ×44 on the\n * fleet list) and Turso bills the bytes. WebsiteRow only needs the metadata\n * columns; the bytes have their own dedicated readers when a consumer wants\n * them. Kept in lockstep with SitesTable by the blob-exclusion test. */\nconst SITE_COLUMNS = [\n \"sites.id\",\n \"sites.slug\",\n \"sites.name\",\n \"sites.url\",\n \"sites.status\",\n \"sites.point_of_contact\",\n \"sites.maintenance_freq\",\n \"sites.testing_freq\",\n \"sites.maintenance_day\",\n \"sites.testing_day\",\n \"sites.ga4_property_id\",\n \"sites.search_query\",\n \"sites.search_console_property\",\n \"sites.git_repo\",\n \"sites.netlify_id\",\n \"sites.report_recipients_to\",\n \"sites.report_recipients_cc\",\n \"sites.copy_intro\",\n \"sites.copy_contact\",\n \"sites.copy_footer\",\n \"sites.newsletter_webhook\",\n \"sites.mailchimp_api_key\",\n \"sites.mailchimp_audience_id\",\n \"sites.notify_routing\",\n \"sites.require_turnstile\",\n \"sites.accepted_watch_conditions\",\n \"sites.prismic_ack_until\",\n \"sites.launched_at\",\n \"sites.header_image_filename\",\n \"sites.header_image_type\",\n \"sites.header_image_generated_at\",\n \"sites.legacy\",\n] as const;\n\n/** The three-table join every read composes from. site_health/site_schedule\n * rows are upserted alongside sites by the importer, but LEFT JOIN anyway —\n * a site row must never vanish from a surface because a health row is absent. */\nfunction joined(db: Db) {\n return db\n .selectFrom(\"sites\")\n .leftJoin(\"site_health\", \"site_health.site_id\", \"sites.id\")\n .leftJoin(\"site_schedule\", \"site_schedule.site_id\", \"sites.id\")\n .select(SITE_COLUMNS)\n .selectAll([\"site_health\", \"site_schedule\"]);\n}\n\n/** Write-through for the site-detail editor (Phase 2): after the Airtable\n * write (still the Phase 2 source of truth), mirror the same cell into\n * `sites` so a Turso-reading page renders the edit immediately instead of\n * waiting for the next hourly sync. The column mapping is the IMPORTER's own\n * map — one truth — and the value gets the importer's empty-clears-to-null\n * semantics. Throws on a column the importer doesn't claim (the lockstep\n * test makes that unreachable for the editor's allowlist); the caller treats\n * a mirror failure as non-fatal — the hourly sync converges it.\n *\n * Handles the non-text columns too (`Require Turnstile` is a checkbox,\n * `Accepted Watch Conditions` a multi-select): the coercion is NOT repeated\n * here, it is delegated to the importer's own `siteValueFor`. That delegation\n * is the whole safety property — parity compares raw-to-raw, so a mirror that\n * stored `\"true\"` where the importer stores `1` would red every hourly run. */\nexport async function mirrorSiteField(\n db: Db,\n siteId: string,\n airtableColumn: string,\n value: AirtableCellValue,\n): Promise<void> {\n await mirrorSiteFields(db, siteId, { [airtableColumn]: value });\n}\n\n/** The multi-column form, and where the work actually happens (#539 Phase 5).\n *\n * `updateLaunched` is why it exists: it flips `Status` AND stamps `Launched at`\n * in one Airtable update, and mirroring those as two separate UPDATEs would\n * open a window where Turso says a site is maintained but never launched.\n *\n * Same contract as {@link mirrorHealthFields}, deliberately — it takes the\n * EXACT FieldSet just written to Airtable (the writers return it, so the mirror\n * cannot carry a different payload), and returns whether a `sites` row matched:\n * a site the hourly sync hasn't imported yet updates 0 rows → false, so the\n * caller can count it honestly instead of claiming it mirrored. An empty\n * FieldSet runs no SQL and returns true — nothing to mirror is not a miss. */\nexport async function mirrorSiteFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n): Promise<boolean> {\n const patch: Record<string, string | number | null> = {};\n for (const [airtableColumn, value] of Object.entries(fields)) {\n const col = SITE_FIELDS[airtableColumn];\n if (!col)\n throw new Error(`mirrorSiteFields: importer claims no sites column for '${airtableColumn}'`);\n // Empty text still clears to null (the importer's `s()` does the same); the\n // non-text columns get their own coercion inside siteValueFor.\n patch[col] = siteValueFor(col, value);\n }\n if (Object.keys(patch).length === 0) return true;\n const res = await db\n .updateTable(\"sites\")\n .set(patch as Updateable<SitesTable>)\n .where(\"id\", \"=\", siteId)\n .executeTakeFirst();\n // kysely/libSQL reports numUpdatedRows as a BigInt — compare in BigInt.\n return res.numUpdatedRows > 0n;\n}\n\n/** Mirror a NEWLY CREATED Airtable Websites record into Turso (#539 Phase 5).\n *\n * Since #646 step 3 a new site is created in Turso by {@link insertSiteRows}\n * instead; this survives as the #645 heal's ADOPT — an Airtable site with no\n * Turso row is inserted under its own `rec` id by `ensure-site`.\n *\n * `ensure-site` CREATES a row, and every other site mirror is an UPDATE, which\n * does nothing at all for a row that does not exist yet — so a site\n * bootstrapped at 09:05 was invisible until the 09:20 sync, and every mirror\n * the rest of the bootstrap fired reported `mirrored=missed` with nothing to\n * update.\n *\n * Maps with the IMPORTER's own `mapWebsiteRecord`, which is also exactly what\n * parity diffs against: that delegation is what makes the mirrored rows\n * parity-clean by construction rather than by a column list someone has to\n * remember to extend.\n *\n * All THREE rows, not just `sites`. Parity reverse-checks `site_health` and\n * `site_schedule` per site and reports a missing one as `(row) ABSENT`, and a\n * later `mirrorHealthFields` would return `missed` forever with no row to hit.\n *\n * Upserts rather than inserts because `ensure-site` is re-run to RESUME a\n * bootstrap. The header_image* columns survive that by construction —\n * `mapWebsiteRecord` does not carry them (Airtable stopped being their source,\n * design D5), so the conflict branch cannot blank a stored plate whose bytes\n * live in no other store. */\nexport async function mirrorSiteInsert(db: Db, rec: RawRecord, computedAt: string): Promise<void> {\n const { site, health, schedule } = mapWebsiteRecord(rec, computedAt);\n await db\n .insertInto(\"sites\")\n .values(site)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(site))\n .execute();\n await db\n .insertInto(\"site_health\")\n .values(health)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(health))\n .execute();\n await db\n .insertInto(\"site_schedule\")\n .values(schedule)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(schedule))\n .execute();\n}\n\n/** Write-through mirror for the nightly health writers (#539 Phase 3). Takes\n * the EXACT FieldSet just written to Airtable (updateAuditFields /\n * updateGitHubSignals return it), so the mirror can never carry a different\n * payload than the Airtable write it shadows. Resolution + coercion come from\n * the importer's healthColumnFor — one truth; an Airtable column no\n * site_health column claims throws (same contract as mirrorSiteField).\n * Partial by design: absent fields stay untouched, matching\n * updateGitHubSignals' deliberate omission of a null lastCommitAt. Returns\n * whether a site_health row matched: a site the hourly sync hasn't imported\n * yet updates 0 rows → false, so the caller can count it honestly\n * (mirror_missed) instead of claiming it mirrored — it still converges on the\n * next sync, like every mirror. An empty FieldSet runs no SQL and returns\n * true: nothing to mirror is not a miss. */\nexport async function mirrorHealthFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n): Promise<boolean> {\n // Per-column value types (number vs text) are guaranteed by healthColumnFor's\n // coercion — the numeric/text split lives there, once — so the patch builds\n // untyped and casts at the .set() boundary (the importer's own idiom).\n const patch: Record<string, string | number | null> = {};\n for (const [field, value] of Object.entries(fields)) {\n const m = healthColumnFor(field);\n if (!m)\n throw new Error(`mirrorHealthFields: importer claims no site_health column for '${field}'`);\n patch[m.col] = m.coerce(value);\n }\n if (Object.keys(patch).length === 0) return true;\n const res = await db\n .updateTable(\"site_health\")\n .set(patch as Updateable<SiteHealthTable>)\n .where(\"site_id\", \"=\", siteId)\n .executeTakeFirst();\n // kysely/libSQL reports numUpdatedRows as a BigInt — compare in BigInt.\n return res.numUpdatedRows > 0n;\n}\n\n/** A site as the Turso-native creator INSERTS it (#646 step 3). Only the columns\n * `ensure-site` owns; everything else starts at its schema default (null, or 0\n * for `require_turnstile`) exactly as an imported Airtable row with blank cells\n * did. */\nexport type NewSiteRow = {\n id: string;\n slug: string;\n name: string;\n status: string;\n url: string | null;\n pointOfContact: string | null;\n gitRepo: string | null;\n};\n\n/** Insert a brand-new site's three rows: `sites`, `site_health`, `site_schedule`.\n *\n * Plain INSERTs, not upserts — the id was just minted, so a conflict on any of\n * the three primary keys, or on `sites.slug`, means something is wrong and must\n * fail loudly rather than overwrite. Atomicity is the CALLER's: `src/db/site-\n * create.ts` runs this inside one transaction (a Kysely `Transaction` is a\n * `Db`), so a failure on the third insert leaves none of the three behind.\n * Why all three: every reader LEFT JOINs the companions, but the nightly health\n * and schedule writers are UPDATEs, and under the freeze an UPDATE that matches\n * no row throws (`mirrored=missed`). */\nexport async function insertSiteRows(db: Db, site: NewSiteRow, computedAt: string): Promise<void> {\n await db\n .insertInto(\"sites\")\n .values({\n id: site.id,\n slug: site.slug,\n name: site.name,\n status: site.status,\n url: site.url,\n point_of_contact: site.pointOfContact,\n git_repo: site.gitRepo,\n require_turnstile: 0,\n })\n .execute();\n await db.insertInto(\"site_health\").values({ site_id: site.id }).execute();\n await db\n .insertInto(\"site_schedule\")\n .values({\n site_id: site.id,\n next_maintenance_at: null,\n next_testing_at: null,\n computed_at: computedAt,\n })\n .execute();\n}\n\n/** The columns `ensure-site` may change on an EXISTING site: its fill-blanks\n * inputs and the `--name` retitle. `slug` is absent BY TYPE — it is the stable\n * external handle (URLs, `/api/forms/:slug`, deployed sites) and no rename may\n * move it (operator decision 2026-09-17). */\nexport type SiteIdentityPatch = {\n name?: string;\n url?: string;\n pointOfContact?: string;\n gitRepo?: string;\n};\n\n/** Apply a {@link SiteIdentityPatch} to one `sites` row by PK. Returns whether a\n * row matched; an empty patch runs no SQL and returns true (nothing to write is\n * not a miss) — the same contract as {@link mirrorSiteFields}. */\nexport async function updateSiteIdentity(\n db: Db,\n siteId: string,\n patch: SiteIdentityPatch,\n): Promise<boolean> {\n const set: Updateable<SitesTable> = {};\n if (patch.name !== undefined) set.name = patch.name;\n if (patch.url !== undefined) set.url = patch.url;\n if (patch.pointOfContact !== undefined) set.point_of_contact = patch.pointOfContact;\n if (patch.gitRepo !== undefined) set.git_repo = patch.gitRepo;\n if (Object.keys(set).length === 0) return true;\n const res = await db.updateTable(\"sites\").set(set).where(\"id\", \"=\", siteId).executeTakeFirst();\n return res.numUpdatedRows > 0n;\n}\n\n/** The site_schedule twin of {@link mirrorHealthFields}, for the nightly\n * next-due write-back. `computedAt` stamps when THIS computation ran — the\n * hourly sync overwrites it with its own import stamp, same as every mirrored\n * value. Empty fields → full no-op (no lone computed_at stamp for a write\n * that carried nothing). Same return contract as mirrorHealthFields: false\n * when the UPDATE matched no site_schedule row (site not yet imported), true\n * otherwise — including the empty-fields no-op. */\nexport async function mirrorScheduleFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n computedAt: string,\n): Promise<boolean> {\n const patch: Record<string, string | null> = {};\n for (const [field, value] of Object.entries(fields)) {\n const m = scheduleColumnFor(field);\n if (!m)\n throw new Error(\n `mirrorScheduleFields: importer claims no site_schedule column for '${field}'`,\n );\n patch[m.col] = m.coerce(value);\n }\n if (Object.keys(patch).length === 0) return true;\n patch.computed_at = computedAt;\n const res = await db\n .updateTable(\"site_schedule\")\n .set(patch as Updateable<SiteScheduleTable>)\n .where(\"site_id\", \"=\", siteId)\n .executeTakeFirst();\n return res.numUpdatedRows > 0n;\n}\n\n/** Same contract as the Airtable getWebsiteBySlug: slug is siteSlug(Name),\n * precomputed into the UNIQUE sites.slug column at import time. */\nexport async function getSiteBySlug(db: Db, slug: string): Promise<WebsiteRow | null> {\n const r = await joined(db).where(\"sites.slug\", \"=\", slug).executeTakeFirst();\n return r ? rowFromJoined(r as JoinedRow) : null;\n}\n\n/** #645. Does `sites` hold this row at all? One column, no join, no row mapping —\n * `getSiteById` would answer the same question (its joins are LEFT, so a site\n * missing its site_health/site_schedule companions still resolves) but it pays\n * for a full `rowFromJoined` to return a boolean, and it would tie the heal's\n * decision to whatever that mapper does next. `sites.id` is the PK and it is\n * exactly what `mirrorSiteInsert` conflicts on. */\nexport async function siteRowExists(db: Db, siteId: string): Promise<boolean> {\n const r = await db.selectFrom(\"sites\").select(\"id\").where(\"id\", \"=\", siteId).executeTakeFirst();\n return r !== undefined;\n}\n\n/** By Airtable rec id (the PK) — approve-report's lookup shape. */\nexport async function getSiteById(db: Db, id: string): Promise<WebsiteRow | null> {\n const r = await joined(db).where(\"sites.id\", \"=\", id).executeTakeFirst();\n return r ? rowFromJoined(r as JoinedRow) : null;\n}\n\n/** Same contract as the Airtable listWebsites: every site, one row each.\n * Name-ordered for determinism (Airtable returned table order; no consumer\n * is order-sensitive — the cockpit groups and sorts itself). */\nexport async function listSites(db: Db): Promise<WebsiteRow[]> {\n const rows = await joined(db).orderBy(\"sites.name\").execute();\n return rows.map((r) => rowFromJoined(r as JoinedRow));\n}\n\n// ————————————————————————— reports —————————————————————————\n\n/** stable checklist key → Airtable column name. The importer stores stable keys\n * (mapReportRecord); `ReportRow.checklist` exposes Airtable column names — one\n * derived map, built from the checklist definitions themselves. */\nconst CHECKLIST_FIELD_BY_KEY: ReadonlyMap<string, string> = new Map(\n [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map((i) => [i.key, i.field]),\n);\n\nfunction checklistFromStored(raw: string | null): Record<string, boolean> {\n let stored: Record<string, unknown> = {};\n if (typeof raw === \"string\" && raw.trim() !== \"\") {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed))\n stored = parsed as Record<string, unknown>;\n } catch {\n // fall through to all-false — a bad blob must not throw the page\n }\n }\n const out: Record<string, boolean> = {};\n for (const [key, field] of CHECKLIST_FIELD_BY_KEY) out[field] = Boolean(stored[key]);\n return out;\n}\n\n/**\n * `hasRenderedHtml` is passed separately rather than read off `r`, because the\n * list read deliberately does not fetch the body (see REPORT_LIST_COLUMNS) and\n * only ever needed its null-ness. Taking it as an argument means a list row and a\n * full row are both honest inputs, and nothing here can start depending on body\n * CONTENT without the type changing to say so.\n */\nfunction reportRowFromDb(\n r: Omit<Selectable<ReportsTable>, \"rendered_html\">,\n hasRenderedHtml: boolean,\n): ReportRow {\n const p = r.lighthouse_performance;\n const a = r.lighthouse_accessibility;\n const b = r.lighthouse_best_practices;\n const seo = r.lighthouse_seo;\n const lighthouse =\n typeof p === \"number\" &&\n typeof a === \"number\" &&\n typeof b === \"number\" &&\n typeof seo === \"number\"\n ? { performance: p, accessibility: a, bestPractices: b, seo }\n : null;\n return {\n id: r.id,\n reportId: r.report_id ?? \"\",\n siteId: r.site_id ?? \"\",\n reportType: toReportType(r.report_type ?? undefined),\n period: r.period,\n periodStart: r.period_start,\n periodEnd: r.period_end,\n completedOn: r.completed_on,\n lighthouse,\n gaUsersCurrent: r.ga_users_current,\n gaUsersPrevious: r.ga_users_previous,\n searchFoundPage1: bool(r.search_found_page1),\n searchPosition: r.search_position,\n lastTestedDate: r.last_tested_date,\n commentary: r.commentary,\n subjectOverride: r.subject_override,\n draftReady: r.draft_ready !== 0,\n approvedToSend: r.approved_to_send !== 0,\n sentAt: r.sent_at,\n approvedAt: r.approved_at,\n approvedBy: r.approved_by,\n deliveryStatus: (r.delivery_status ?? \"pending\") as DeliveryStatus,\n // The body lives IN the row (rendered_html) — the link points at the\n // dashboard's own preview route instead of an EXPIRING Airtable signed URL.\n // Strictly better for the operator: the old link 404'd once the URL aged out.\n renderedHtmlAttachment: hasRenderedHtml\n ? { url: `/api/reports/${r.id}/preview`, filename: `${r.report_id ?? r.id}.html` }\n : null,\n resendMessageId: r.resend_message_id,\n checklist: checklistFromStored(r.checklist),\n autoEvidence: parseAutoEvidence(r.checklist_auto_evidence),\n sendOverride: r.send_override !== 0,\n overrideReason: r.override_reason,\n overrideBy: r.override_by,\n overrideAt: r.override_at,\n };\n}\n\n/** Every reports column EXCEPT the rendered_html body — the exact hazard\n * SITE_COLUMNS guards against on `sites`, on the table where it currently costs\n * more. Live, all 16 reports carry a body: 1.17 MB total, avg 73 KB. A list read\n * wants the body only to know WHETHER there is one, so `has_rendered_html`\n * computes that in SQL and the bytes stay in the database. Kept in lockstep with\n * ReportsTable by the body-exclusion test. */\nexport const REPORT_LIST_COLUMNS = [\n \"id\",\n \"site_id\",\n \"report_id\",\n \"report_type\",\n \"period\",\n \"period_start\",\n \"period_end\",\n \"completed_on\",\n \"lighthouse_performance\",\n \"lighthouse_accessibility\",\n \"lighthouse_best_practices\",\n \"lighthouse_seo\",\n \"ga_users_current\",\n \"ga_users_previous\",\n \"search_found_page1\",\n \"search_position\",\n \"last_tested_date\",\n \"commentary\",\n \"subject_override\",\n \"draft_ready\",\n \"approved_to_send\",\n \"approved_at\",\n \"approved_by\",\n \"send_override\",\n \"override_reason\",\n \"override_by\",\n \"override_at\",\n \"sent_at\",\n \"delivery_status\",\n \"resend_message_id\",\n \"checklist\",\n \"checklist_auto_evidence\",\n] as const;\n\n/** The one thing a list read still needs from the body: whether there IS one.\n * Computed in SQLite so the bytes never cross the wire. */\nconst HAS_RENDERED_HTML = sql<number>`(rendered_html is not null)`.as(\"has_rendered_html\");\n\n/** Same contract as the Airtable listAllReports. Newest-period-first (Airtable\n * returned manual table order; every consumer filters/sorts itself). */\nexport async function listAllReports(db: Db): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .select(REPORT_LIST_COLUMNS)\n .select(HAS_RENDERED_HTML)\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));\n}\n\n/** Same contract as the Airtable listReportsForSite — served by idx_reports_site.\n * Body-free for the same reason as listAllReports: the site-detail page renders\n * a link, never the HTML. */\nexport async function listReportsForSite(db: Db, siteId: string): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .select(REPORT_LIST_COLUMNS)\n .select(HAS_RENDERED_HTML)\n .where(\"site_id\", \"=\", siteId)\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));\n}\n\n/** Same contract as the Airtable `listSendableReports`: the send queue —\n * `Draft ready` ∧ `Approved to send` ∧ `Sent at` BLANK, which is exactly the\n * filterByFormula that reader sends. Airtable evaluated the predicate server-side;\n * here it is a WHERE, so the three-part rule is stated once, in SQL, and\n * tests/reports/send/sendable-predicate.test.ts drives every combination of the\n * three columns through it.\n *\n * Body-free like the other list reads: the send renders from the row, and\n * `rendered_html` is fetched separately by the paths that want the stored body.\n * It scans `reports` (16 rows, ~44/month) exactly as `listAllReports` does, and\n * the query-plan gate carries that as a named allowance rather than a silent one. */\nexport async function listSendableReports(db: Db): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .select(REPORT_LIST_COLUMNS)\n .select(HAS_RENDERED_HTML)\n .where(\"draft_ready\", \"=\", 1)\n .where(\"approved_to_send\", \"=\", 1)\n .where(\"sent_at\", \"is\", null)\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));\n}\n\n/** Columns a report writer mirrors after its Airtable write (same pattern as\n * mirrorSiteField): the approve/override flow, the resend-webhook's delivery\n * status, and — since #539 Phase 5 — the drafting path's queue flag and a\n * re-run's refreshed scores.\n *\n * `rendered_html` stays deliberately OUT even though the drafting path now\n * writes it: bodies go through `storeRenderedHtml`, so a request handler\n * holding a patch cannot accidentally write megabytes of HTML. */\nexport type ReportMirrorPatch = Partial<\n Pick<\n ReportsTable,\n | \"approved_to_send\"\n | \"approved_at\"\n | \"approved_by\"\n | \"send_override\"\n | \"override_reason\"\n | \"override_by\"\n | \"override_at\"\n | \"delivery_status\"\n // Phase 4 report review: the console edits commentary and re-renders the\n // page immediately after the write, so the mirror has to carry it or the\n // operator sees their own save as a no-op until the next hourly sync.\n | \"commentary\"\n // Phase 5 drafting path. `draft_ready` is the queue flag `queueDraft`\n // writes — for the new draft AND for every row it supersedes, so without it\n // the console shows a site with two queued reports until the next sync.\n | \"draft_ready\"\n // A re-run (announce/launch reuse) refreshes an existing row's scores so the\n // eventually-sent email is not stale; the console reads the same numbers.\n | \"lighthouse_performance\"\n | \"lighthouse_accessibility\"\n | \"lighthouse_best_practices\"\n | \"lighthouse_seo\"\n | \"ga_users_current\"\n | \"ga_users_previous\"\n | \"search_found_page1\"\n | \"search_position\"\n | \"completed_on\"\n // #643 (the freeze): the send batch mirrors its own `Sent at` stamp. These\n // used to stay out because the hourly sync converged them; that sync is\n // retired, and the console's already-sent guards (approve, the commentary\n // lock, re-render) read `sent_at` from Turso — an unmirrored stamp would\n // disarm all three for every post-flip send.\n | \"sent_at\"\n | \"resend_message_id\"\n >\n>;\n\n/** Mirror an Airtable report write into Turso so the page re-render after an\n * approve/override/bounce shows the new state immediately. Callers route\n * failures through `mirrorWrite`, which decides fatal vs swallowed by the\n * freeze switch; an empty patch is a no-op, never invalid SQL.\n *\n * Returns whether the UPDATE matched a row (#647). Same contract as\n * `mirrorSiteFields`: this module has no error policy of its own, so the\n * count is REPORTED and the boundary (`mirrorWrite`, `makeReportMirror`)\n * decides what a miss means — logged before the freeze, fatal after it, when\n * no importer exists to converge a row that was never inserted. Discarding\n * the count is what let a stamp for a row Turso never held mirror\n * \"successfully\". An empty patch reports `true`: nothing to write is not a\n * miss. */\nexport async function mirrorReportPatch(\n db: Db,\n reportId: string,\n patch: ReportMirrorPatch,\n): Promise<boolean> {\n if (Object.keys(patch).length === 0) return true;\n const res = await db\n .updateTable(\"reports\")\n .set(patch)\n .where(\"id\", \"=\", reportId)\n .executeTakeFirst();\n // kysely/libSQL reports numUpdatedRows as a BigInt — compare in BigInt.\n return res.numUpdatedRows > 0n;\n}\n\n/** Mirror a NEWLY CREATED Airtable Reports record into Turso (#539 Phase 5).\n *\n * Every other report mirror is an UPDATE, which silently does nothing for a row\n * that does not exist yet — so a draft created at 09:05 was invisible to the\n * Turso-backed console until the 09:20 sync. Takes the raw record Airtable\n * echoed back from the create, and maps it with the IMPORTER's own\n * `mapReportRecord`: parity diffs Turso against exactly that function, so\n * delegating is what makes the mirrored row parity-clean by construction rather\n * than by a column list someone has to remember to extend.\n *\n * Upsert, not insert, and the conflict branch drops `rendered_html` for the\n * same reason the importer's does: the body is written later by a separate\n * sharp-bearing step, so a re-mirror carrying null would blank a render that\n * had already succeeded. */\nexport async function mirrorReportInsert(db: Db, rec: RawRecord): Promise<void> {\n const row = mapReportRecord(rec, null);\n const { rendered_html: _rh, ...rowSansHtml } = row;\n void _rh;\n await db\n .insertInto(\"reports\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(rowSansHtml))\n .execute();\n}\n\n/** Insert a BRAND-NEW report row (#646 step 4). The report id was just minted\n * (`report_<ULID>`), Turso owns the row, and no Airtable record exists for it.\n *\n * A plain INSERT, deliberately not `mirrorReportInsert`'s upsert: that one\n * resolves a conflict because it re-mirrors a record Airtable already holds,\n * whereas a conflict HERE means the freshly minted id already exists — which\n * must fail loudly, never silently overwrite a report. Same reasoning as\n * `insertSiteRows`.\n *\n * Maps with the importer's `mapReportRecord`, like the create mirror it replaces:\n * the parity harness diffs Turso against exactly that function, so a drafted row\n * is parity-clean by construction rather than by a second column list. The caller\n * builds the record's fields with `draftFields` (`src/reports/draft-fields.ts`),\n * whose keys are that mapper's own vocabulary. */\nexport async function insertReportRow(db: Db, rec: RawRecord): Promise<void> {\n await db.insertInto(\"reports\").values(mapReportRecord(rec, null)).execute();\n}\n\n/**\n * Store a freshly rendered report body, replacing whatever was there.\n *\n * Deliberately NOT part of `ReportMirrorPatch`: that patch is the request path's\n * write-through (approve, override, delivery status), and a rendered body is\n * produced by a batch job with sharp, never by a dashboard POST. Keeping it out\n * means a handler cannot accidentally write megabytes of HTML from a request.\n *\n * It REPLACES unconditionally. The importer's `when-missing` mode skips a report\n * that already has a body — correct for an import, wrong here: a refresh whose\n * entire purpose is showing the newest commentary must overwrite.\n */\nexport async function storeRenderedHtml(db: Db, reportId: string, html: string): Promise<void> {\n await db.updateTable(\"reports\").set({ rendered_html: html }).where(\"id\", \"=\", reportId).execute();\n}\n\n/** By rec id (the PK) — approve-report's read. */\nexport async function getReportById(db: Db, id: string): Promise<ReportRow | null> {\n const r = await db.selectFrom(\"reports\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? reportRowFromDb(r, r.rendered_html !== null) : null;\n}\n\n/** By Resend message id — the resend-webhook's report lookup (#539 Phase 6 step 2,\n * #646). Same contract as the Airtable `findReportByMessageId` it replaces: the\n * first matching row, or null. Served by idx_reports_resend_message (0027), and\n * body-free like the list reads — the webhook needs the row's id and current\n * delivery status, never the HTML. `resend_message_id` is stamped by the send\n * path's `reportSentMirror` (#643) and was imported for historical rows. */\nexport async function findReportByMessageId(db: Db, messageId: string): Promise<ReportRow | null> {\n const r = await db\n .selectFrom(\"reports\")\n .select(REPORT_LIST_COLUMNS)\n .select(HAS_RENDERED_HTML)\n .where(\"resend_message_id\", \"=\", messageId)\n .limit(1)\n .executeTakeFirst();\n return r ? reportRowFromDb(r, r.has_rendered_html !== 0) : null;\n}\n\n/** The preview route's read: the stored rendered body, or null when the report\n * has none (imported while its signed URL was expired, or predates rendering). */\nexport async function getReportHtml(\n db: Db,\n id: string,\n): Promise<{ html: string; reportId: string | null } | null> {\n const r = await db\n .selectFrom(\"reports\")\n .select([\"rendered_html\", \"report_id\"])\n .where(\"id\", \"=\", id)\n .executeTakeFirst();\n if (!r || r.rendered_html === null) return null;\n return { html: r.rendered_html, reportId: r.report_id };\n}\n"],"mappings":"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","names":[]}
@@ -517,6 +517,32 @@ var MIGRATIONS = [
517
517
  {
518
518
  id: "0026_prospect_audits_chosen_questions",
519
519
  sql: `ALTER TABLE prospect_audits ADD COLUMN chosen_questions TEXT;`
520
+ },
521
+ {
522
+ // #539 Phase 6 step 2 (#646): the resend-webhook's REPORT lookup moved from
523
+ // Airtable (`{Resend message ID} = "…"`) to Turso, as
524
+ // fleet-state's findReportByMessageId. Every Resend delivery/bounce event
525
+ // that is not a lead notification runs it, so the EXPLAIN-query-plan gate
526
+ // (tests/db/query-plans.test.ts) needs it served by an index rather than a
527
+ // scan of the HTML-bearing reports table — the same reason 0008 indexed
528
+ // submissions.resend_message_id for the bounce lookup. Single statement.
529
+ id: "0027_reports_resend_message_index",
530
+ sql: `CREATE INDEX IF NOT EXISTS idx_reports_resend_message ON reports (resend_message_id);`
531
+ },
532
+ {
533
+ // MED-11 of the 2026-09-02 review: `/s/:slug` built the fleet-wide unreplayed
534
+ // dead-letter map on every load and read one key out of it. The per-slug
535
+ // replacement (`countUnreplayedDeadLettersForSlug`) needs an index that
536
+ // starts with `site_slug` — served by 0005's `idx_deadletter_unreplayed
537
+ // (replayed_at)` it would visit every unreplayed row in the fleet and then
538
+ // do a rowid lookup into the payload-bearing table for each, which is what
539
+ // the grouped query's docblock says it exists to avoid.
540
+ //
541
+ // COVERING for the count: all three columns are in the index, so the query
542
+ // never touches a row carrying a client's lead payload. Single statement.
543
+ id: "0028_deadletter_slug_unreplayed_index",
544
+ sql: `CREATE INDEX IF NOT EXISTS idx_deadletter_slug_unreplayed
545
+ ON submission_deadletter (site_slug, replayed_at, abandoned_at);`
520
546
  }
521
547
  ];
522
548
 
@@ -551,4 +577,4 @@ async function runMigrations(client) {
551
577
  export {
552
578
  runMigrations
553
579
  };
554
- //# sourceMappingURL=chunk-T76MA2FH.js.map
580
+ //# sourceMappingURL=chunk-CP6MVZ5X.js.map