@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.
- package/README.md +5 -6
- package/dist/{announce-45HVP4J4.js → announce-2FH2KBQQ.js} +39 -31
- package/dist/announce-2FH2KBQQ.js.map +1 -0
- package/dist/attachments-JFGFQRXQ.js +10 -0
- package/dist/{blux-RUFMNOUJ.js → blux-NXQLMS4W.js} +5 -5
- package/dist/{bump-deps-EL3TKMIL.js → bump-deps-54K4ECP3.js} +6 -6
- package/dist/{chunk-IMYL67JV.js → chunk-3RLDX3Q2.js} +5 -5
- package/dist/{chunk-EQQ4H6L3.js → chunk-3YALLSBF.js} +2 -2
- package/dist/{chunk-LAJ3FF54.js → chunk-5UI4FERC.js} +16 -5
- package/dist/chunk-5UI4FERC.js.map +1 -0
- package/dist/{chunk-CA6ADGOC.js → chunk-6PGXKN6F.js} +2 -2
- package/dist/{chunk-Y5BVR4HW.js → chunk-6THEZQLA.js} +3 -3
- package/dist/chunk-ASLDCWBF.js +41 -0
- package/dist/chunk-ASLDCWBF.js.map +1 -0
- package/dist/{chunk-DVJNYPFR.js → chunk-AYDQQBNG.js} +2 -2
- package/dist/{chunk-OTEVKRAH.js → chunk-B2BRFWJY.js} +7 -16
- package/dist/chunk-B2BRFWJY.js.map +1 -0
- package/dist/{chunk-GEUC5VGM.js → chunk-BINFZQ2T.js} +49 -4
- package/dist/chunk-BINFZQ2T.js.map +1 -0
- package/dist/{chunk-T76MA2FH.js → chunk-CP6MVZ5X.js} +27 -1
- package/dist/{chunk-T76MA2FH.js.map → chunk-CP6MVZ5X.js.map} +1 -1
- package/dist/{chunk-72FJZ6LO.js → chunk-DRNZVORX.js} +26 -22
- package/dist/chunk-DRNZVORX.js.map +1 -0
- package/dist/{chunk-XY7YV2YQ.js → chunk-DXZRWW2F.js} +65 -10
- package/dist/chunk-DXZRWW2F.js.map +1 -0
- package/dist/{chunk-SHGIDTYF.js → chunk-DYTYPKFN.js} +2 -2
- package/dist/chunk-DZZ5KRTB.js +23 -0
- package/dist/chunk-DZZ5KRTB.js.map +1 -0
- package/dist/{chunk-NVLREB5R.js → chunk-EN6CTBUN.js} +7 -7
- package/dist/{chunk-ZH5LOQET.js → chunk-FHFXJ3EM.js} +7 -5
- package/dist/chunk-FHFXJ3EM.js.map +1 -0
- package/dist/{chunk-3EA43S3R.js → chunk-GRF2RYEO.js} +12 -12
- package/dist/{chunk-RRE74VWI.js → chunk-HSMN35LU.js} +2 -2
- package/dist/chunk-ICSRWW5Q.js +148 -0
- package/dist/chunk-ICSRWW5Q.js.map +1 -0
- package/dist/{chunk-XG2A6GIG.js → chunk-IDBPII6G.js} +4 -4
- package/dist/{chunk-XG2A6GIG.js.map → chunk-IDBPII6G.js.map} +1 -1
- package/dist/{chunk-ECDJDHKT.js → chunk-JHJNNJLG.js} +2 -2
- package/dist/{chunk-QFSL2L2B.js → chunk-L5F3WYIY.js} +81 -31
- package/dist/chunk-L5F3WYIY.js.map +1 -0
- package/dist/{chunk-XED2DNAI.js → chunk-LAG6IP7Y.js} +7 -7
- package/dist/{chunk-UIITN37J.js → chunk-LF2TJN4X.js} +2 -2
- package/dist/chunk-LF2TJN4X.js.map +1 -0
- package/dist/{chunk-SQ4FSNYL.js → chunk-LUF52EIY.js} +2 -2
- package/dist/{chunk-MZZDMBDT.js → chunk-LZMAG73U.js} +19 -19
- package/dist/chunk-LZMAG73U.js.map +1 -0
- package/dist/{chunk-6INOBNRO.js → chunk-MNRWHSFS.js} +2 -2
- package/dist/{chunk-CGQZFQAY.js → chunk-MVKNU4QD.js} +2 -3
- package/dist/chunk-MVKNU4QD.js.map +1 -0
- package/dist/{chunk-62YWOFVN.js → chunk-ORTOMEYR.js} +3 -3
- package/dist/{chunk-62YWOFVN.js.map → chunk-ORTOMEYR.js.map} +1 -1
- package/dist/{chunk-44SDGPGQ.js → chunk-PZZ6HM5T.js} +3 -3
- package/dist/chunk-RQJFGK3A.js +51 -0
- package/dist/chunk-RQJFGK3A.js.map +1 -0
- package/dist/{chunk-H445EH33.js → chunk-S2PLX4XK.js} +10 -7
- package/dist/chunk-S2PLX4XK.js.map +1 -0
- package/dist/{chunk-KN52PM63.js → chunk-TC4GANMX.js} +47 -149
- package/dist/chunk-TC4GANMX.js.map +1 -0
- package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
- package/dist/chunk-TEMQPSCM.js.map +1 -0
- package/dist/{chunk-4UTJ4G6P.js → chunk-TJLKWQOX.js} +2 -2
- package/dist/{chunk-A5QKNOXW.js → chunk-TTT6HYPW.js} +47 -10
- package/dist/chunk-TTT6HYPW.js.map +1 -0
- package/dist/{chunk-VEIIUCYL.js → chunk-TV76A6GN.js} +2 -2
- package/dist/{chunk-D3CTOCUR.js → chunk-TVZ6QCOE.js} +3 -3
- package/dist/chunk-UPE6D4AT.js +69 -0
- package/dist/chunk-UPE6D4AT.js.map +1 -0
- package/dist/{chunk-BIYQN7FH.js → chunk-VCPTOZ56.js} +31 -1
- package/dist/{chunk-BIYQN7FH.js.map → chunk-VCPTOZ56.js.map} +1 -1
- package/dist/chunk-VQMAAQ5M.js +53 -0
- package/dist/chunk-VQMAAQ5M.js.map +1 -0
- package/dist/{chunk-EASYCD6F.js → chunk-WMF5S2MB.js} +1 -1
- package/dist/{chunk-EASYCD6F.js.map → chunk-WMF5S2MB.js.map} +1 -1
- package/dist/{chunk-37B4LVB3.js → chunk-X56LAKPR.js} +3 -3
- package/dist/chunk-X56LAKPR.js.map +1 -0
- package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
- package/dist/chunk-YJBSB4LP.js.map +1 -0
- package/dist/{chunk-P4A6D6HO.js → chunk-ZEE5WQYU.js} +32 -18
- package/dist/chunk-ZEE5WQYU.js.map +1 -0
- package/dist/cli/bin.js +92 -50
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +33 -3
- package/dist/cli/commands/audit.js +26 -23
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-3XGSZMLU.js +11 -0
- package/dist/{convert-to-pnpm-4DON7IFS.js → convert-to-pnpm-U2CRNKNY.js} +6 -6
- package/dist/{db-5K4CDEGJ.js → db-7XYTBIPH.js} +25 -18
- package/dist/db-7XYTBIPH.js.map +1 -0
- package/dist/{deadletter-G4M3HSVM.js → deadletter-A7RQVNG3.js} +8 -4
- package/dist/{digest-5AJGWS33.js → digest-AMXRGWZA.js} +34 -33
- package/dist/digest-AMXRGWZA.js.map +1 -0
- package/dist/{digest-collectors-MC5NWEUA.js → digest-collectors-CX2NCGS7.js} +15 -12
- package/dist/ensure-site-F2WFCWWA.js +70 -0
- package/dist/ensure-site-F2WFCWWA.js.map +1 -0
- package/dist/ensure-site-JEKV2ODE.js +105 -0
- package/dist/ensure-site-JEKV2ODE.js.map +1 -0
- package/dist/{ensure-site-XESZHZEB.js → ensure-site-OO4UPWNQ.js} +19 -42
- package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
- package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
- package/dist/{fleet-state-GJWGKTLD.js → fleet-state-RJROYXKO.js} +17 -7
- package/dist/{forms-notify-target-4NPLX2UG.js → forms-notify-target-DK5R326N.js} +17 -13
- package/dist/forms-notify-target-DK5R326N.js.map +1 -0
- package/dist/{github-signals-DMU74SAX.js → github-signals-RGRPYTUB.js} +20 -15
- package/dist/github-signals-RGRPYTUB.js.map +1 -0
- package/dist/{header-image-BRTOLHJF.js → header-image-43ZIEJKP.js} +13 -17
- package/dist/header-image-43ZIEJKP.js.map +1 -0
- package/dist/{health-endpoint-UCLMSSCN.js → health-endpoint-K5LXDPGE.js} +7 -7
- package/dist/{health-mirror-ONRT4DQ5.js → health-mirror-WZUHWCCV.js} +10 -10
- package/dist/{import-airtable-IIK2QUUF.js → import-airtable-UBF5GYAL.js} +3 -3
- package/dist/index.d.ts +102 -494
- package/dist/index.js +62 -52
- package/dist/index.js.map +1 -1
- package/dist/{init-3LMZTCVD.js → init-OFITGMOP.js} +16 -16
- package/dist/{launch-XTDMC6LD.js → launch-AJY3TGTQ.js} +37 -35
- package/dist/launch-AJY3TGTQ.js.map +1 -0
- package/dist/{lighthouse-52WSPZ5D.js → lighthouse-RFMFREEX.js} +2 -2
- package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
- package/dist/{chunk-IHHAYXKY.js → match-harness-CFELJA57.js} +70 -4
- package/dist/match-harness-CFELJA57.js.map +1 -0
- package/dist/migrate-EZL55AWA.js +7 -0
- package/dist/notify-K3WVH3DM.js +1 -1
- package/dist/{onboard-CZYBWVWH.js → onboard-F7RTLMDZ.js} +6 -6
- package/dist/{orchestrate-TGDFGY52.js → orchestrate-SAIFXZIW.js} +13 -10
- package/dist/parity-2JGVUCZD.js +14 -0
- package/dist/{pipeline-UUPXOS2J.js → pipeline-TCSNVDQZ.js} +3 -3
- package/dist/{preflight-POZZI3GN.js → preflight-6XGVVO2U.js} +35 -11
- package/dist/preflight-6XGVVO2U.js.map +1 -0
- package/dist/{prismic-ci-AHY5ULJR.js → prismic-ci-XMX7A5E4.js} +11 -11
- package/dist/prismic-ci-XMX7A5E4.js.map +1 -0
- package/dist/{prismic-models-URYGODNY.js → prismic-models-7GKFAAFA.js} +19 -16
- package/dist/prismic-models-7GKFAAFA.js.map +1 -0
- package/dist/prospect/types.d.ts +44 -2
- package/dist/prospect/types.js +1 -1
- package/dist/{prospect-audit-AJEE3Z4L.js → prospect-audit-KWP5P5U2.js} +37 -5
- package/dist/prospect-audit-KWP5P5U2.js.map +1 -0
- package/dist/{protection-audit-3J3UEOVT.js → protection-audit-YXJKFU6O.js} +3 -3
- package/dist/{protection-audit-3J3UEOVT.js.map → protection-audit-YXJKFU6O.js.map} +1 -1
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/{render-MHUUHPLN.js → render-NWUAX6RN.js} +3 -3
- package/dist/{render-from-row-65DC6ZP3.js → render-from-row-75JTED2Z.js} +6 -4
- package/dist/{renovate-dispatch-ONJMBUHJ.js → renovate-dispatch-ZZQXT7DA.js} +13 -8
- package/dist/{renovate-dispatch-ONJMBUHJ.js.map → renovate-dispatch-ZZQXT7DA.js.map} +1 -1
- package/dist/replay-HB44TAII.js +77 -0
- package/dist/replay-HB44TAII.js.map +1 -0
- package/dist/{report-RC5LGPU3.js → report-MVPWS7WP.js} +102 -58
- package/dist/report-MVPWS7WP.js.map +1 -0
- package/dist/{report-mirror-KKZZ6RMZ.js → report-mirror-O6IDFDWU.js} +28 -10
- package/dist/report-mirror-O6IDFDWU.js.map +1 -0
- package/dist/roster-EJ3ILR52.js +19 -0
- package/dist/roster-EJ3ILR52.js.map +1 -0
- package/dist/{screenouts-7P5GOZMV.js → screenouts-7NQUWHOJ.js} +14 -1
- package/dist/screenouts-7NQUWHOJ.js.map +1 -0
- package/dist/{self-updating-RKKIYNV4.js → self-updating-KX5FMGEO.js} +7 -7
- package/dist/{selftest-AFLR7YV7.js → selftest-KABY2CYJ.js} +21 -17
- package/dist/{selftest-AFLR7YV7.js.map → selftest-KABY2CYJ.js.map} +1 -1
- package/dist/site-create-ZU3ACBTO.js +45 -0
- package/dist/site-create-ZU3ACBTO.js.map +1 -0
- package/dist/{site-mirror-H2FYMN55.js → site-mirror-TKTDWYP3.js} +10 -10
- package/dist/site-mirror-TKTDWYP3.js.map +1 -0
- package/dist/{smoke-suite-BWHR3XX5.js → smoke-suite-QQMV7TNA.js} +7 -7
- package/dist/{submissions-JDZP35JM.js → submissions-4I66W6QK.js} +11 -1
- package/dist/{submissions-JDZP35JM.js.map → submissions-4I66W6QK.js.map} +1 -1
- package/dist/{submissions-II5J35QM.js → submissions-K5UXGZUN.js} +12 -7
- package/dist/submissions-K5UXGZUN.js.map +1 -0
- package/dist/{svelte-codemods-QWGIRNN6.js → svelte-codemods-YFN4TEGV.js} +6 -6
- package/dist/{sync-NKPDCMYW.js → sync-F4USL4K5.js} +6 -6
- package/dist/{sync-configs-HGLQV55H.js → sync-configs-HBXS3D7C.js} +6 -6
- package/dist/turso-KQKBBQCK.js +24 -0
- package/dist/turso-KQKBBQCK.js.map +1 -0
- package/dist/{upgrade-WZ44IARG.js → upgrade-XOJMDIT4.js} +6 -6
- package/dist/util/git.d.ts +1 -1
- package/dist/util/git.js +1 -1
- package/dist/{websites-6DHIHJ2D.js → websites-Y6FN2XFD.js} +26 -21
- package/dist/{write-audits-to-airtable-BIK6LNVC.js → write-audits-to-airtable-TI3JMWSY.js} +6 -4
- package/package.json +1 -1
- package/dist/airtable-SJV4ZSMS.js +0 -9
- package/dist/announce-45HVP4J4.js.map +0 -1
- package/dist/attachments-NLZIJ5NU.js +0 -9
- package/dist/chunk-37B4LVB3.js.map +0 -1
- package/dist/chunk-72FJZ6LO.js.map +0 -1
- package/dist/chunk-74QRWE6G.js +0 -53
- package/dist/chunk-74QRWE6G.js.map +0 -1
- package/dist/chunk-A5QKNOXW.js.map +0 -1
- package/dist/chunk-CGQZFQAY.js.map +0 -1
- package/dist/chunk-F3JWOPTG.js.map +0 -1
- package/dist/chunk-GEUC5VGM.js.map +0 -1
- package/dist/chunk-H445EH33.js.map +0 -1
- package/dist/chunk-IHHAYXKY.js.map +0 -1
- package/dist/chunk-IY6256GK.js.map +0 -1
- package/dist/chunk-KN52PM63.js.map +0 -1
- package/dist/chunk-LAJ3FF54.js.map +0 -1
- package/dist/chunk-MZZDMBDT.js.map +0 -1
- package/dist/chunk-OTEVKRAH.js.map +0 -1
- package/dist/chunk-P4A6D6HO.js.map +0 -1
- package/dist/chunk-QFSL2L2B.js.map +0 -1
- package/dist/chunk-UIITN37J.js.map +0 -1
- package/dist/chunk-XY7YV2YQ.js.map +0 -1
- package/dist/chunk-YFAXOD7Z.js +0 -42
- package/dist/chunk-YFAXOD7Z.js.map +0 -1
- package/dist/chunk-ZH5LOQET.js.map +0 -1
- package/dist/client-L7NAUG6J.js +0 -11
- package/dist/db-5K4CDEGJ.js.map +0 -1
- package/dist/digest-5AJGWS33.js.map +0 -1
- package/dist/ensure-site-XESZHZEB.js.map +0 -1
- package/dist/fleet-events-cF2Om88w.d.ts +0 -24
- package/dist/forms-notify-target-4NPLX2UG.js.map +0 -1
- package/dist/github-signals-DMU74SAX.js.map +0 -1
- package/dist/header-image-BRTOLHJF.js.map +0 -1
- package/dist/launch-XTDMC6LD.js.map +0 -1
- package/dist/lighthouse-airtable-3NOFB42B.js +0 -12
- package/dist/match-harness-OW45CB5H.js +0 -77
- package/dist/match-harness-OW45CB5H.js.map +0 -1
- package/dist/migrate-NE3DUMTM.js +0 -7
- package/dist/parity-AEKYSLN6.js +0 -14
- package/dist/preflight-POZZI3GN.js.map +0 -1
- package/dist/prismic-ci-AHY5ULJR.js.map +0 -1
- package/dist/prismic-models-URYGODNY.js.map +0 -1
- package/dist/prospect-audit-AJEE3Z4L.js.map +0 -1
- package/dist/replay-4FVFOMHH.js +0 -54
- package/dist/replay-4FVFOMHH.js.map +0 -1
- package/dist/report-RC5LGPU3.js.map +0 -1
- package/dist/report-mirror-KKZZ6RMZ.js.map +0 -1
- package/dist/screenouts-7P5GOZMV.js.map +0 -1
- package/dist/site-mirror-H2FYMN55.js.map +0 -1
- package/dist/submissions-II5J35QM.js.map +0 -1
- package/dist/write-audits-to-airtable-BIK6LNVC.js.map +0 -1
- /package/dist/{airtable-SJV4ZSMS.js.map → attachments-JFGFQRXQ.js.map} +0 -0
- /package/dist/{blux-RUFMNOUJ.js.map → blux-NXQLMS4W.js.map} +0 -0
- /package/dist/{bump-deps-EL3TKMIL.js.map → bump-deps-54K4ECP3.js.map} +0 -0
- /package/dist/{chunk-IMYL67JV.js.map → chunk-3RLDX3Q2.js.map} +0 -0
- /package/dist/{chunk-EQQ4H6L3.js.map → chunk-3YALLSBF.js.map} +0 -0
- /package/dist/{chunk-CA6ADGOC.js.map → chunk-6PGXKN6F.js.map} +0 -0
- /package/dist/{chunk-Y5BVR4HW.js.map → chunk-6THEZQLA.js.map} +0 -0
- /package/dist/{chunk-DVJNYPFR.js.map → chunk-AYDQQBNG.js.map} +0 -0
- /package/dist/{chunk-SHGIDTYF.js.map → chunk-DYTYPKFN.js.map} +0 -0
- /package/dist/{chunk-NVLREB5R.js.map → chunk-EN6CTBUN.js.map} +0 -0
- /package/dist/{chunk-3EA43S3R.js.map → chunk-GRF2RYEO.js.map} +0 -0
- /package/dist/{chunk-RRE74VWI.js.map → chunk-HSMN35LU.js.map} +0 -0
- /package/dist/{chunk-ECDJDHKT.js.map → chunk-JHJNNJLG.js.map} +0 -0
- /package/dist/{chunk-XED2DNAI.js.map → chunk-LAG6IP7Y.js.map} +0 -0
- /package/dist/{chunk-SQ4FSNYL.js.map → chunk-LUF52EIY.js.map} +0 -0
- /package/dist/{chunk-6INOBNRO.js.map → chunk-MNRWHSFS.js.map} +0 -0
- /package/dist/{chunk-44SDGPGQ.js.map → chunk-PZZ6HM5T.js.map} +0 -0
- /package/dist/{chunk-4UTJ4G6P.js.map → chunk-TJLKWQOX.js.map} +0 -0
- /package/dist/{chunk-VEIIUCYL.js.map → chunk-TV76A6GN.js.map} +0 -0
- /package/dist/{chunk-D3CTOCUR.js.map → chunk-TVZ6QCOE.js.map} +0 -0
- /package/dist/{attachments-NLZIJ5NU.js.map → client-3XGSZMLU.js.map} +0 -0
- /package/dist/{convert-to-pnpm-4DON7IFS.js.map → convert-to-pnpm-U2CRNKNY.js.map} +0 -0
- /package/dist/{client-L7NAUG6J.js.map → deadletter-A7RQVNG3.js.map} +0 -0
- /package/dist/{deadletter-G4M3HSVM.js.map → digest-collectors-CX2NCGS7.js.map} +0 -0
- /package/dist/{digest-collectors-MC5NWEUA.js.map → fleet-state-RJROYXKO.js.map} +0 -0
- /package/dist/{health-endpoint-UCLMSSCN.js.map → health-endpoint-K5LXDPGE.js.map} +0 -0
- /package/dist/{health-mirror-ONRT4DQ5.js.map → health-mirror-WZUHWCCV.js.map} +0 -0
- /package/dist/{fleet-state-GJWGKTLD.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
- /package/dist/{init-3LMZTCVD.js.map → init-OFITGMOP.js.map} +0 -0
- /package/dist/{import-airtable-IIK2QUUF.js.map → lighthouse-RFMFREEX.js.map} +0 -0
- /package/dist/{lighthouse-52WSPZ5D.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
- /package/dist/{lighthouse-airtable-3NOFB42B.js.map → migrate-EZL55AWA.js.map} +0 -0
- /package/dist/{onboard-CZYBWVWH.js.map → onboard-F7RTLMDZ.js.map} +0 -0
- /package/dist/{migrate-NE3DUMTM.js.map → orchestrate-SAIFXZIW.js.map} +0 -0
- /package/dist/{orchestrate-TGDFGY52.js.map → parity-2JGVUCZD.js.map} +0 -0
- /package/dist/{parity-AEKYSLN6.js.map → pipeline-TCSNVDQZ.js.map} +0 -0
- /package/dist/{render-MHUUHPLN.js.map → render-NWUAX6RN.js.map} +0 -0
- /package/dist/{pipeline-UUPXOS2J.js.map → render-from-row-75JTED2Z.js.map} +0 -0
- /package/dist/{self-updating-RKKIYNV4.js.map → self-updating-KX5FMGEO.js.map} +0 -0
- /package/dist/{smoke-suite-BWHR3XX5.js.map → smoke-suite-QQMV7TNA.js.map} +0 -0
- /package/dist/{svelte-codemods-QWGIRNN6.js.map → svelte-codemods-YFN4TEGV.js.map} +0 -0
- /package/dist/{sync-NKPDCMYW.js.map → sync-F4USL4K5.js.map} +0 -0
- /package/dist/{sync-configs-HGLQV55H.js.map → sync-configs-HBXS3D7C.js.map} +0 -0
- /package/dist/{upgrade-WZ44IARG.js.map → upgrade-XOJMDIT4.js.map} +0 -0
- /package/dist/{render-from-row-65DC6ZP3.js.map → websites-Y6FN2XFD.js.map} +0 -0
- /package/dist/{websites-6DHIHJ2D.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
package/dist/chunk-74QRWE6G.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ACTIVE_STATUSES,
|
|
3
|
-
isPreLaunch,
|
|
4
|
-
listWebsites,
|
|
5
|
-
siteSlug
|
|
6
|
-
} from "./chunk-KN52PM63.js";
|
|
7
|
-
import {
|
|
8
|
-
isHttpUrl
|
|
9
|
-
} from "./chunk-RARREDQE.js";
|
|
10
|
-
|
|
11
|
-
// src/inventory/airtable.ts
|
|
12
|
-
function fromAirtableBase(base, opts = {}) {
|
|
13
|
-
return async () => {
|
|
14
|
-
const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;
|
|
15
|
-
if (!workdir) {
|
|
16
|
-
throw new Error(
|
|
17
|
-
"fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)"
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
const websites = await listWebsites(base);
|
|
21
|
-
return websites.filter(
|
|
22
|
-
(w) => w.status !== null && ACTIVE_STATUSES.has(w.status) && !isPreLaunch(w.status) && w.url.length > 0
|
|
23
|
-
).flatMap((w) => {
|
|
24
|
-
const slug = siteSlug(w.name);
|
|
25
|
-
if (slug.length === 0) {
|
|
26
|
-
console.warn(
|
|
27
|
-
`[inventory] skipping "${w.name}" (row ${w.id}): Name has no slug-able characters (empty slug)`
|
|
28
|
-
);
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
const site = {
|
|
32
|
-
path: `${workdir}/${slug}`,
|
|
33
|
-
name: slug,
|
|
34
|
-
meta: { airtableRowId: w.id, displayName: w.name }
|
|
35
|
-
};
|
|
36
|
-
if (isHttpUrl(w.url)) {
|
|
37
|
-
site.deployedUrl = w.url;
|
|
38
|
-
} else {
|
|
39
|
-
console.warn(
|
|
40
|
-
`[inventory] skipping deployed audit for "${w.name}": url is not http(s): ${JSON.stringify(w.url)}`
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
if (w.gitRepo) site.gitRepo = w.gitRepo;
|
|
44
|
-
if (w.netlifyId) site.netlifyId = w.netlifyId;
|
|
45
|
-
return [site];
|
|
46
|
-
});
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export {
|
|
51
|
-
fromAirtableBase
|
|
52
|
-
};
|
|
53
|
-
//# sourceMappingURL=chunk-74QRWE6G.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/inventory/airtable.ts"],"sourcesContent":["import type { Site, InventoryProvider } from \"../types.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n ACTIVE_STATUSES,\n isPreLaunch,\n} from \"../reports/airtable/websites.js\";\nimport { isHttpUrl } from \"../util/url.js\";\n\nexport type AirtableInventoryOptions = {\n /**\n * Local workdir to compute each site's path as `{workdir}/{slug}`.\n * Defaults to REDDOOR_FLEET_WORKDIR env var if not provided.\n * Airtable doesn't store local checkout paths, so this is required.\n */\n workdir?: string;\n};\n\n/**\n * Read sites from the Airtable Websites table as an InventoryProvider.\n * Each row becomes one Site; `path` is computed as `{workdir}/{slug}`.\n * Only LIVE `maintenance` sites that have a `url` are included — pre-launch\n * stages (\"launching\" / \"building\", via isPreLaunch) are excluded so a\n * not-yet-live site is never audited as production (its deploy/domain/uptime/CMS\n * audits would fail against nothing and red its Airtable row) nor swept by other\n * `--fleet airtable` ops; it re-enters the fleet when a Launch report flips its\n * Status to \"maintained\". The production URL is exposed as `Site.deployedUrl` so\n * the lighthouse audit can run against it with no checkout. `repoUrl` is\n * intentionally NOT set from `url` — a clone source must come from `gitRepo`\n * (`owner/repo`), never the production URL.\n */\nexport function fromAirtableBase(\n base: AirtableBase,\n opts: AirtableInventoryOptions = {},\n): InventoryProvider {\n return async (): Promise<Site[]> => {\n const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;\n if (!workdir) {\n throw new Error(\n \"fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)\",\n );\n }\n const websites = await listWebsites(base);\n return websites\n .filter(\n (w) =>\n w.status !== null &&\n ACTIVE_STATUSES.has(w.status) &&\n !isPreLaunch(w.status) &&\n w.url.length > 0,\n )\n .flatMap((w) => {\n const slug = siteSlug(w.name);\n // An empty slug (a Name with no slug-able characters) can't form a stable\n // path and — fatally — can't be matched back to its Websites row on\n // write-back: every empty-slug site would collapse under the \"\" key and\n // mis-write or fail. Skip it loudly rather than silently mis-map it.\n if (slug.length === 0) {\n console.warn(\n `[inventory] skipping \"${w.name}\" (row ${w.id}): Name has no slug-able characters (empty slug)`,\n );\n return [];\n }\n const site: Site = {\n path: `${workdir}/${slug}`,\n name: slug,\n meta: { airtableRowId: w.id, displayName: w.name },\n };\n // Scheme-allowlist the Airtable `url` before exposing it as the\n // deployed-audit target (it's handed straight to Chrome/lhci). A\n // `file://`/`gopher://`/internal-host value would be a local-file read\n // or SSRF — skip the deployed audit for that site rather than trust it.\n if (isHttpUrl(w.url)) {\n site.deployedUrl = w.url;\n } else {\n console.warn(\n `[inventory] skipping deployed audit for \"${w.name}\": url is not http(s): ${JSON.stringify(w.url)}`,\n );\n }\n if (w.gitRepo) site.gitRepo = w.gitRepo;\n // Expose the Netlify site id (operator-set identity column) so the\n // netlify-deploy audit can query the API with no checkout. Absent → that\n // audit skips for this site. Not derived from the URL.\n if (w.netlifyId) site.netlifyId = w.netlifyId;\n return [site];\n });\n };\n}\n"],"mappings":";;;;;;;;;;;AAgCO,SAAS,iBACd,MACA,OAAiC,CAAC,GACf;AACnB,SAAO,YAA6B;AAClC,UAAM,UAAU,KAAK,WAAW,QAAQ,IAAI;AAC5C,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,UAAM,WAAW,MAAM,aAAa,IAAI;AACxC,WAAO,SACJ;AAAA,MACC,CAAC,MACC,EAAE,WAAW,QACb,gBAAgB,IAAI,EAAE,MAAM,KAC5B,CAAC,YAAY,EAAE,MAAM,KACrB,EAAE,IAAI,SAAS;AAAA,IACnB,EACC,QAAQ,CAAC,MAAM;AACd,YAAM,OAAO,SAAS,EAAE,IAAI;AAK5B,UAAI,KAAK,WAAW,GAAG;AACrB,gBAAQ;AAAA,UACN,yBAAyB,EAAE,IAAI,UAAU,EAAE,EAAE;AAAA,QAC/C;AACA,eAAO,CAAC;AAAA,MACV;AACA,YAAM,OAAa;AAAA,QACjB,MAAM,GAAG,OAAO,IAAI,IAAI;AAAA,QACxB,MAAM;AAAA,QACN,MAAM,EAAE,eAAe,EAAE,IAAI,aAAa,EAAE,KAAK;AAAA,MACnD;AAKA,UAAI,UAAU,EAAE,GAAG,GAAG;AACpB,aAAK,cAAc,EAAE;AAAA,MACvB,OAAO;AACL,gBAAQ;AAAA,UACN,4CAA4C,EAAE,IAAI,0BAA0B,KAAK,UAAU,EAAE,GAAG,CAAC;AAAA,QACnG;AAAA,MACF;AACA,UAAI,EAAE,QAAS,MAAK,UAAU,EAAE;AAIhC,UAAI,EAAE,UAAW,MAAK,YAAY,EAAE;AACpC,aAAO,CAAC,IAAI;AAAA,IACd,CAAC;AAAA,EACL;AACF;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/deadletter.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { TurnstileVerification } from \"../forms/turnstile.js\";\n\n/** One captured-but-unresolved lead: the site lookup threw before the normal\n * pipeline could run (Phase 0 of #539 — persist before enrich). */\nexport type DeadLetterRow = {\n id: string;\n siteSlug: string;\n /** The raw wire payload, exactly as the handler received it. */\n payload: unknown;\n /** The Turnstile verification computed at receipt — tokens expire in 300s,\n * so replay reuses this answer rather than re-verifying. */\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: string;\n};\n\nexport type DeadLetterInput = {\n siteSlug: string;\n payload: unknown;\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: Date;\n};\n\n/** Opaque, collision-free id (mirrors newSubmissionId). */\nexport function newDeadLetterId(): string {\n return `dl_${crypto.randomUUID()}`;\n}\n\n/** Persist a lead the normal pipeline could not place. This is the LAST writer\n * on the ingest path — if it throws too, both stores are down and the caller's\n * 502 is honest. */\nexport async function createDeadLetter(db: Db, input: DeadLetterInput): Promise<{ id: string }> {\n const id = newDeadLetterId();\n await db\n .insertInto(\"submission_deadletter\")\n .values({\n id,\n site_slug: input.siteSlug,\n payload: JSON.stringify(input.payload),\n turnstile: JSON.stringify(input.turnstile),\n error: input.error,\n received_at: input.receivedAt.toISOString(),\n replayed_at: null,\n replay_outcome: null,\n replay_submission_id: null,\n })\n .execute();\n return { id };\n}\n\n/** Rows not yet replayed, oldest first — replay preserves arrival order so the\n * duplicate/velocity signals see submissions in the order they happened. */\nexport async function listUnreplayedDeadLetters(db: Db): Promise<DeadLetterRow[]> {\n const rows = await db\n .selectFrom(\"submission_deadletter\")\n .select([\"id\", \"site_slug\", \"payload\", \"turnstile\", \"error\", \"received_at\"])\n .where(\"replayed_at\", \"is\", null)\n // #786: an abandoned row is terminal by decision — it must leave the replay\n // queue, or the command it was meant to release stays at exit 1 forever.\n .where(\"abandoned_at\", \"is\", null)\n .orderBy(\"received_at\", \"asc\")\n .execute();\n return rows.map((r) => ({\n id: r.id,\n siteSlug: r.site_slug,\n payload: JSON.parse(r.payload) as unknown,\n turnstile: JSON.parse(r.turnstile) as TurnstileVerification,\n error: r.error,\n receivedAt: r.received_at,\n }));\n}\n\n/** Mark a row's replay TERMINAL — it will never be picked up again. Only call\n * for outcomes that re-running cannot improve (accepted, rejected). A replay\n * whose lookup threw again is left untouched so the next run retries it — and\n * since #645 so is `unknown-site`, because `ensure-site` can heal the missing\n * Turso row and make the same replay succeed. */\nexport async function markDeadLetterReplayed(\n db: Db,\n id: string,\n outcome: string,\n submissionId: string | null,\n now: Date,\n): Promise<void> {\n await db\n .updateTable(\"submission_deadletter\")\n .set({\n replayed_at: now.toISOString(),\n replay_outcome: outcome,\n replay_submission_id: submissionId,\n })\n .where(\"id\", \"=\", id)\n .execute();\n}\n\n/** Unreplayed rows per site slug — the input to the `deadletter` attention\n * collector (#645). Grouped in SQL rather than by listing and counting in JS:\n * the rows carry full lead payloads, and an alarm must never pull a client's\n * PII into a dashboard request just to learn how many rows there are. */\nexport async function countUnreplayedDeadLettersBySlug(\n db: Db,\n): Promise<ReadonlyMap<string, number>> {\n const rows = await db\n .selectFrom(\"submission_deadletter\")\n .select((eb) => [\"site_slug\", eb.fn.countAll<number>().as(\"n\")])\n .where(\"replayed_at\", \"is\", null)\n // #786: and out of the alarm count, which is the standing CRITICAL item.\n .where(\"abandoned_at\", \"is\", null)\n .groupBy(\"site_slug\")\n .execute();\n return new Map(rows.map((r) => [r.site_slug, Number(r.n)]));\n}\n\n/** Abandon dead letters as resolved-by-DECISION (#786).\n *\n * The escape hatch #785 left open. `unknown-site` is deliberately non-terminal\n * on replay so that healing and replaying are safe in either order — but that\n * means a slug which is genuinely dead, and still deployed and posting, grows\n * the queue without bound, holds `db replay-deadletters` at exit 1, and leaves\n * a standing CRITICAL cockpit item. The only alternative today is deleting\n * rows by hand, which destroys the very leads the decision was made about.\n *\n * Targets ONE slug or ONE row id, never \"everything\": a mistyped invocation\n * must not be able to write off the whole queue. Only rows that are still live\n * (neither replayed nor already abandoned) are touched, which makes the call\n * idempotent and keeps the FIRST decision — the one actually made — in place.\n * `reason` is required and non-blank, because an undocumented write-off is the\n * thing this exists to stop being necessary.\n *\n * Returns the ids it abandoned, so the CLI can name them rather than print a\n * bare count.\n */\nexport async function abandonDeadLetters(\n db: Db,\n opts: { slug?: string; id?: string; by: string; reason: string; now: Date },\n): Promise<string[]> {\n const bySlug = opts.slug !== undefined;\n const byId = opts.id !== undefined;\n if (bySlug === byId) {\n throw new Error(\"abandonDeadLetters: pass exactly one of slug or id\");\n }\n const reason = opts.reason.trim();\n if (reason === \"\") {\n throw new Error(\"abandonDeadLetters: a non-blank reason is required\");\n }\n\n // Read the target ids first so the caller can be told exactly what was\n // abandoned; the same predicate then guards the write.\n let q = db\n .selectFrom(\"submission_deadletter\")\n .select(\"id\")\n .where(\"replayed_at\", \"is\", null)\n .where(\"abandoned_at\", \"is\", null);\n q = bySlug ? q.where(\"site_slug\", \"=\", opts.slug!) : q.where(\"id\", \"=\", opts.id!);\n const ids = (await q.execute()).map((r) => r.id);\n if (ids.length === 0) return [];\n\n await db\n .updateTable(\"submission_deadletter\")\n .set({\n abandoned_at: opts.now.toISOString(),\n abandoned_by: opts.by,\n abandoned_reason: reason,\n })\n .where(\"id\", \"in\", ids)\n // Re-asserted on the write: between the read and here nothing else runs\n // (libSQL is single-writer), but the guard costs nothing and keeps the\n // write's meaning legible on its own.\n .where(\"replayed_at\", \"is\", null)\n .where(\"abandoned_at\", \"is\", null)\n .execute();\n return ids;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/github/config.ts"],"sourcesContent":["import { execFileSync } from \"node:child_process\";\n\nexport type GitHubConfig = {\n /** Broad PAT used by the tool's own `gh` calls (PRs, branch protection, secrets). */\n token: string;\n /** Narrow PAT stored per-repo as the RENOVATE_TOKEN secret. Falls back to `token`. */\n renovateToken: string;\n};\n\n/** The shape of `node:child_process`'s `execFileSync` that `ghAuthToken` needs —\n * injectable so a test never shells out (and never depends on the runner's\n * own keyring). */\nexport type ExecFileSyncFn = (\n cmd: string,\n args: readonly string[],\n opts: {\n env: NodeJS.ProcessEnv;\n encoding: \"utf8\";\n stdio: [\"ignore\", \"pipe\", \"ignore\"];\n timeout: number;\n },\n) => string;\n\nconst defaultExecFileSync: ExecFileSyncFn = (cmd, args, opts) => execFileSync(cmd, [...args], opts);\n\n/**\n * The token `gh` itself is logged in with (`gh auth token`), or null when `gh`\n * is missing, not logged in, or prints nothing. #665.\n *\n * Two traps, both from the issue:\n *\n * - `gh auth token` ECHOES `$GITHUB_TOKEN` / `$GH_TOKEN` back when either is\n * set, so the child runs with both stripped. Without that a dead file token\n * makes the good keyring token look dead as well — which is exactly how the\n * original failure was misdiagnosed once.\n * - The token is NOT validated by pinging an endpoint. A fine-grained token can\n * be refused by `/user` and still be fully working for the calls it is scoped\n * to; \"validating\" it there would declare working tokens dead.\n *\n * stderr is discarded (gh prints its \"not logged in\" hint there) and the call\n * is bounded so a wedged keychain prompt cannot hang a recipe.\n */\nexport function ghAuthToken(exec: ExecFileSyncFn = defaultExecFileSync): string | null {\n const { GITHUB_TOKEN: _github, GH_TOKEN: _gh, ...env } = process.env;\n void _github;\n void _gh;\n try {\n const out = exec(\"gh\", [\"auth\", \"token\"], {\n env,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n timeout: 5_000,\n });\n return out.trim() || null;\n } catch {\n return null;\n }\n}\n\n/**\n * The broad token, from the environment or the keyring, or null for \"nobody has\n * one\". `GITHUB_TOKEN` has THREE states here, not two, and the third is the one\n * #665's fix collapsed by accident:\n *\n * - **Absent** — the recommended shape. Ask `gh auth token`.\n * - **Set to a value** — that value wins; `gh` is never asked.\n * - **Set to the EMPTY STRING** — `GITHUB_TOKEN=` in a shell, an env block, or\n * a test's `vi.stubEnv(\"GITHUB_TOKEN\", \"\")`. That is a DELIBERATE \"no token\",\n * and it must resolve to null rather than quietly reaching past the operator\n * to the keyring. Under the previous `?.trim() || ghAuthToken()` it did not:\n * `\"\"` is falsy, so an explicitly-disabled token fell through and a machine\n * that had run `gh auth login` silently resolved one anyway. The token gate\n * then read as configured on a developer's laptop and unconfigured in CI —\n * the same code taking different paths on ambient login state, which is how\n * `tests/cli/prismic-ci-command.test.ts` came to pass only where `gh` was\n * logged out.\n *\n * Whitespace-only is NOT the empty string and keeps falling through to the\n * keyring: `GITHUB_TOKEN=\" \"` is a mis-pasted `credentials.env` line, never\n * an instruction, and #665 exists precisely so a bad file value cannot override\n * a working keyring credential.\n */\nfunction resolveToken(exec?: ExecFileSyncFn): string | null {\n const set = process.env.GITHUB_TOKEN;\n if (set === undefined) return ghAuthToken(exec);\n const trimmed = set.trim();\n if (trimmed) return trimmed;\n if (set === \"\") return null; // explicitly disabled — do not consult the keyring\n return ghAuthToken(exec); // blank-but-not-empty: an accident, not a decision\n}\n\n/**\n * Read GitHub config from the environment (credentials.env is loaded into process.env by the CLI).\n *\n * `GITHUB_TOKEN` wins when set; when it is ABSENT the token comes from\n * `gh auth token` — the keyring `gh auth login` populated — so `GITHUB_TOKEN`\n * may be left OUT of `credentials.env` entirely, and should be: `gh.ts` hands\n * this token to `gh` as `GH_TOKEN`, so a set-but-dead file value actively\n * overrides a working keyring credential, and a file COPY of the keyring token\n * goes stale on the next `gh auth login/refresh/logout` (#665). Returns null\n * only when neither source has a token — the signal that git/GitHub features\n * aren't configured.\n *\n * `RENOVATE_TOKEN` falls back to the resolved token when unset (a narrower token\n * is recommended but optional).\n */\nexport function readGitHubConfig(\n opts: { execFileSync?: ExecFileSyncFn } = {},\n): GitHubConfig | null {\n const token = resolveToken(opts.execFileSync);\n if (!token) return null;\n const renovateToken = process.env.RENOVATE_TOKEN?.trim() || token;\n return { token, renovateToken };\n}\n"],"mappings":";AAAA,SAAS,oBAAoB;AAuB7B,IAAM,sBAAsC,CAAC,KAAK,MAAM,SAAS,aAAa,KAAK,CAAC,GAAG,IAAI,GAAG,IAAI;AAmB3F,SAAS,YAAY,OAAuB,qBAAoC;AACrF,QAAM,EAAE,cAAc,SAAS,UAAU,KAAK,GAAG,IAAI,IAAI,QAAQ;AACjE,OAAK;AACL,OAAK;AACL,MAAI;AACF,UAAM,MAAM,KAAK,MAAM,CAAC,QAAQ,OAAO,GAAG;AAAA,MACxC;AAAA,MACA,UAAU;AAAA,MACV,OAAO,CAAC,UAAU,QAAQ,QAAQ;AAAA,MAClC,SAAS;AAAA,IACX,CAAC;AACD,WAAO,IAAI,KAAK,KAAK;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAyBA,SAAS,aAAa,MAAsC;AAC1D,QAAM,MAAM,QAAQ,IAAI;AACxB,MAAI,QAAQ,OAAW,QAAO,YAAY,IAAI;AAC9C,QAAM,UAAU,IAAI,KAAK;AACzB,MAAI,QAAS,QAAO;AACpB,MAAI,QAAQ,GAAI,QAAO;AACvB,SAAO,YAAY,IAAI;AACzB;AAiBO,SAAS,iBACd,OAA0C,CAAC,GACtB;AACrB,QAAM,QAAQ,aAAa,KAAK,YAAY;AAC5C,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,gBAAgB,QAAQ,IAAI,gBAAgB,KAAK,KAAK;AAC5D,SAAO,EAAE,OAAO,cAAc;AAChC;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/reports/airtable/reports.ts"],"sourcesContent":["import type { FieldSet, Records } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport type { ReportType, LighthouseScores } from \"../types.js\";\nimport { ALL_CHECKLIST_FIELDS } from \"../checklist.js\";\nimport type { EvidenceRecord } from \"../auto-tick.js\";\n\nexport const REPORTS_TABLE = \"Reports\";\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\nexport function mapRow(rec: { id: string; fields: Record<string, unknown> }): ReportRow {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const html =\n ((f[\"Rendered HTML\"] as Array<{ url: string; filename: string }> | undefined) ?? [])[0] ?? null;\n return {\n id: rec.id,\n reportId: String(f[\"Report ID\"] ?? \"\"),\n siteId: linkSites[0] ?? \"\",\n reportType: toReportType(f[\"Report type\"] as string | undefined),\n period: (f[\"Period\"] as string | undefined) ?? null,\n periodStart: (f[\"Period start\"] as string | undefined) ?? null,\n periodEnd: (f[\"Period end\"] as string | undefined) ?? null,\n completedOn: (f[\"Completed on\"] as string | undefined) ?? null,\n lighthouse: lighthouseFromFields(f),\n gaUsersCurrent: (f[\"GA users (period)\"] as number | undefined) ?? null,\n gaUsersPrevious: (f[\"GA users (prev period)\"] as number | undefined) ?? null,\n searchFoundPage1:\n typeof f[\"Search found page 1\"] === \"boolean\" ? (f[\"Search found page 1\"] as boolean) : null,\n searchPosition: (f[\"Search position\"] as number | undefined) ?? null,\n lastTestedDate: (f[\"Last tested date\"] as string | undefined) ?? null,\n commentary: (f[\"Commentary\"] as string | undefined) ?? null,\n subjectOverride: (f[\"Subject override\"] as string | undefined) ?? null,\n draftReady: Boolean(f[\"Draft ready\"]),\n approvedToSend: Boolean(f[\"Approved to send\"]),\n sentAt: (f[\"Sent at\"] as string | undefined) ?? null,\n approvedAt: (f[\"Approved At\"] as string | undefined) ?? null,\n approvedBy: (f[\"Approved By\"] as string | undefined) ?? null,\n deliveryStatus: ((f[\"Delivery status\"] as string | undefined) ?? \"pending\") as DeliveryStatus,\n renderedHtmlAttachment: html,\n resendMessageId: (f[\"Resend message ID\"] as string | undefined) ?? null,\n checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),\n autoEvidence: parseAutoEvidence(f[\"Checklist auto-evidence\"]),\n sendOverride: Boolean(f[\"Send override\"]),\n overrideReason: (f[\"Override reason\"] as string | undefined) ?? null,\n overrideBy: (f[\"Override by\"] as string | undefined) ?? null,\n overrideAt: (f[\"Override at\"] as string | undefined) ?? null,\n };\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\nfunction lighthouseFromFields(f: Record<string, unknown>): LighthouseScores | null {\n const p = f[\"Lighthouse — Performance\"];\n const a = f[\"Lighthouse — Accessibility\"];\n const b = f[\"Lighthouse — Best Practices\"];\n const s = f[\"Lighthouse — SEO\"];\n if (\n typeof p !== \"number\" ||\n typeof a !== \"number\" ||\n typeof b !== \"number\" ||\n typeof s !== \"number\"\n )\n return null;\n return { performance: p, accessibility: a, bestPractices: b, seo: s };\n}\n\n/** The GA + Search-presence fields a draft can carry, written to the Reports row.\n * `searchFoundPage1` is written whenever the check ran (true or false — false is the\n * operator-only negative signal); `searchPosition` only when found on page 1. Shared by\n * the create path (DraftInput) and the reuse path (updateReportScores). */\nexport type ReportEnrichment = {\n gaUsersCurrent?: number;\n gaUsersPrevious?: number;\n searchFoundPage1?: boolean;\n searchPosition?: number;\n};\n\nexport type DraftInput = {\n reportId: string;\n siteId: string;\n reportType: ReportType;\n /** UTC `YYYY-MM` recurrence key. Omitted on legacy callers; written only when supplied. */\n period?: string;\n periodStart: Date;\n periodEnd: Date;\n completedOn: Date;\n lighthouse: LighthouseScores;\n lastTestedDate: Date | null;\n /** GA \"Users\" for the period / previous period. Omitted when GA is not configured\n * for the site or the fetch failed — the operator fills the fields manually. */\n gaUsersCurrent?: number;\n gaUsersPrevious?: number;\n /** Search-presence result. `searchFoundPage1` is written whenever the check ran (true or\n * false — false is the operator-only negative signal). `searchPosition` only when found. */\n searchFoundPage1?: boolean;\n searchPosition?: number;\n subjectOverride?: string;\n /** Checklist fields to tick at create time (the auto-tick \"pass\" set). */\n checklistTicks?: string[];\n /** Auto-tick evidence snapshot to persist (keyed by checklist field). */\n autoEvidence?: Record<string, EvidenceRecord>;\n};\n\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Escape a string for safe interpolation into an Airtable filterByFormula.\n * Airtable formulas use SQL-like string literals; we escape backslash and\n * double quote. Used wherever an externally-supplied string flows into a\n * formula (e.g. Resend message ids on the webhook path).\n */\nexport function escapeFormulaString(s: string): string {\n return s.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\n/** Called with the record Airtable echoed back from the create, before it is\n * mapped (#539 Phase 5). Structurally typed rather than importing the db\n * layer's `RawRecord`, so this module stays the leaf it has always been. */\nexport type CreatedDraftMirror = (rec: {\n id: string;\n fields: Record<string, unknown>;\n}) => Promise<void>;\n\nexport async function createDraft(\n base: AirtableBase,\n input: DraftInput,\n mirror?: CreatedDraftMirror,\n): Promise<ReportRow> {\n // Set Delivery status to \"pending\" at creation time, NOT at send time. This\n // matters for H4: if stampSent wrote \"pending\" after the webhook had already\n // written \"delivered\" (race), the operator would see a regressed status.\n const fields: FieldSet = {\n \"Report ID\": input.reportId,\n Site: [input.siteId],\n \"Report type\": input.reportType,\n \"Period start\": ymd(input.periodStart),\n \"Period end\": ymd(input.periodEnd),\n \"Completed on\": ymd(input.completedOn),\n \"Lighthouse — Performance\": input.lighthouse.performance,\n \"Lighthouse — Accessibility\": input.lighthouse.accessibility,\n \"Lighthouse — Best Practices\": input.lighthouse.bestPractices,\n \"Lighthouse — SEO\": input.lighthouse.seo,\n \"Delivery status\": \"pending\",\n };\n if (input.lastTestedDate) fields[\"Last tested date\"] = ymd(input.lastTestedDate);\n // GA fields are written only when supplied (GA configured + fetch succeeded). When\n // omitted the row keeps them blank for manual entry — the pre-GA behavior.\n if (input.gaUsersCurrent !== undefined) fields[\"GA users (period)\"] = input.gaUsersCurrent;\n if (input.gaUsersPrevious !== undefined) fields[\"GA users (prev period)\"] = input.gaUsersPrevious;\n if (input.searchFoundPage1 !== undefined) fields[\"Search found page 1\"] = input.searchFoundPage1;\n if (input.searchPosition !== undefined) fields[\"Search position\"] = input.searchPosition;\n if (input.period !== undefined) fields[\"Period\"] = input.period;\n if (input.subjectOverride !== undefined) fields[\"Subject override\"] = input.subjectOverride;\n // Auto-ticked checklist boxes + the evidence snapshot. The booleans are the same columns the\n // operator/dashboard toggle; the JSON is what the health gate reads (isHealthGateClear/gatingHealth)\n // and also drives the dashboard's green/amber badges.\n for (const field of input.checklistTicks ?? []) fields[field] = true;\n if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {\n fields[\"Checklist auto-evidence\"] = JSON.stringify(input.autoEvidence);\n }\n const created = (await base(REPORTS_TABLE).create([{ fields }])) as Records<FieldSet>;\n const rec = created[0];\n if (!rec) throw new Error(\"Airtable create returned no records\");\n const raw = { id: rec.id, fields: rec.fields as Record<string, unknown> };\n // The mirror gets what Airtable STORED, never the caller's DraftInput: parity\n // diffs Turso against the stored record, so mapping the input would diverge\n // the moment Airtable normalises a value. Injected rather than called\n // directly so this module keeps no db dependency; the injected\n // implementation owns the never-throw policy (see reports/report-mirror.ts).\n if (mirror) await mirror(raw);\n return mapRow(raw);\n}\n\nexport async function setDraftReady(\n base: AirtableBase,\n recordId: string,\n ready: boolean,\n): Promise<void> {\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { \"Draft ready\": ready } }]);\n}\n\n/**\n * Overwrite the four `Lighthouse — *` score cells (and, when supplied, `Completed\n * on`) on an EXISTING Reports row. The launch re-run path uses this: it reuses the\n * already-created Launch row but must refresh its scores to match the freshly-run\n * audit — otherwise the re-rendered preview shows new scores while the row (and the\n * eventually-sent email, which reads the row) keeps the stale ones. The create path\n * already writes fresh scores via `createDraft`; this is its update-side mirror, using\n * the same exact field names so the two stay in lockstep.\n */\nexport async function updateReportScores(\n base: AirtableBase,\n recordId: string,\n scores: LighthouseScores,\n completedOn?: Date,\n enrichment?: ReportEnrichment,\n): Promise<void> {\n const fields: FieldSet = {\n \"Lighthouse — Performance\": scores.performance,\n \"Lighthouse — Accessibility\": scores.accessibility,\n \"Lighthouse — Best Practices\": scores.bestPractices,\n \"Lighthouse — SEO\": scores.seo,\n };\n if (completedOn) fields[\"Completed on\"] = ymd(completedOn);\n if (enrichment?.gaUsersCurrent !== undefined)\n fields[\"GA users (period)\"] = enrichment.gaUsersCurrent;\n if (enrichment?.gaUsersPrevious !== undefined)\n fields[\"GA users (prev period)\"] = enrichment.gaUsersPrevious;\n if (enrichment?.searchFoundPage1 !== undefined)\n fields[\"Search found page 1\"] = enrichment.searchFoundPage1;\n if (enrichment?.searchPosition !== undefined)\n fields[\"Search position\"] = enrichment.searchPosition;\n await base(REPORTS_TABLE).update([{ id: recordId, fields }]);\n}\n\n/** Write one report's operator commentary (#539 Phase 4 report review). The\n * caller (`setReportCommentary`) has already validated the length and refused\n * the write for an already-sent report, so this stays a plain single-field\n * writer — the same division of labour as `updateSiteField`/`setSiteDetail`. */\nexport async function updateReportCommentary(\n base: AirtableBase,\n recordId: string,\n text: string,\n): Promise<void> {\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { Commentary: text } }]);\n}\n\nexport async function listSendableReports(base: AirtableBase): Promise<ReportRow[]> {\n const out: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({\n filterByFormula:\n \"AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())\",\n pageSize: 100,\n })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\n/**\n * Fetch every Reports row, unfiltered. Site-scoped callers filter the result in\n * memory: the `Site` linked-record field CANNOT be formula-filtered by record id\n * (see findReportByPeriod's doc for why), and the fleet's Reports table is small\n * enough that one paged fetch-all beats N broken-or-per-site queries.\n */\nexport async function listAllReports(base: AirtableBase): Promise<ReportRow[]> {\n const out: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\nexport async function listReportsForSite(base: AirtableBase, siteId: string): Promise<ReportRow[]> {\n // Client-side match on the mapped siteId (mapRow reads the record id from the\n // REST response, where it IS present) — record ids can't appear in formulas.\n return (await listAllReports(base)).filter((r) => r.siteId === siteId);\n}\n\n/**\n * Mark a row as sent: write `Sent at` and (when known) `Resend message ID`.\n * Crucially does NOT touch `Delivery status` — that's set to \"pending\" in\n * createDraft and updated by the webhook from there. If we wrote \"pending\" here\n * we could clobber a \"delivered\" that the webhook raced ahead and wrote first (H4).\n *\n * `messageId` may be `null`: the 409 idempotency-conflict path has no recoverable\n * id (the original send's id was lost when the prior run's stamp failed), so it\n * passes null and we write ONLY `Sent at`. Writing a sentinel string like\n * \"idempotent-conflict\" into the id column would masquerade as a real Resend id\n * and silently orphan `findReportByMessageId` lookups; leaving it null is honest —\n * the row still stops replaying (Sent at is set), delivery tracking for that one\n * report is simply degraded (the id is genuinely unknowable on that path).\n */\nexport async function stampSent(\n base: AirtableBase,\n recordId: string,\n sentAt: Date,\n messageId: string | null,\n): Promise<void> {\n const fields: Record<string, string> = { \"Sent at\": sentAt.toISOString() };\n if (messageId !== null) fields[\"Resend message ID\"] = messageId;\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields,\n },\n ]);\n}\n\nexport async function setDeliveryStatus(\n base: AirtableBase,\n recordId: string,\n status: DeliveryStatus,\n): Promise<void> {\n await base(REPORTS_TABLE).update([{ id: recordId, fields: { \"Delivery status\": status } }]);\n}\n\n/**\n * Stamp the approval on a Reports row: flips `Approved to send` TRUE and records\n * who/when for the audit trail. The caller (approveReport handler) is responsible\n * for idempotency — this is the raw write. Never touches `Sent at`.\n */\nexport async function approveReportRow(\n base: AirtableBase,\n recordId: string,\n approvedAt: Date,\n approvedBy: string,\n): Promise<void> {\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields: {\n \"Approved to send\": true,\n \"Approved At\": approvedAt.toISOString(),\n \"Approved By\": approvedBy,\n },\n },\n ]);\n}\n\n/**\n * Stamp a logged send-anyway override on a Reports row: sets `Send override` TRUE, the reason, and\n * who/when — AND flips `Approved to send` TRUE (with the same Approved At/By stamp) so the daily\n * cron delivers the overridden report. Mirrors {@link approveReportRow}; never touches `Sent at`.\n */\nexport async function overrideReportRow(\n base: AirtableBase,\n recordId: string,\n overrideAt: Date,\n overrideBy: string,\n reason: string,\n): Promise<void> {\n const at = overrideAt.toISOString();\n await base(REPORTS_TABLE).update([\n {\n id: recordId,\n fields: {\n \"Send override\": true,\n \"Override reason\": reason,\n \"Override by\": overrideBy,\n \"Override at\": at,\n \"Approved to send\": true,\n \"Approved At\": at,\n \"Approved By\": overrideBy,\n },\n },\n ]);\n}\n\n/**\n * True when an `.find` rejection is a GENUINE not-found, not a transient failure.\n * The Airtable SDK stamps `.statusCode` (404) and/or `.error` (\"NOT_FOUND\") on\n * its errors. Anything else (429 rate-limit, 500 outage, bad-PAT 401, network\n * error) must NOT be masked as a 404 — see getReportById.\n */\nfunction isNotFoundError(err: unknown): boolean {\n if (typeof err !== \"object\" || err === null) return false;\n const e = err as { statusCode?: unknown; error?: unknown; name?: unknown; message?: unknown };\n if (e.statusCode === 404) return true;\n const tag = String(e.error ?? e.name ?? e.message ?? \"\");\n return tag === \"NOT_FOUND\" || /not found/i.test(tag);\n}\n\n/**\n * Fetch one Reports row by its Airtable record id, or null if it doesn't exist.\n * Only a GENUINE not-found (404 / NOT_FOUND) collapses to null; every other\n * failure (outage, 429, bad PAT, network error) is rethrown so the adapter\n * surfaces a 500 instead of a misleading 404. Swallowing all throws previously\n * turned an Airtable outage into a \"no such report\".\n */\nexport async function getReportById(\n base: AirtableBase,\n recordId: string,\n): Promise<ReportRow | null> {\n try {\n const rec = await base(REPORTS_TABLE).find(recordId);\n return mapRow({ id: rec.id, fields: rec.fields as Record<string, unknown> });\n } catch (err) {\n if (isNotFoundError(err)) return null;\n throw err;\n }\n}\n\nexport async function findReportByMessageId(\n base: AirtableBase,\n messageId: string,\n): Promise<ReportRow | null> {\n const rows: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({\n filterByFormula: `{Resend message ID} = \"${escapeFormulaString(messageId)}\"`,\n maxRecords: 1,\n })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return rows[0] ?? null;\n}\n\n/**\n * Find the Reports row for a `(site, reportType, period)` triple, or null. The\n * idempotency lookup behind search-before-create drafting.\n *\n * The site is matched CLIENT-side, never in the formula: Airtable's formula layer\n * renders linked-record fields ({Site}) as the linked rows' PRIMARY-FIELD NAMES,\n * not record ids, so any formula comparing {Site} or ARRAYJOIN({Site}) against a\n * `recXXX` id matches NOTHING (live-proven against the real base — do not\n * reintroduce that idiom). Record ids exist only in the REST response, where\n * mapRow reads them. So the formula filters on the real scalar fields (Report\n * type + Period — escaped, keeping it injection-safe if their source ever\n * changes), and the first mapped row whose siteId matches wins. The candidate\n * set is at most one row per site for the (type, period), so this stays small.\n */\nexport async function findReportByPeriod(\n base: AirtableBase,\n siteId: string,\n reportType: ReportType,\n period: string,\n): Promise<ReportRow | null> {\n const safeType = escapeFormulaString(reportType);\n const safePeriod = escapeFormulaString(period);\n const formula = `AND({Report type} = \"${safeType}\", {Period} = \"${safePeriod}\")`;\n const rows: ReportRow[] = [];\n await base(REPORTS_TABLE)\n .select({ filterByFormula: formula, pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return rows.find((r) => r.siteId === siteId) ?? null;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
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 Airtable module's\n * own exported coercers (`toVerdict`, `toFrequency`, `parseNotifyRouting`,\n * `parseSecurityAdvisories`, `trimToNull`) so there is one truth for each.\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/airtable/reports.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { canonicalizeStatus } from \"../reports/airtable/site-status.js\";\nimport {\n parseNotifyRouting,\n parseSecurityAdvisories,\n toFrequency,\n toPrismicModelsVerdict,\n toVerdict,\n trimToNull,\n} from \"../reports/airtable/websites.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 * `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/** 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/** 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/**\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/** 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":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cli/fleet/resolve-sites.ts"],"sourcesContent":["import { pathToFileURL } from \"node:url\";\nimport { resolve, extname } from \"node:path\";\nimport type { InventoryProvider, Site } from \"../../types.js\";\nimport { localPath } from \"../../inventory/local.js\";\nimport { fromJsonFile } from \"../../inventory/json.js\";\nimport { isHttpUrl } from \"../../util/url.js\";\n\n/** A dynamic .js/.mjs/.cjs inventory returns arbitrary Site objects; apply the\n * same `deployedUrl` scheme-allowlist the JSON + Airtable providers enforce, so\n * a module returning `file:///…`/`gopher://…` can't reach Chrome/lhci. Drop the\n * offending value (and warn) rather than throw — matching the JSON provider. */\nexport function sanitizeDynamicSites(sites: Site[]): Site[] {\n return sites.map((s) => {\n if (s.deployedUrl !== undefined && !isHttpUrl(s.deployedUrl)) {\n console.warn(\n `[inventory] dynamic inventory: ignoring deployedUrl that is not http(s) for ` +\n `${s.name ?? s.path}: ${JSON.stringify(s.deployedUrl)}`,\n );\n const copy = { ...s };\n delete copy.deployedUrl;\n return copy;\n }\n return s;\n });\n}\n\nexport type ResolveSitesInput = {\n site?: string;\n fleet?: string;\n /** Optional workdir for the `--fleet airtable` keyword path (computes site.path as {workdir}/{slug}). */\n workdir?: string;\n cwd: string;\n};\n\nexport async function resolveSites(input: ResolveSitesInput): Promise<Site[]> {\n if (input.site && input.fleet) {\n throw Object.assign(new Error(\"cannot combine a positional [site] with --fleet\"), {\n exitCode: 2,\n });\n }\n\n if (input.fleet === \"airtable\") {\n const { openBase, readAirtableConfig } = await import(\"../../reports/airtable/client.js\");\n const { fromAirtableBase } = await import(\"../../inventory/airtable.js\");\n const base = openBase(readAirtableConfig());\n const provider = fromAirtableBase(base, input.workdir ? { workdir: input.workdir } : {});\n return provider();\n }\n\n if (input.fleet) {\n const fleetPath = resolve(input.cwd, input.fleet);\n const ext = extname(fleetPath).toLowerCase();\n if (ext === \".json\") {\n // fromJsonFile already scheme-allowlists deployedUrl internally.\n return fromJsonFile(fleetPath)();\n }\n if (ext === \".js\" || ext === \".mjs\" || ext === \".cjs\") {\n const mod = (await import(pathToFileURL(fleetPath).href)) as {\n default?: InventoryProvider;\n };\n if (!mod.default || typeof mod.default !== \"function\") {\n throw Object.assign(new Error(`--fleet ${input.fleet}: default export is not a function`), {\n exitCode: 2,\n });\n }\n // A dynamic module's deployedUrl is unvalidated — sanitize before it can\n // reach a deployed audit (the JSON/Airtable providers already do this).\n return sanitizeDynamicSites(await mod.default());\n }\n throw Object.assign(\n new Error(`--fleet ${input.fleet}: unsupported extension ${ext || \"(none)\"}`),\n { exitCode: 2 },\n );\n }\n\n return localPath(resolve(input.cwd, input.site ?? input.cwd))();\n}\n"],"mappings":";;;;;;;;;AAAA,SAAS,qBAAqB;AAC9B,SAAS,SAAS,eAAe;AAU1B,SAAS,qBAAqB,OAAuB;AAC1D,SAAO,MAAM,IAAI,CAAC,MAAM;AACtB,QAAI,EAAE,gBAAgB,UAAa,CAAC,UAAU,EAAE,WAAW,GAAG;AAC5D,cAAQ;AAAA,QACN,+EACK,EAAE,QAAQ,EAAE,IAAI,KAAK,KAAK,UAAU,EAAE,WAAW,CAAC;AAAA,MACzD;AACA,YAAM,OAAO,EAAE,GAAG,EAAE;AACpB,aAAO,KAAK;AACZ,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,CAAC;AACH;AAUA,eAAsB,aAAa,OAA2C;AAC5E,MAAI,MAAM,QAAQ,MAAM,OAAO;AAC7B,UAAM,OAAO,OAAO,IAAI,MAAM,iDAAiD,GAAG;AAAA,MAChF,UAAU;AAAA,IACZ,CAAC;AAAA,EACH;AAEA,MAAI,MAAM,UAAU,YAAY;AAC9B,UAAM,EAAE,UAAU,mBAAmB,IAAI,MAAM,OAAO,sBAAkC;AACxF,UAAM,EAAE,iBAAiB,IAAI,MAAM,OAAO,wBAA6B;AACvE,UAAM,OAAO,SAAS,mBAAmB,CAAC;AAC1C,UAAM,WAAW,iBAAiB,MAAM,MAAM,UAAU,EAAE,SAAS,MAAM,QAAQ,IAAI,CAAC,CAAC;AACvF,WAAO,SAAS;AAAA,EAClB;AAEA,MAAI,MAAM,OAAO;AACf,UAAM,YAAY,QAAQ,MAAM,KAAK,MAAM,KAAK;AAChD,UAAM,MAAM,QAAQ,SAAS,EAAE,YAAY;AAC3C,QAAI,QAAQ,SAAS;AAEnB,aAAO,aAAa,SAAS,EAAE;AAAA,IACjC;AACA,QAAI,QAAQ,SAAS,QAAQ,UAAU,QAAQ,QAAQ;AACrD,YAAM,MAAO,MAAM,OAAO,cAAc,SAAS,EAAE;AAGnD,UAAI,CAAC,IAAI,WAAW,OAAO,IAAI,YAAY,YAAY;AACrD,cAAM,OAAO,OAAO,IAAI,MAAM,WAAW,MAAM,KAAK,oCAAoC,GAAG;AAAA,UACzF,UAAU;AAAA,QACZ,CAAC;AAAA,MACH;AAGA,aAAO,qBAAqB,MAAM,IAAI,QAAQ,CAAC;AAAA,IACjD;AACA,UAAM,OAAO;AAAA,MACX,IAAI,MAAM,WAAW,MAAM,KAAK,2BAA2B,OAAO,QAAQ,EAAE;AAAA,MAC5E,EAAE,UAAU,EAAE;AAAA,IAChB;AAAA,EACF;AAEA,SAAO,UAAU,QAAQ,MAAM,KAAK,MAAM,QAAQ,MAAM,GAAG,CAAC,EAAE;AAChE;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/recipes/match-harness/index.ts","../src/recipes/match-harness/previous.ts"],"sourcesContent":["import { mkdir, readFile, rm, rmdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../../types.js\";\nimport { withRecipe } from \"../_with-recipe.js\";\nimport { ignoreRulesFor, pathsMissingFromHead } from \"../../util/git.js\";\nimport { defaultSpawn, type SpawnFn } from \"../../audits/util/spawn.js\";\nimport { formatWithPrettier, resolveTargetPrettier, PRETTIER_FLAG_NOTE } from \"../_prettier.js\";\nimport {\n MATCH_HARNESS_FILES,\n MATCH_HARNESS_PREVIOUS,\n MATCH_HARNESS_COUPLED,\n HARNESS_JSON_RELATIVE,\n GITIGNORE_MARKER,\n GITIGNORE_BLOCK,\n PRETTIERIGNORE_MARKER,\n PRETTIERIGNORE_BLOCK,\n CLAUDE_MD_MARKER,\n CLAUDE_MD_BLOCK,\n} from \"./template.js\";\nimport { MATCH_HARNESS_BLOCK_PREVIOUS } from \"./previous.js\";\n\n/** How long the target's own prettier gets. Also what makes the fleet's default\n * spawn detach the child, so the kill reaches prettier and not just a wrapper.\n * Same budget as `prismic-ci` and the Prismic pull-down path. */\nconst PRETTIER_TIMEOUT_MS = 60_000;\n\nexport type MatchHarnessOptions = {\n /** The live reference URL the harness gates against. Required — a harness\n * with no reference cannot produce evidence, only an absence of errors. */\n ref: string;\n /** Candidate origin. Defaults to the SvelteKit dev server. */\n cand?: string;\n /** Breakpoint matrix. Defaults to the fleet's 1440 / 834 / 390. */\n matrix?: number[];\n};\n\nexport type MatchHarnessDeps = {\n spawn: SpawnFn;\n /** Previously shipped renders per relative path; injectable so the\n * safe-replace path is testable before a second version exists. */\n previous?: Readonly<Record<string, readonly string[]>>;\n /** Previously shipped BLOCK bodies per target file; injectable for the same\n * reason, and empty in the shipped table until a body is superseded. */\n blockPrevious?: Readonly<Record<string, readonly string[]>>;\n /** Resolve the TARGET repo's own prettier. Injected so a test can assert the\n * absolute-path spawn without a populated `node_modules`. */\n resolvePrettier?: (repoRoot: string) => Promise<string | null>;\n};\n\ntype Plan = { ref: string; cand: string; matrix: number[] };\n\nasync function readIfExists(path: string): Promise<string | null> {\n try {\n return await readFile(path, \"utf-8\");\n } catch {\n return null;\n }\n}\n\n/** CRLF and trailing whitespace are the only differences a site's formatter is\n * allowed to introduce before a file stops counting as \"ours\". */\nfunction normalize(s: string): string {\n return s\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/[ \\t]+$/gm, \"\")\n .trim();\n}\n\n/** What to do with one marked block region. `flag` and `skip` never write.\n * One member per action, so `content` narrows on the discriminant alone. */\nexport type BlockPlan =\n | { action: \"write\"; content: string }\n | { action: \"replace\"; content: string }\n | { action: \"terminate\"; content: string }\n | { action: \"skip\" }\n | { action: \"flag\" };\n\n/**\n * Plan one marked block region. The only append-to-existing-file idiom in this\n * repo is mergeGitignore, and it cannot express a negated whitelist\n * (`matching/*` then `!matching/*.sh`) as one unit: it compares entries by\n * normalized presence, so the ordering that makes the block work would be lost.\n * So the block is kept whole — and, being whole, it needs an END as well as a\n * start if it is ever to be CORRECTED rather than only appended.\n *\n * That was #739. The predecessor returned \"already done\" the instant the marker\n * was present, which is right for \"never append twice\" and wrong for everything\n * else: the block's CONTENTS could then never change on a site that had already\n * installed. On `.gitignore` that is not staleness but a brick — the block is a\n * negated whitelist, so a harness file at a path it does not re-include is\n * absent from the commit, `pathsMissingFromHead` refuses the install, and the\n * remedy (widen the whitelist) is the one edit that cannot land.\n *\n * Four states, and the third is the one that made this worth doing properly:\n *\n * - No marker: append the region, terminator included.\n * - Marker AND terminator: the extent is KNOWN. Body still current -> skip.\n * Body byte-matches something we shipped before -> replace IN PLACE, keeping\n * everything before and after the region. Anything else -> flag, write\n * nothing.\n * - Marker, NO terminator — every site installed from 0.95.0: the extent is not\n * known, so it is never guessed. A candidate qualifies only by matching\n * EXACTLY, byte-for-byte, at the exact offset the body starts; what follows\n * is the site's own and is preserved verbatim. Guessing end-of-file instead\n * would eat whatever mergeGitignore appended after us.\n * - Anything unrecognised: flag. Anchoring is on the FIRST occurrence of the\n * marker, so a marker quoted in the site's own prose mis-anchors into text\n * that matches no candidate — which degrades to a flag, never to a write.\n */\nexport function planBlockWrite(\n existing: string | null,\n marker: string,\n endMarker: string,\n block: string,\n previous: readonly string[],\n): BlockPlan {\n const region = `${marker}\\n${block}\\n${endMarker}`;\n if (existing === null) return { action: \"write\", content: `${region}\\n` };\n\n const start = existing.indexOf(marker);\n if (start === -1) {\n const base = existing.endsWith(\"\\n\") ? existing : `${existing}\\n`;\n return { action: \"write\", content: `${base}\\n${region}\\n` };\n }\n\n const head = existing.slice(0, start);\n const bodyStart = start + marker.length + 1;\n\n const endAt = existing.indexOf(endMarker, bodyStart);\n if (endAt !== -1) {\n const body = existing.slice(bodyStart, endAt);\n if (normalize(body) === normalize(block)) return { action: \"skip\" };\n if (previous.some((p) => normalize(body) === normalize(p)))\n return {\n action: \"replace\",\n content: head + region + existing.slice(endAt + endMarker.length),\n };\n return { action: \"flag\" };\n }\n\n for (const candidate of [block, ...previous]) {\n if (!existing.startsWith(candidate, bodyStart)) continue;\n const content = `${head}${region}\\n${existing.slice(bodyStart + candidate.length)}`;\n // Matching the CURRENT block means the bytes do not change at all and only\n // the terminator is added — the migration write is content-neutral.\n return candidate === block ? { action: \"terminate\", content } : { action: \"replace\", content };\n }\n return { action: \"flag\" };\n}\n\n/**\n * The commit message says what the run DID. Every run used to commit as\n * `feat: install …`, including one that installed nothing — measured on 29-navy:\n * three scripts upgraded, three block regions terminated, 6 files and 436\n * lines, labelled a first install (#760). The notes distinguished the two the\n * whole time; the message is what a reader bisecting `git log` sees.\n *\n * `write` anywhere is an install; only replace/terminate is an upgrade; both is\n * both. A pristine install keeps the historical one-line message byte-for-byte.\n * An upgrade names its paths in the body, so `git log` answers \"when did this\n * site's gate.sh change\" without a diff.\n */\nexport function commitMessage(installed: readonly string[], upgraded: readonly string[]): string {\n const what = \"(/dev/match route + matching/ gate scripts)\";\n if (upgraded.length === 0) return `feat: install the matching harness ${what}`;\n const subject =\n installed.length > 0\n ? `feat: install and upgrade the matching harness ${what}`\n : `chore: upgrade the matching harness (${upgraded.length} file${upgraded.length === 1 ? \"\" : \"s\"} from a previously shipped version)`;\n const body = [\n installed.length > 0 ? `installed: ${installed.join(\", \")}` : \"\",\n `upgraded from a previously shipped version: ${upgraded.join(\", \")}`,\n ]\n .filter(Boolean)\n .join(\"\\n\");\n return `${subject}\\n\\n${body}`;\n}\n\n/** What to do with one installed file. `flag` never writes. */\nexport function planFileWrite(\n existing: string | null,\n template: string,\n owner: \"recipe\" | \"site\",\n previous: readonly string[],\n): \"write\" | \"replace\" | \"skip\" | \"flag\" {\n if (existing === null) return \"write\";\n if (normalize(existing) === normalize(template)) return \"skip\";\n if (owner === \"site\") return \"skip\"; // records are never touched again\n if (previous.some((p) => normalize(existing) === normalize(p))) return \"replace\";\n return \"flag\";\n}\n\n/**\n * Where each block region ENDS. Deliberately NOT in template.ts beside the\n * start markers, and the split is a real smell being paid for on purpose:\n * template.ts is GENERATED, and regenerating it today drags in undeclared drift\n * from the source site and would flag three already-installed scripts forever.\n * Reuniting them belongs to #753, which makes the generator's\n * `MATCH_HARNESS_PREVIOUS` emission trustworthy enough to regenerate against.\n * That table stays in template.ts and stays POPULATED in the meantime: it is\n * what upgrades an installed site's gate.sh past #744, so pointing this recipe\n * at an empty one un-ships that fix to every site already carrying the v1 gate.\n *\n * None of the three contains its own start marker as a substring — `# end\n * reddoor-maint …` never yields `# reddoor-maint …` — which is what keeps\n * `indexOf(marker)` from anchoring the region on its own terminator. A test\n * asserts it in both directions.\n */\nexport const GITIGNORE_END_MARKER = \"# end reddoor-maint match-harness\";\nexport const PRETTIERIGNORE_END_MARKER = \"# end reddoor-maint match-harness\";\nexport const CLAUDE_MD_END_MARKER = \"<!-- end reddoor-maint match-harness -->\";\n\n/** The three files the recipe APPENDS a marked block to rather than writing whole. */\nconst APPENDED_BLOCKS = [\n [\".gitignore\", GITIGNORE_MARKER, GITIGNORE_END_MARKER, GITIGNORE_BLOCK],\n [\".prettierignore\", PRETTIERIGNORE_MARKER, PRETTIERIGNORE_END_MARKER, PRETTIERIGNORE_BLOCK],\n [\"CLAUDE.md\", CLAUDE_MD_MARKER, CLAUDE_MD_END_MARKER, CLAUDE_MD_BLOCK],\n] as const;\n\n/**\n * Every path the installed harness requires to be IN THE COMMIT — deliberately\n * the whole manifest, NOT the paths written on this run. A re-run skips every\n * file already byte-correct on disk, so a check over the written delta would\n * find nothing missing and report a second hollow \"applied\" over the same\n * half-install.\n */\nexport const MATCH_HARNESS_INSTALLED_PATHS: readonly string[] = [\n ...MATCH_HARNESS_FILES.map((f) => f.rel),\n ...APPENDED_BLOCKS.map(([rel]) => rel),\n];\n\n/**\n * Installs the matching harness into a site: the dev-guarded `/dev/match/[uid]`\n * route, a `site-pages.js` scaffold and its fixture-vs-model test,\n * `matching/harness.json` + the read layer, the round scripts, the site records,\n * and the `.gitignore` / `.prettierignore` / `CLAUDE.md` blocks.\n *\n * Install-if-absent throughout. Files a site edits are never rewritten. A\n * recipe-owned script that matches a previously shipped render is safe-replaced;\n * one that has been hand-edited is FLAGGED in the notes and left alone.\n *\n * ONE EXCEPTION TO PER-FILE INDEPENDENCE: the scripts in MATCH_HARNESS_COUPLED\n * upgrade as a SET. If any one of them is hand-edited, none of them is written,\n * because gate.sh calls `harness.mjs --check-run` and next.mjs imports from\n * harness.mjs — upgrading the others around a pinned one leaves a harness that\n * cannot run at all. See the measurement on MATCH_HARNESS_COUPLED.\n */\nexport async function matchHarness(\n site: Site,\n opts: MatchHarnessOptions,\n deps: MatchHarnessDeps = { spawn: defaultSpawn },\n): Promise<RecipeResult> {\n return withRecipe<Plan>({\n name: \"match-harness\",\n site,\n plan: async () => {\n if (!opts.ref || !/^https?:\\/\\//.test(opts.ref)) {\n return {\n kind: \"failed\",\n notes:\n \"--ref <url> is required: the harness gates against a live reference, and a harness with no reference can only ever report an absence of errors\",\n };\n }\n return {\n kind: \"apply\",\n plan: {\n ref: opts.ref.replace(/\\/$/, \"\"),\n cand: opts.cand ?? \"http://localhost:5173\",\n matrix: opts.matrix ?? [1440, 834, 390],\n },\n };\n },\n apply: async (planned, { commit, cwd }) => {\n const notes: string[] = [];\n const written: string[] = [];\n /** What this run did, for the commit message: paths written for the\n * FIRST time, and paths replaced or terminated from a previously shipped\n * render. The notes already tell them apart; the message is what\n * survives into `git log` (#760). */\n const installed: string[] = [];\n const upgraded: string[] = [];\n /** For every path this run writes: what was there BEFORE (null = the file\n * did not exist). The refusal below restores from this, so a refused run\n * leaves the checkout byte-identical to how it found it. */\n const before = new Map<string, string | null>();\n const previousAll = deps.previous ?? MATCH_HARNESS_PREVIOUS;\n const blockPreviousAll = deps.blockPrevious ?? MATCH_HARNESS_BLOCK_PREVIOUS;\n\n // PASS 1 — decide everything, write nothing. The coupled-set demotion\n // below needs the whole picture before the first byte is written.\n type Planned = {\n f: (typeof MATCH_HARNESS_FILES)[number];\n template: string;\n existing: string | null;\n action: ReturnType<typeof planFileWrite>;\n };\n const plans: Planned[] = [];\n for (const f of MATCH_HARNESS_FILES) {\n const target = join(cwd, f.rel);\n let template = f.template;\n if (f.rel === HARNESS_JSON_RELATIVE) {\n // Patch the PARSED object, never the text. The seed is pretty-printed\n // two-space JSON, so `matrix` renders as a four-line exploded array\n // and the literal \"[1440, 834, 390]\" does not occur — a string\n // replace on it is a no-op that drops --matrix with no error.\n const seed = JSON.parse(template) as Record<string, unknown>;\n seed.ref = planned.ref;\n seed.cand = planned.cand;\n seed.matrix = planned.matrix;\n template = JSON.stringify(seed, null, 2) + \"\\n\";\n }\n const existing = await readIfExists(target);\n plans.push({\n f,\n template,\n existing,\n action: planFileWrite(existing, template, f.owner, previousAll[f.rel] ?? []),\n });\n }\n\n // The coupled set moves together or not at all. Two deliberate limits,\n // both of them the difference between a guard and an outage:\n //\n // ONLY `flag` demotes. `skip` means the file is already byte-correct, and\n // treating that as a flag would make every re-run refuse itself and the\n // recipe permanently inert — the demotion's own failure mode is\n // OVER-refusal, so its test has to GRANT an upgrade, not merely deny one.\n //\n // ONLY `replace` is demoted. A file that is ABSENT cannot be \"left\n // alone\": there is nothing to preserve, and skipping the write leaves the\n // site with no harness at all rather than an old one. The hazard being\n // guarded is upgrading HALF of an installed set past a pinned member;\n // writing a missing file is the only action that makes the set exist.\n // (Measured: demoting `write` too broke a fresh install alongside one\n // hand-edited script — 16 files silently unwritten, then a failed run.)\n const coupled = new Set(MATCH_HARNESS_COUPLED);\n const blockedBy = plans\n .filter((p) => coupled.has(p.f.rel) && p.action === \"flag\")\n .map((p) => p.f.rel);\n const demoted = plans.filter(\n (p) => coupled.has(p.f.rel) && p.action === \"replace\" && !blockedBy.includes(p.f.rel),\n );\n if (blockedBy.length > 0 && demoted.length > 0) {\n for (const p of demoted) p.action = \"flag\";\n notes.push(\n `${blockedBy.join(\", \")} differs from the shipped template, so the whole coupled set ` +\n `was left alone: ${MATCH_HARNESS_COUPLED.join(\", \")} upgrade together. ` +\n `matching/gate.sh calls \\`harness.mjs --check-run\\` and matching/next.mjs imports from ` +\n `harness.mjs, so upgrading one without the others leaves a harness that cannot run.`,\n );\n }\n\n // PASS 2 — write.\n const demotedRels = new Set(demoted.map((p) => p.f.rel));\n for (const { f, template, existing, action } of plans) {\n if (action === \"flag\") {\n if (demotedRels.has(f.rel)) continue; // named in the set note above\n notes.push(\n `${f.rel} differs from the shipped template and was left alone (hand-edited?)`,\n );\n continue;\n }\n if (action === \"skip\") continue;\n const target = join(cwd, f.rel);\n await mkdir(dirname(target), { recursive: true });\n before.set(f.rel, existing);\n await writeFile(target, template, \"utf-8\");\n written.push(f.rel);\n (action === \"write\" ? installed : upgraded).push(f.rel);\n if (action === \"replace\") notes.push(`${f.rel} upgraded from a previous version`);\n }\n\n // Three appended blocks, each a delimited region that is replaced in\n // place when its body is one this recipe shipped, and flagged — never\n // overwritten — when it is anything else.\n for (const [rel, marker, endMarker, block] of APPENDED_BLOCKS) {\n const path = join(cwd, rel);\n const existing = await readIfExists(path);\n const plan = planBlockWrite(\n existing,\n marker,\n endMarker,\n block,\n blockPreviousAll[rel] ?? [],\n );\n if (plan.action === \"skip\") continue;\n if (plan.action === \"flag\") {\n // Never enters `written`: not formatted, and not restored by the\n // refusal path below, because this run did not touch it.\n notes.push(\n `${rel} carries a match-harness block that differs from every block this recipe has shipped, and was left alone (hand-edited?)`,\n );\n continue;\n }\n before.set(rel, existing);\n await writeFile(path, plan.content, \"utf-8\");\n written.push(rel);\n (plan.action === \"write\" ? installed : upgraded).push(`${rel} (match-harness block)`);\n if (plan.action === \"replace\")\n notes.push(`${rel}'s match-harness block upgraded from a previous version`);\n if (plan.action === \"terminate\")\n notes.push(\n `${rel}'s match-harness block region was terminated so a future version can update it`,\n );\n }\n\n // Format only what the SITE owns, decided by OWNERSHIP and never by path.\n // A recipe-owned file that a site's prettier reformats is indistinguishable\n // from a hand edit on the next install, so planFileWrite flags it and the\n // recipe can never upgrade it again. A path prefix cannot express that:\n // three recipe-owned files live under src/. This is an ALLOW-list on\n // purpose — a file added to MATCH_HARNESS_FILES is not formatted until its\n // owner says \"site\", because the unsafe default is silent and permanent.\n // The same files are in the .prettierignore block written above, which\n // stops the site's OWN `prettier --write .` from doing the identical\n // damage; that block and this filter must agree, and a test asserts it.\n // CLAUDE.md is not in the file table — it is an append into a file the\n // site owns — so it is named here.\n const siteOwned = new Set(\n MATCH_HARNESS_FILES.filter((f) => f.owner === \"site\").map((f) => f.rel),\n );\n const toFormat = written.filter((p) => siteOwned.has(p) || p === \"CLAUDE.md\");\n\n // Run the SITE's own prettier, resolved POSITIVELY and invoked by absolute\n // path. `pnpm exec prettier` here would first run a full, unbounded\n // `pnpm install` in the client's checkout — which on `--fleet` is the\n // NORMAL path, because `prepareFleetSites` clones and never installs — and\n // would then, in a repo whose install left no prettier of its own, fall\n // through to the CALLING repo's binary and exit 0, reporting success for a\n // format the target never did. A `true` from here must mean the target's\n // own prettier ran, so nothing about which binary runs is left to\n // resolution.\n if (toFormat.length > 0) {\n const bin = await (deps.resolvePrettier ?? resolveTargetPrettier)(cwd);\n if (bin === null) {\n notes.push(PRETTIER_FLAG_NOTE);\n } else if (\n !(await formatWithPrettier(deps.spawn, cwd, toFormat, {\n bin,\n timeoutMs: PRETTIER_TIMEOUT_MS,\n }))\n ) {\n notes.push(PRETTIER_FLAG_NOTE);\n }\n }\n\n await commit(commitMessage(installed, upgraded));\n\n // POSITIVE EVIDENCE that the install is real: every installed path is\n // FOUND in HEAD's tree. `git add -A` honours the site's .gitignore and\n // exits 0 either way, and git cannot re-include a file whose PARENT\n // DIRECTORY is excluded — so a site that already ignores `matching/`\n // (or `**/matching/`, `/matching`, `src/lib/site-pages.js`, …) gets the\n // files on disk, nothing in the commit, and — before this check — a\n // result of \"applied\", plus 83 lines of CLAUDE.md rules naming scripts\n // that exist on no other machine.\n const missing = await pathsMissingFromHead(cwd, MATCH_HARNESS_INSTALLED_PATHS);\n if (missing.length > 0) {\n const rules = await ignoreRulesFor(cwd, missing);\n const sources = [...new Set(rules.map((r) => r.split(\"\\t\")[0]!))];\n const explained = new Set(rules.map((r) => r.split(\"\\t\")[1] ?? \"\"));\n const unexplained = missing.filter((p) => !explained.has(p));\n\n // Put the checkout back exactly as we found it. Only paths THIS RUN\n // wrote and git then refused are touched: one that reached the commit\n // is left to withRecipe's force-checkout, and one we never wrote is\n // never ours. `git checkout -f` cannot do this job — these paths are\n // ignored, so git does not know they exist.\n const removed: string[] = [];\n for (const rel of written.filter((p) => missing.includes(p))) {\n const prev = before.get(rel);\n if (prev === undefined) continue;\n if (prev === null) {\n await rm(join(cwd, rel), { force: true });\n removed.push(rel);\n } else {\n await writeFile(join(cwd, rel), prev, \"utf-8\");\n }\n }\n // Deepest-first, so `matching/spec-sections` goes before `matching`.\n // rmdir refuses a non-empty directory, which is exactly the guard wanted.\n for (const d of [...new Set(removed.map(dirname))].sort((a, b) => b.length - a.length)) {\n if (d !== \".\") await rmdir(join(cwd, d)).catch(() => undefined);\n }\n\n return {\n kind: \"failed\",\n notes:\n `the harness was NOT installed: ${missing.length} of ${MATCH_HARNESS_INSTALLED_PATHS.length} paths git refused to track, so a fresh clone, CI and the next agent get a harness that does not exist. ` +\n `Absent from the commit: ${missing.join(\", \")}. ` +\n (sources.length > 0\n ? `Excluded by ${sources.join(\", \")} — narrow or remove those rules and re-run. `\n : \"\") +\n (unexplained.length > 0\n ? `No ignore rule matched ${unexplained.join(\", \")} — check .git/info/exclude, core.excludesFile, and whether the directory is a nested repository. `\n : \"\") +\n \"Everything this run wrote to those paths has been put back as it was.\",\n };\n }\n\n return notes.length > 0 ? { kind: \"ok\", notes: notes.join(\"; \") } : { kind: \"ok\" };\n },\n });\n}\n","/**\n * Block BODIES this recipe has previously shipped — the block half of what lets\n * it tell \"stale, but ours\" from \"hand-edited by the site\". `planBlockWrite`\n * flags every region it cannot recognise, so without a record of what was\n * shipped, install-once is install-forever for the three marked regions.\n *\n * The whole-FILE table is NOT here. It lives in the generated `template.ts` as\n * `MATCH_HARNESS_PREVIOUS`, it is POPULATED, and it is what upgrades an\n * installed site's `matching/gate.sh` to the one that stopped saying ALL DONE\n * over failed runs (#744). Re-pointing that import at an empty table here would\n * silently un-ship that fix to every site already carrying the v1 gate — which\n * is exactly what this file did on its first draft, and what\n * `tests/recipes/match-harness.test.ts`'s \"UPGRADES a site running the shipped\n * v1 gate.sh rather than flagging it\" caught. The deprecation note and the\n * import switch were written together; the data migration they both depend on\n * is #753, and had not happened. Whatever replaces the generated table has to\n * carry the bodies across in the same commit that moves the import.\n *\n * Two rules for what goes below, and they are why this table is HAND-AUTHORED:\n *\n * 1. An entry is added ONLY when a block body has been SUPERSEDED — at the\n * moment the shipped body changes, in the same commit that changes it.\n * Recording a body that is still current makes the table lie in the one\n * direction that matters: it would let a stale render be replaced by itself.\n *\n * 2. A body's source is a PUBLISHED GIT TAG — `git show v0.95.0:<path>` — never\n * the working tree. Reading the tree is how a mutant gets banked as a\n * legitimate prior release: mutate, regenerate, revert, and the mutation is\n * now a body this recipe will happily install over a real site. Measured on\n * this branch: one mutate/regenerate/revert cycle added 693 lines and two\n * copies of the same predicate.\n *\n * EMPTY at 0.95.1 on purpose. 0.95.1 changes no block body at all: its whole\n * job is to install the region terminators, which is what makes the first\n * exercise of the block replace path provably content-neutral.\n *\n * First entry after 0.95.1: the CLAUDE.md block, superseded when rule 4 and the\n * round protocol gained `census.sh` (#736). Sourced from `git show v0.95.1`,\n * per rule 2, and identical at v0.95.0.\n */\n\n/** The CLAUDE.md block as shipped at v0.95.0 and v0.95.1 — `git show\n * v0.95.1:src/recipes/match-harness/template.ts` → `CLAUDE_MD_BLOCK`, byte-\n * identical at both tags. Superseded by the block that names `census.sh`\n * (#736): the recipe installed the Phase 3 style gate and the rules that would\n * make anyone run it did not mention it. Exported so a test can seed exactly\n * this body and prove the shipped table upgrades it. */\nexport const CLAUDE_MD_BLOCK_0_95_1 = `\nThe \\`matching-a-page\\` skill governs a live-reference rebuild. These five rules\nexist because the skill alone did not hold on the project this harness came\nfrom — each one is a drift that actually happened, with the mechanical check\nthat now catches it. \\`matching/harness.json\\` is this site's configuration;\n\\`matching/LEDGER.md\\` is the dated record of every deviation, floor and mask.\n\n### 1. Source prescribes, rects only verify\n\nEvery geometry fix must cite the rule it came from: a line in the captured\nreference stylesheet under \\`matching/spec/\\`, or the reference's HTML. \"The probe\nsays the gap is 40px\" is not a source. If you cannot name the line you are\nguessing — go read the stylesheet first.\n\n**Check:** the commit body must name the file:line for each fix.\n**Operator's challenge:** _\"which line of the reference stylesheet says that?\"_\n\n### 2. Phase 1 before Phase 4\n\nA page gets its section census and per-section spec in \\`matching/SPEC.md\\` BEFORE\nits geometry is touched. No SPEC section, no geometry round. The census is the\ncoverage denominator; skipping it is how a reference's root-font ladder and its\nper-component height ladders get discovered reactively, after the region has\nalready failed several rounds.\n\n**Check:** \\`matching/gate.sh\\` refuses to run a page with no \\`SPEC.md\\` section.\n**Operator's challenge:** _\"show me the SPEC section for that region.\"_\n\n### 3. Three strikes, then stop\n\nA failing region that has not improved across 3+ gate runs does not get a fourth\nattempt. Present the attempts. Never widen the threshold, add a mask, or\nreclassify it as a floor to make it go away.\n\n**Check:** \\`node matching/strikes.mjs <page>\\` — exits 1 while any region is\nstalled, and is the first thing a geometry round runs.\n**Operator's challenge:** _\"how many runs has that region been flat?\"_\n\n### 4. A gate closes an item, nothing else\n\nNo fix is \"done\" because the code changed. Paste the gate header — it is\nself-describing, so a nonstandard threshold or an undisclosed mask is visible.\nThe threshold and the matrix are whatever \\`matching/harness.json\\` says, on every\npage, never a subset.\n\n**Operator's challenge:** _\"paste the gate header.\"_\n\n### 5. A commit is a checkpoint, not a stopping point\n\nDo not hand control back between rounds. After committing, run\n\\`node matching/next.mjs\\`: while it exits 1 there is a named next action, and the\nround continues. Report when the backlog is empty, when a decision is genuinely\nthe operator's (three strikes, a novel floor, a threshold change), or when\nasked — not because a commit felt like a natural place to summarise.\n\nThis exists because the failure was habitual, not deliberate: a turn ends when\nuser-facing prose gets written, and \"I just committed something good\" is the\nmoment that invites writing it. The gate stops incorrect work; this stops\npremature stopping.\n\n**Pausing:** create \\`matching/PAUSED\\` holding a note that says what the pause\nmeans; \\`next.mjs\\` and \\`strikes.mjs\\` then exit 0 while it exists. This rule says\n\"do not stop while work is named\"; it never said \"find work to name\". An empty\nagenda and a paused one end the round the same way. Deleting the switch is the\noperator's call.\n\n**Check:** \\`node matching/next.mjs\\` — exits 1 while any non-floor region fails.\n**Operator's challenge:** _\"what does next.mjs say?\"_\n\n### Round protocol\n\n1. \\`node matching/strikes.mjs <page>\\` — if it exits 1, the stalled regions are\n the agenda, and stalled ones get escalated rather than re-attempted.\n2. Confirm the page has a \\`matching/SPEC.md\\` section; write\n \\`matching/spec-sections/<page>.md\\` and run \\`node matching/build-spec.mjs\\` if\n not.\n3. Fix, each change citing its source line.\n4. \\`bash matching/gate.sh <tag> <page>\\` — paste the header.\n5. Append to \\`matching/LEDGER.md\\` at the moment a deviation, floor or mask is\n decided, not reconstructed at the end.\n6. \\`pnpm verify\\`, then commit and push.\n`;\n\n/** Block BODIES previously shipped per target file, for the three marked\n * regions in `.gitignore`, `.prettierignore` and `CLAUDE.md`. */\nexport const MATCH_HARNESS_BLOCK_PREVIOUS: Readonly<Record<string, readonly string[]>> = {\n \"CLAUDE.md\": [CLAUDE_MD_BLOCK_0_95_1],\n};\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/reports/airtable/attachments.ts"],"sourcesContent":["/** Cheap HTML sniff: an Airtable signed-URL \"200\" that is really a login/error page\n * starts with `<!doctype html`, `<html`, or `<head` after an optional UTF-8 BOM /\n * leading whitespace. We only need to catch the common error-page case, not parse\n * HTML. */\nfunction looksLikeHtml(bytes: Uint8Array): boolean {\n // Inspect the first ~64 bytes as ASCII (1 byte → 1 char; enough for a doctype /\n // opening tag). Skip a leading UTF-8 BOM (bytes EF BB BF) by index, then strip any\n // leading ASCII whitespace, and match the common HTML openers case-insensitively.\n const start = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;\n const head = Buffer.from(bytes.slice(start, start + 64))\n .toString(\"ascii\")\n .replace(/^[\\s]+/, \"\")\n .toLowerCase();\n return head.startsWith(\"<!doctype html\") || head.startsWith(\"<html\") || head.startsWith(\"<head\");\n}\n\nexport async function fetchAttachmentBytes(\n url: string,\n): Promise<{ bytes: Uint8Array; contentType: string }> {\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(\n `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`,\n );\n }\n const contentType = res.headers.get(\"content-type\") ?? \"application/octet-stream\";\n const ab = await res.arrayBuffer();\n const bytes = new Uint8Array(ab);\n // Sanity-gate the body: a 200 that is actually an HTML error/login page (expired\n // signed URL, auth wall) would otherwise be attached as the \"image\" and ship a\n // broken header. Accept an explicit image/* content-type; otherwise reject anything\n // that sniffs as HTML — so the send fails loudly rather than emailing a broken image.\n const isImageType = contentType.toLowerCase().startsWith(\"image/\");\n if (!isImageType && looksLikeHtml(bytes)) {\n throw new Error(\n `Airtable attachment did not return image data (content-type=\"${contentType}\", ` +\n `body looks like an HTML page — the signed URL may have expired) (url=${url})`,\n );\n }\n return { bytes, contentType };\n}\n\n/**\n * Upload bytes (or a string) as an attachment to a specific record + field.\n * Uses Airtable's content.airtable.com upload endpoint (base64 body) because\n * the standard SDK only accepts public URLs for attachments, and we don't\n * host the generated content anywhere public.\n *\n * Docs: https://airtable.com/developers/web/api/upload-attachment\n *\n * Requires AIRTABLE_PAT + AIRTABLE_BASE_ID in env (same as the rest of the\n * reports module). The fieldName is URL-encoded for the request path.\n */\n/**\n * Upload one file into an Airtable attachment field.\n *\n * ⚠️ Airtable's uploadAttachment endpoint APPENDS — it never replaces. Every\n * re-upload therefore stacks another file in the field while readers\n * (`websites.ts`, `db/header-images.ts`) take attachment [0], the OLDEST. Left\n * alone that means a field silently serves a stale image forever: beachfront\n * accumulated four headers and kept sending the first, which is how a\n * pre-clean-plate header reached a 2026-08-24 announcement and got a second\n * headline printed over its baked one.\n *\n * Pass `{ replaceIn: \"<table>\" }` for a field that should hold exactly one\n * current file (the site header). Omit it where history is wanted (per-period\n * report previews, which are one row per period anyway).\n *\n * The table is part of the option rather than a separate flag because the prune\n * PATCHes `/v0/{baseId}/{table}/{recordId}` — an earlier version omitted the\n * table segment entirely and 403'd on every call, so `replace` cannot be asked\n * for without supplying it.\n */\nexport async function uploadAttachment(\n recordId: string,\n fieldName: string,\n body: Uint8Array | string,\n filename: string,\n contentType: string,\n opts: { replaceIn?: string } = {},\n): Promise<void> {\n const apiKey = process.env.AIRTABLE_PAT;\n const baseId = process.env.AIRTABLE_BASE_ID;\n if (!apiKey || !baseId) {\n throw new Error(\"AIRTABLE_PAT and AIRTABLE_BASE_ID must be set\");\n }\n const base64 =\n typeof body === \"string\"\n ? Buffer.from(body, \"utf-8\").toString(\"base64\")\n : Buffer.from(body).toString(\"base64\");\n const payload = { contentType, file: base64, filename };\n const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/uploadAttachment`;\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok) {\n throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);\n }\n if (!opts.replaceIn) return;\n\n // The upload response carries the record with the field's full, post-append\n // attachment list. Keep only the last entry (the one just uploaded) by\n // PATCHing the field to that id — Airtable preserves an attachment referenced\n // by id alone and drops any omitted. Best-effort: the file IS uploaded by this\n // point, so a prune failure must not fail the caller; it only leaves the\n // field over-full, which the next successful run tidies.\n try {\n const body = (await res.json()) as {\n fields?: Record<string, Array<{ id?: string }> | undefined>;\n };\n // Airtable keys this response's `fields` by FIELD ID, not field name — verified\n // live 2026-08-24: {\"id\":\"rec…\",\"createdTime\":…,\"fields\":{\"fldBUAW180p8MIpvh\":[…]}}.\n // Looking it up by NAME therefore yielded undefined, `list` was [], and the\n // `length <= 1` guard returned silently — so `replace` never pruned anything on a\n // real call, while a name-keyed test stub kept it green. Eleven site header fields\n // stacked before the discrepancy surfaced. The endpoint writes exactly ONE field,\n // so its response carries exactly one entry: take it whatever it is keyed by, and\n // fall back to the name in case Airtable ever switches.\n const lists = Object.values(body.fields ?? {}).filter((v) => Array.isArray(v));\n const list = body.fields?.[fieldName] ?? (lists.length === 1 ? lists[0] : undefined);\n if (!list) {\n // Never return quietly here: silence is what let the no-op ship.\n console.warn(\n `⚠ attachment prune skipped for \"${fieldName}\": upload response carried no ` +\n `attachment list (fields keys: ${Object.keys(body.fields ?? {}).join(\", \") || \"none\"})`,\n );\n return;\n }\n if (list.length <= 1) return;\n const newest = list[list.length - 1]?.id;\n if (!newest) {\n console.warn(`⚠ kept ${list.length} attachments in \"${fieldName}\": newest entry has no id`);\n return;\n }\n // /v0/{baseId}/{table}/{recordId} — the table segment is REQUIRED. Without it\n // Airtable answers 403 Forbidden (not 404), which reads like a token-scope\n // problem and sent this in the wrong direction once already.\n const patchUrl = `https://api.airtable.com/v0/${baseId}/${encodeURIComponent(opts.replaceIn)}/${recordId}`;\n const patch = await fetch(patchUrl, {\n method: \"PATCH\",\n headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ fields: { [fieldName]: [{ id: newest }] } }),\n });\n if (!patch.ok) {\n console.warn(\n `⚠ kept ${list.length} attachments in \"${fieldName}\" — prune failed: ${patch.status} ${patch.statusText}`,\n );\n }\n } catch (e) {\n console.warn(`⚠ attachment prune skipped for \"${fieldName}\": ${(e as Error).message}`);\n }\n}\n"],"mappings":"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","names":["body"]}
|