@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
markDeadLetterReplayed,
|
|
3
|
+
readUnreplayedDeadLetters
|
|
4
|
+
} from "./chunk-TTT6HYPW.js";
|
|
5
|
+
import {
|
|
6
|
+
ingestSubmission
|
|
7
|
+
} from "./chunk-Z7FDJ35L.js";
|
|
8
|
+
import "./chunk-NO74DXDP.js";
|
|
9
|
+
import "./chunk-I7NHLVVI.js";
|
|
10
|
+
import "./chunk-2OP4JKIQ.js";
|
|
11
|
+
|
|
12
|
+
// src/forms/replay.ts
|
|
13
|
+
async function replayDeadLetters(db, deps) {
|
|
14
|
+
const queue = await readUnreplayedDeadLetters(db);
|
|
15
|
+
const result = {
|
|
16
|
+
replayed: [],
|
|
17
|
+
stillFailing: [],
|
|
18
|
+
unmarked: [],
|
|
19
|
+
unreadable: [...queue.unreadable]
|
|
20
|
+
};
|
|
21
|
+
for (const row of queue.rows) {
|
|
22
|
+
let outcome;
|
|
23
|
+
try {
|
|
24
|
+
outcome = await replayOne(db, deps, row);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
result.stillFailing.push({ id: row.id, error: `replay threw: ${String(err)}` });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (outcome.terminal) {
|
|
30
|
+
result.replayed.push({
|
|
31
|
+
id: row.id,
|
|
32
|
+
outcome: outcome.outcome,
|
|
33
|
+
submissionId: outcome.submissionId
|
|
34
|
+
});
|
|
35
|
+
} else if (outcome.unmarked) {
|
|
36
|
+
result.unmarked.push({
|
|
37
|
+
id: row.id,
|
|
38
|
+
outcome: outcome.outcome,
|
|
39
|
+
submissionId: outcome.submissionId,
|
|
40
|
+
error: outcome.error
|
|
41
|
+
});
|
|
42
|
+
} else {
|
|
43
|
+
result.stillFailing.push({ id: row.id, error: outcome.error });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
async function markTerminal(db, row, outcome, submissionId, now) {
|
|
49
|
+
try {
|
|
50
|
+
await markDeadLetterReplayed(db, row.id, outcome, submissionId, now);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { terminal: false, unmarked: true, outcome, submissionId, error: String(err) };
|
|
53
|
+
}
|
|
54
|
+
return { terminal: true, outcome, submissionId };
|
|
55
|
+
}
|
|
56
|
+
async function replayOne(db, deps, row) {
|
|
57
|
+
const { deadLetter: _stripped, ...safeDeps } = deps;
|
|
58
|
+
void _stripped;
|
|
59
|
+
let res;
|
|
60
|
+
try {
|
|
61
|
+
res = await ingestSubmission(safeDeps, row.siteSlug, row.payload, row.turnstile);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return { terminal: false, error: String(err) };
|
|
64
|
+
}
|
|
65
|
+
const now = deps.now();
|
|
66
|
+
if (res.status === "accepted") {
|
|
67
|
+
return markTerminal(db, row, "accepted", res.submissionId, now);
|
|
68
|
+
}
|
|
69
|
+
if (res.status === "unknown-site") {
|
|
70
|
+
return { terminal: false, error: `unknown-site: no fleet row for '${row.siteSlug}'` };
|
|
71
|
+
}
|
|
72
|
+
return markTerminal(db, row, `rejected:${res.reason}`, null, now);
|
|
73
|
+
}
|
|
74
|
+
export {
|
|
75
|
+
replayDeadLetters
|
|
76
|
+
};
|
|
77
|
+
//# sourceMappingURL=replay-HB44TAII.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/forms/replay.ts"],"sourcesContent":["import type { Db } from \"../db/client.js\";\nimport {\n readUnreplayedDeadLetters,\n markDeadLetterReplayed,\n type DeadLetterRow,\n type UnreadableDeadLetterRow,\n} from \"../db/deadletter.js\";\nimport { ingestSubmission, type IngestDeps } from \"./ingest.js\";\n\nexport type ReplayResult = {\n /** Terminal outcomes this run: the row will never be picked up again. */\n replayed: Array<{ id: string; outcome: string; submissionId: string | null }>;\n /** Rows whose lookup threw AGAIN — left unreplayed for the next run. */\n stillFailing: Array<{ id: string; error: string }>;\n /** MED-10(b). Rows that DID re-ingest — a real submission exists for them —\n * but whose terminal mark could not be written. Distinct from `stillFailing`\n * on purpose: that bucket means \"nothing was written, retry freely\", which is\n * the one thing nobody may believe about these. Re-running replay before the\n * row is reconciled WILL mint a duplicate submission, so each entry names the\n * submission that already exists. */\n unmarked: Array<{ id: string; outcome: string; submissionId: string | null; error: string }>;\n /** MED-10(c). Rows whose stored JSON could not be decoded — they cannot be\n * replayed at all until a human looks. Reported so they are visible; still\n * queued, so nothing is destroyed by reporting them. */\n unreadable: UnreadableDeadLetterRow[];\n};\n\n/**\n * Run every unreplayed dead-letter back through `ingestSubmission` — the SAME\n * pipeline a live submission takes, so a replayed lead gets real spam\n * classification, notify, and fan-out, not a bespoke re-implementation.\n *\n * Each row replays with the Turnstile verification computed at receipt (tokens\n * expire in 300s; re-verifying is impossible by design, see deadletter.ts).\n *\n * `deps` must NOT wire `deadLetter`: a lookup that throws during replay would\n * otherwise dead-letter the SAME lead a second time while the original row stays\n * unreplayed — every retry would mint a duplicate. Enforced here by stripping the\n * key rather than trusting the caller. A thrown lookup leaves the row untouched\n * for the next run; `accepted` and `rejected` are terminal — re-running cannot\n * improve on an answer the store actually gave. `unknown-site` USED to be\n * terminal too and no longer is: see replayOne below (#645).\n *\n * Oldest-first (listUnreplayedDeadLetters orders by received_at) so the\n * duplicate/velocity spam signals see submissions in arrival order.\n */\nexport async function replayDeadLetters(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n): Promise<ReplayResult> {\n const queue = await readUnreplayedDeadLetters(db);\n const result: ReplayResult = {\n replayed: [],\n stillFailing: [],\n unmarked: [],\n unreadable: [...queue.unreadable],\n };\n\n for (const row of queue.rows) {\n // MED-10(b). One row's failure must never abort the ones behind it. Every\n // outcome replayOne can reach is already a return value; this catch is the\n // backstop for anything it cannot anticipate (a `deps.now()` that throws, a\n // dep wired wrong), so a surprise costs one lead's run, not the queue's.\n let outcome: Awaited<ReturnType<typeof replayOne>>;\n try {\n outcome = await replayOne(db, deps, row);\n } catch (err) {\n result.stillFailing.push({ id: row.id, error: `replay threw: ${String(err)}` });\n continue;\n }\n if (outcome.terminal) {\n result.replayed.push({\n id: row.id,\n outcome: outcome.outcome,\n submissionId: outcome.submissionId,\n });\n } else if (outcome.unmarked) {\n result.unmarked.push({\n id: row.id,\n outcome: outcome.outcome,\n submissionId: outcome.submissionId,\n error: outcome.error,\n });\n } else {\n result.stillFailing.push({ id: row.id, error: outcome.error });\n }\n }\n return result;\n}\n\n/**\n * MED-10(b). Write the terminal mark, and never let its failure escape.\n *\n * ORDERING. Ingest-then-mark is kept, because the alternative (mark first) turns\n * every ingest failure into a lead marked replayed that no submission exists\n * for — silent loss, which is strictly worse than a duplicate the operator can\n * see. What changes is what a failed mark MEANS: it is no longer an exception\n * that unwinds `replayDeadLetters` with the row looking like an ordinary retry.\n * It is its own outcome, naming the submission that already exists, and it\n * cannot touch the rows behind it.\n *\n * RESIDUAL, stated honestly: the mark is a durable write and its failure means\n * the store refused a write. Nothing in-process can persist the fact, so the\n * row IS still queued and the next unattended run will re-ingest it and mint a\n * duplicate. That is why this is not retried quietly and not folded into\n * `stillFailing`: the run's output is the only record that exists, so it has to\n * be loud enough to reach a person before the next run does.\n */\nasync function markTerminal(\n db: Db,\n row: DeadLetterRow,\n outcome: string,\n submissionId: string | null,\n now: Date,\n): Promise<ReplayOutcome> {\n try {\n await markDeadLetterReplayed(db, row.id, outcome, submissionId, now);\n } catch (err) {\n return { terminal: false, unmarked: true, outcome, submissionId, error: String(err) };\n }\n return { terminal: true, outcome, submissionId };\n}\n\ntype ReplayOutcome =\n | { terminal: true; outcome: string; submissionId: string | null }\n | { terminal: false; unmarked?: false; error: string }\n | {\n terminal: false;\n unmarked: true;\n outcome: string;\n submissionId: string | null;\n error: string;\n };\n\nasync function replayOne(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n row: DeadLetterRow,\n): Promise<ReplayOutcome> {\n // Strip deadLetter defensively even though the type forbids it — a structurally\n // wider object passed through `as` must not re-arm the duplicate loop.\n const { deadLetter: _stripped, ...safeDeps } = deps as IngestDeps;\n void _stripped;\n let res;\n try {\n res = await ingestSubmission(safeDeps, row.siteSlug, row.payload, row.turnstile);\n } catch (err) {\n return { terminal: false, error: String(err) };\n }\n const now = deps.now();\n if (res.status === \"accepted\") {\n return markTerminal(db, row, \"accepted\", res.submissionId, now);\n }\n // #645. `unknown-site` is NO LONGER terminal. It used to be, on the reasoning\n // that a slug the store rejects \"can never improve\" — true while nothing could\n // create the missing row. `ensure-site` can now heal one (#645 item 1), and\n // `ingestSubmission` now dead-letters `unknown-site` leads instead of dropping\n // them, so this queue holds real client leads whose site is merely absent.\n //\n // Terminality would then be an ORDERING TRAP: replay-before-heal marks every\n // such lead replayed-and-lost, and that is the order a person reaches for\n // first. Leaving the row queued makes both orders safe.\n //\n // The cost is a slug that is genuinely gone piling up rows and holding this\n // command at exit 1. Deliberate: loud and recoverable beats silent and not.\n if (res.status === \"unknown-site\") {\n return { terminal: false, error: `unknown-site: no fleet row for '${row.siteSlug}'` };\n }\n return markTerminal(db, row, `rejected:${res.reason}`, null, now);\n}\n"],"mappings":";;;;;;;;;;;;AA8CA,eAAsB,kBACpB,IACA,MACuB;AACvB,QAAM,QAAQ,MAAM,0BAA0B,EAAE;AAChD,QAAM,SAAuB;AAAA,IAC3B,UAAU,CAAC;AAAA,IACX,cAAc,CAAC;AAAA,IACf,UAAU,CAAC;AAAA,IACX,YAAY,CAAC,GAAG,MAAM,UAAU;AAAA,EAClC;AAEA,aAAW,OAAO,MAAM,MAAM;AAK5B,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,UAAU,IAAI,MAAM,GAAG;AAAA,IACzC,SAAS,KAAK;AACZ,aAAO,aAAa,KAAK,EAAE,IAAI,IAAI,IAAI,OAAO,iBAAiB,OAAO,GAAG,CAAC,GAAG,CAAC;AAC9E;AAAA,IACF;AACA,QAAI,QAAQ,UAAU;AACpB,aAAO,SAAS,KAAK;AAAA,QACnB,IAAI,IAAI;AAAA,QACR,SAAS,QAAQ;AAAA,QACjB,cAAc,QAAQ;AAAA,MACxB,CAAC;AAAA,IACH,WAAW,QAAQ,UAAU;AAC3B,aAAO,SAAS,KAAK;AAAA,QACnB,IAAI,IAAI;AAAA,QACR,SAAS,QAAQ;AAAA,QACjB,cAAc,QAAQ;AAAA,QACtB,OAAO,QAAQ;AAAA,MACjB,CAAC;AAAA,IACH,OAAO;AACL,aAAO,aAAa,KAAK,EAAE,IAAI,IAAI,IAAI,OAAO,QAAQ,MAAM,CAAC;AAAA,IAC/D;AAAA,EACF;AACA,SAAO;AACT;AAoBA,eAAe,aACb,IACA,KACA,SACA,cACA,KACwB;AACxB,MAAI;AACF,UAAM,uBAAuB,IAAI,IAAI,IAAI,SAAS,cAAc,GAAG;AAAA,EACrE,SAAS,KAAK;AACZ,WAAO,EAAE,UAAU,OAAO,UAAU,MAAM,SAAS,cAAc,OAAO,OAAO,GAAG,EAAE;AAAA,EACtF;AACA,SAAO,EAAE,UAAU,MAAM,SAAS,aAAa;AACjD;AAaA,eAAe,UACb,IACA,MACA,KACwB;AAGxB,QAAM,EAAE,YAAY,WAAW,GAAG,SAAS,IAAI;AAC/C,OAAK;AACL,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,iBAAiB,UAAU,IAAI,UAAU,IAAI,SAAS,IAAI,SAAS;AAAA,EACjF,SAAS,KAAK;AACZ,WAAO,EAAE,UAAU,OAAO,OAAO,OAAO,GAAG,EAAE;AAAA,EAC/C;AACA,QAAM,MAAM,KAAK,IAAI;AACrB,MAAI,IAAI,WAAW,YAAY;AAC7B,WAAO,aAAa,IAAI,KAAK,YAAY,IAAI,cAAc,GAAG;AAAA,EAChE;AAaA,MAAI,IAAI,WAAW,gBAAgB;AACjC,WAAO,EAAE,UAAU,OAAO,OAAO,mCAAmC,IAAI,QAAQ,IAAI;AAAA,EACtF;AACA,SAAO,aAAa,IAAI,KAAK,YAAY,IAAI,MAAM,IAAI,MAAM,GAAG;AAClE;","names":[]}
|
|
@@ -4,20 +4,19 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
draftReportForSite,
|
|
6
6
|
readGaConfig
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-DRNZVORX.js";
|
|
8
8
|
import {
|
|
9
9
|
reportTier
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-UPE6D4AT.js";
|
|
11
11
|
import {
|
|
12
12
|
findDueReports,
|
|
13
13
|
nextDueDate,
|
|
14
14
|
reportPeriodKey
|
|
15
15
|
} from "./chunk-PZR4EBPY.js";
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
} from "./chunk-F3JWOPTG.js";
|
|
16
|
+
import "./chunk-TEMQPSCM.js";
|
|
17
|
+
import "./chunk-ASLDCWBF.js";
|
|
19
18
|
import "./chunk-VAD43MOR.js";
|
|
20
|
-
import "./chunk-
|
|
19
|
+
import "./chunk-YJBSB4LP.js";
|
|
21
20
|
import {
|
|
22
21
|
openBase,
|
|
23
22
|
readAirtableConfig
|
|
@@ -26,12 +25,14 @@ import "./chunk-KJGKZLCT.js";
|
|
|
26
25
|
import {
|
|
27
26
|
escapeHtml
|
|
28
27
|
} from "./chunk-BGTYPVLT.js";
|
|
29
|
-
import "./chunk-
|
|
28
|
+
import "./chunk-LZMAG73U.js";
|
|
30
29
|
import {
|
|
31
|
-
listWebsites,
|
|
32
|
-
siteSlug,
|
|
33
30
|
updateNextDueDates
|
|
34
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-TC4GANMX.js";
|
|
32
|
+
import {
|
|
33
|
+
siteSlug
|
|
34
|
+
} from "./chunk-ICSRWW5Q.js";
|
|
35
|
+
import "./chunk-RQJFGK3A.js";
|
|
35
36
|
import "./chunk-RARREDQE.js";
|
|
36
37
|
import "./chunk-6VAL7XJT.js";
|
|
37
38
|
|
|
@@ -76,41 +77,76 @@ function parseSingleSiteReportType(raw) {
|
|
|
76
77
|
function dashboardBaseUrl() {
|
|
77
78
|
return process.env.DASHBOARD_BASE_URL?.trim() || "https://reddoor-maintenance.netlify.app";
|
|
78
79
|
}
|
|
80
|
+
function lazyFleetDb() {
|
|
81
|
+
let db = null;
|
|
82
|
+
return {
|
|
83
|
+
get: async () => {
|
|
84
|
+
if (!db) {
|
|
85
|
+
const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
|
|
86
|
+
db = await openDb(readDbConfig());
|
|
87
|
+
}
|
|
88
|
+
return db;
|
|
89
|
+
},
|
|
90
|
+
close: async () => {
|
|
91
|
+
if (db) await db.destroy();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
79
95
|
async function runReportCommand(slug, opts) {
|
|
80
96
|
if (opts.digest) {
|
|
81
|
-
const { runDigest } = await import("./digest-
|
|
82
|
-
|
|
97
|
+
const { runDigest } = await import("./digest-AMXRGWZA.js");
|
|
98
|
+
const { listSites, listAllReports } = await import("./fleet-state-RJROYXKO.js");
|
|
99
|
+
const fleetDb = lazyFleetDb();
|
|
100
|
+
try {
|
|
101
|
+
return await runDigest({
|
|
102
|
+
baseUrl: dashboardBaseUrl(),
|
|
103
|
+
roster: async () => listSites(await fleetDb.get()),
|
|
104
|
+
allReports: async () => listAllReports(await fleetDb.get())
|
|
105
|
+
});
|
|
106
|
+
} finally {
|
|
107
|
+
await fleetDb.close();
|
|
108
|
+
}
|
|
83
109
|
}
|
|
84
110
|
if (opts.sendReady) {
|
|
85
|
-
const { sendApprovedReports } = await import("./orchestrate-
|
|
86
|
-
const { makeSiteMirror } = await import("./site-mirror-
|
|
111
|
+
const { sendApprovedReports } = await import("./orchestrate-SAIFXZIW.js");
|
|
112
|
+
const { makeSiteMirror } = await import("./site-mirror-TKTDWYP3.js");
|
|
87
113
|
const { mirrorWrite } = await import("./freeze-GUQ43EAU.js");
|
|
88
|
-
const { openDb, readDbConfig } = await import("./client-
|
|
89
|
-
const { mirrorReportPatch } = await import("./fleet-state-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
|
|
115
|
+
const { mirrorReportPatch, listSendableReports, listSites } = await import("./fleet-state-RJROYXKO.js");
|
|
116
|
+
const { loadHeaderImage } = await import("./header-images-SQBCE5OD.js");
|
|
117
|
+
const held = { db: null };
|
|
118
|
+
const getFleetDb = async () => held.db ??= await openDb(readDbConfig());
|
|
119
|
+
try {
|
|
120
|
+
return await sendApprovedReports({
|
|
121
|
+
sendable: async () => listSendableReports(await getFleetDb()),
|
|
122
|
+
roster: async () => listSites(await getFleetDb()),
|
|
123
|
+
loadHeaderPlate: async (siteId) => (await loadHeaderImage(await getFleetDb(), siteId))?.bytes ?? null,
|
|
124
|
+
siteMirror: await makeSiteMirror(),
|
|
125
|
+
// stampSent's Turso shadow, routed through mirrorWrite so the freeze
|
|
126
|
+
// switch owns the semantics: strict rethrows and the send loop reds the
|
|
127
|
+
// run. Mirrors stampSent exactly — the 409 replay path leaves Airtable's
|
|
128
|
+
// `Resend message ID` untouched, so the shadow omits it there too. The
|
|
129
|
+
// row count is handed through (#647): a stamp for a report row Turso
|
|
130
|
+
// never held is `missed`, not a green no-op.
|
|
131
|
+
reportSentMirror: (reportId, sentAt, messageId) => mirrorWrite(`stamp-sent ${reportId}`, async () => {
|
|
132
|
+
const db = await openDb(readDbConfig());
|
|
133
|
+
return mirrorReportPatch(db, reportId, {
|
|
134
|
+
sent_at: sentAt.toISOString(),
|
|
135
|
+
...messageId !== null ? { resend_message_id: messageId } : {}
|
|
136
|
+
});
|
|
137
|
+
})
|
|
138
|
+
});
|
|
139
|
+
} finally {
|
|
140
|
+
if (held.db) await held.db.destroy();
|
|
141
|
+
}
|
|
106
142
|
}
|
|
107
143
|
if (opts.rerender) {
|
|
108
144
|
const { rerenderReport, formatRerenderResult } = await import("./rerender-3FM6DNRH.js");
|
|
109
|
-
const { renderReportFromRow } = await import("./render-from-row-
|
|
110
|
-
const { openDb, readDbConfig } = await import("./client-
|
|
111
|
-
const { getReportById, getSiteById, storeRenderedHtml } = await import("./fleet-state-
|
|
145
|
+
const { renderReportFromRow } = await import("./render-from-row-75JTED2Z.js");
|
|
146
|
+
const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
|
|
147
|
+
const { getReportById, getSiteById, storeRenderedHtml } = await import("./fleet-state-RJROYXKO.js");
|
|
112
148
|
const { loadHeaderImage } = await import("./header-images-SQBCE5OD.js");
|
|
113
|
-
const { fetchAttachmentBytes } = await import("./attachments-
|
|
149
|
+
const { fetchAttachmentBytes } = await import("./attachments-JFGFQRXQ.js");
|
|
114
150
|
const db = await openDb(readDbConfig());
|
|
115
151
|
const result = await rerenderReport(
|
|
116
152
|
{
|
|
@@ -147,18 +183,24 @@ async function runReportCommand(slug, opts) {
|
|
|
147
183
|
}
|
|
148
184
|
async function runDueDraft() {
|
|
149
185
|
const base = openBase(readAirtableConfig());
|
|
150
|
-
const { makeScheduleMirrorBestEffort } = await import("./health-mirror-
|
|
151
|
-
const { makeReportMirror } = await import("./report-mirror-
|
|
152
|
-
const { makeSiteMirror } = await import("./site-mirror-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
186
|
+
const { makeScheduleMirrorBestEffort } = await import("./health-mirror-WZUHWCCV.js");
|
|
187
|
+
const { makeReportMirror } = await import("./report-mirror-O6IDFDWU.js");
|
|
188
|
+
const { makeSiteMirror } = await import("./site-mirror-TKTDWYP3.js");
|
|
189
|
+
const { listSites, listAllReports } = await import("./fleet-state-RJROYXKO.js");
|
|
190
|
+
const fleetDb = lazyFleetDb();
|
|
191
|
+
try {
|
|
192
|
+
const result = await draftDueReports(base, /* @__PURE__ */ new Date(), {
|
|
193
|
+
roster: async () => listSites(await fleetDb.get()),
|
|
194
|
+
allReports: async () => listAllReports(await fleetDb.get()),
|
|
195
|
+
scheduleMirror: await makeScheduleMirrorBestEffort(),
|
|
196
|
+
reportMirror: await makeReportMirror(),
|
|
197
|
+
siteMirror: await makeSiteMirror()
|
|
198
|
+
});
|
|
199
|
+
await alertOnFleetAnalyticsFailure(result.health);
|
|
200
|
+
return { output: result.output, code: result.code };
|
|
201
|
+
} finally {
|
|
202
|
+
await fleetDb.close();
|
|
203
|
+
}
|
|
162
204
|
}
|
|
163
205
|
async function alertOnFleetAnalyticsFailure(health) {
|
|
164
206
|
if (!assessAnalyticsAlert(health).fire) return;
|
|
@@ -213,13 +255,14 @@ async function writeNextDueDates(base, websites, reports, today, scheduleMirror
|
|
|
213
255
|
const mirrorNote = scheduleMirror ? ` mirrored=${mirrored} mirror_failed=${mirrorFailed} mirror_missed=${mirrorMissed}` : " mirror=absent";
|
|
214
256
|
console.log(`NEXT_DUE_WRITE wrote=${wrote} skipped=${skipped} failed=${failed}${mirrorNote}`);
|
|
215
257
|
}
|
|
216
|
-
async function draftDueReports(base, today,
|
|
258
|
+
async function draftDueReports(base, today, deps) {
|
|
259
|
+
const scheduleMirror = deps.scheduleMirror ?? null;
|
|
217
260
|
const mirrorOpt = {
|
|
218
|
-
|
|
219
|
-
...siteMirror ? { siteMirror } : {}
|
|
261
|
+
reportMirror: deps.reportMirror,
|
|
262
|
+
...deps.siteMirror ? { siteMirror: deps.siteMirror } : {}
|
|
220
263
|
};
|
|
221
|
-
const websites = await
|
|
222
|
-
const reports = await
|
|
264
|
+
const websites = await deps.roster();
|
|
265
|
+
const reports = await deps.allReports();
|
|
223
266
|
await writeNextDueDates(base, websites, reports, today, scheduleMirror);
|
|
224
267
|
const due = findDueReports(websites, reports, today);
|
|
225
268
|
const gaConfigured = readGaConfig() !== null;
|
|
@@ -334,13 +377,14 @@ async function draftDueReports(base, today, scheduleMirror = null, reportMirror,
|
|
|
334
377
|
}
|
|
335
378
|
async function runSingleSiteDraft(slug, opts) {
|
|
336
379
|
const base = openBase(readAirtableConfig());
|
|
337
|
-
const
|
|
380
|
+
const { readFleetRoster } = await import("./roster-EJ3ILR52.js");
|
|
381
|
+
const websites = await readFleetRoster();
|
|
338
382
|
const site = websites.find((w) => siteSlug(w.name) === slug);
|
|
339
383
|
if (!site) {
|
|
340
|
-
throw Object.assign(new Error(`No
|
|
384
|
+
throw Object.assign(new Error(`No site matched slug "${slug}"`), { exitCode: 2 });
|
|
341
385
|
}
|
|
342
|
-
const reportMirror = opts.previewOnly ? null : await (await import("./report-mirror-
|
|
343
|
-
const siteMirror = opts.previewOnly ? null : await (await import("./site-mirror-
|
|
386
|
+
const reportMirror = opts.previewOnly ? null : await (await import("./report-mirror-O6IDFDWU.js")).makeReportMirror();
|
|
387
|
+
const siteMirror = opts.previewOnly ? null : await (await import("./site-mirror-TKTDWYP3.js")).makeSiteMirror();
|
|
344
388
|
const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {
|
|
345
389
|
previewOnly: opts.previewOnly,
|
|
346
390
|
// Only forced on. Left undefined, draftReportForSite keeps its default
|
|
@@ -368,4 +412,4 @@ export {
|
|
|
368
412
|
runReportCommand,
|
|
369
413
|
writeNextDueDates
|
|
370
414
|
};
|
|
371
|
-
//# sourceMappingURL=report-
|
|
415
|
+
//# sourceMappingURL=report-MVPWS7WP.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/analytics-health.ts","../src/cli/commands/report.ts"],"sourcesContent":["import { escapeHtml } from \"../util/html.js\";\n\n/**\n * The GA/Search enrichment outcome of one `report --due` draft run, aggregated\n * across the sites it drafted. `softFailedSites` is how many drafted sites had GA\n * OR Search enrichment ERROR (not \"not configured\" — that's a legitimate skip);\n * `configuredSites` is how many drafted sites had analytics configured at all\n * (the subject is set AND the site has a GA4 property or a search query). The ratio\n * is what distinguishes a fleet-wide subject outage from a single transient blip.\n */\nexport type AnalyticsRunHealth = {\n softFailedSites: number;\n configuredSites: number;\n};\n\nexport type AnalyticsAlert = { fire: boolean; reason: string };\n\n/** A lone failure never alerts — that's a per-site config issue, not the shared-\n * subject SPOF. Two-plus AND a majority of configured sites is the fleet signature. */\nconst MIN_FAILED_SITES = 2;\n\n/**\n * Decide whether a draft run's GA/Search soft-failures look FLEET-WIDE — i.e. the\n * one impersonated subject (`GA_SUBJECT`) lost access — rather than one site's\n * transient blip. PURE. Fires when at least {@link MIN_FAILED_SITES} configured\n * sites failed AND they are a majority (≥ half) of the analytics-configured sites\n * this run. With <2 configured sites it can't distinguish a SPOF from a one-off, so\n * it never fires (at fleet scale there are always many configured sites).\n */\nexport function assessAnalyticsAlert(h: AnalyticsRunHealth): AnalyticsAlert {\n const { softFailedSites, configuredSites } = h;\n const fire =\n configuredSites >= 2 &&\n softFailedSites >= MIN_FAILED_SITES &&\n softFailedSites * 2 >= configuredSites;\n const reason = fire\n ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run — the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.`\n : \"\";\n return { fire, reason };\n}\n\n/**\n * Compose the operator alert email for a fleet-wide analytics failure. PURE — the\n * caller (the `report --due` cron) sends it best-effort via Resend only when\n * {@link assessAnalyticsAlert} fires. `dashboardUrl` is the fleet homepage link.\n */\nexport function composeAnalyticsAlertEmail(\n h: AnalyticsRunHealth,\n dashboardUrl: string,\n): { subject: string; html: string } {\n const subject = `⚠ Fleet analytics enrichment failing — ${h.softFailedSites}/${h.configuredSites} sites`;\n const { reason } = assessAnalyticsAlert(h);\n const html = `<p><strong>${escapeHtml(reason)}</strong></p>\n<p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send — but with blank analytics — until the subject is restored.</p>\n<p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>\n<p><a href=\"${escapeHtml(dashboardUrl)}\">Open the fleet dashboard →</a></p>`;\n return { subject, html };\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport type { Db } from \"../../db/client.js\";\nimport { siteSlug, updateNextDueDates, type WebsiteRow } from \"../../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../../reports/airtable/reports.js\";\nimport { findDueReports, nextDueDate, reportPeriodKey } from \"../../reports/due.js\";\nimport { draftReportForSite } from \"../../reports/draft.js\";\nimport { reportTier } from \"../../reports/queue.js\";\nimport { readGaConfig } from \"../../reports/ga/config.js\";\nimport {\n assessAnalyticsAlert,\n composeAnalyticsAlertEmail,\n type AnalyticsRunHealth,\n} from \"../../alerts/analytics-health.js\";\nimport type { ReportType } from \"../../reports/types.js\";\nimport type { ScheduleMirror } from \"../../audits/health-mirror.js\";\nimport type { ReportMirror } from \"../../reports/report-mirror.js\";\nimport type { SiteMirror } from \"../../db/site-mirror.js\";\nimport { operatorEmail } from \"../../util/operator.js\";\n\nexport type ReportCommandOptions = {\n due?: boolean;\n preview?: boolean;\n enrich?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n rerender?: string;\n type?: string;\n cwd?: string;\n};\n\n/**\n * Summary line for a drafted report, reflecting the single-queue outcome. `queued === false`\n * means a higher-or-equal-tier report was already pending for the site, so this draft was\n * created but deliberately left OUT of the approve queue. A non-empty `supersededIds` means it\n * un-queued that many lower-tier drafts. `null` is the previewOnly path (no Airtable queue).\n */\nfunction draftLine(\n reportId: string | undefined,\n queued: boolean | null,\n supersededIds: string[],\n verb = \"drafted\",\n): string {\n const id = reportId ?? \"(unknown)\";\n if (queued === false) {\n return `• ${verb} but NOT queued: ${id} — a higher-or-equal-tier report is already pending approval`;\n }\n const sup =\n supersededIds.length > 0\n ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? \"s\" : \"\"})`\n : \"\";\n return `✓ ${verb}: ${id}${sup}`;\n}\n\n/**\n * Parse the single-site `--type` flag. Only Maintenance and Testing are draftable\n * this way — Launch has the `launch <site>` command and Announcement has\n * `announce <site>`, each with its own purpose-built flow. Case-insensitive;\n * defaults to Maintenance (the historical single-site behaviour). Throws an\n * exitCode-2 usage error on anything else. PURE.\n */\nexport function parseSingleSiteReportType(raw: string | undefined): ReportType {\n if (raw === undefined || raw.trim() === \"\") return \"Maintenance\";\n const norm = raw.trim().toLowerCase();\n if (norm === \"maintenance\") return \"Maintenance\";\n if (norm === \"testing\") return \"Testing\";\n const hint =\n norm === \"launch\"\n ? \" — use the `launch <site>` command\"\n : norm === \"announcement\"\n ? \" — use the `announce <site>` command\"\n : \"\";\n throw Object.assign(\n new Error(`--type must be Maintenance or Testing (got ${JSON.stringify(raw)})${hint}`),\n { exitCode: 2 },\n );\n}\n\n/** Dashboard origin for digest /s/<slug> links. DASHBOARD_BASE_URL overrides the\n * production default; the trailing slash (if any) is trimmed by runDigest. */\nfunction dashboardBaseUrl(): string {\n return process.env.DASHBOARD_BASE_URL?.trim() || \"https://reddoor-maintenance.netlify.app\";\n}\n\n/** One lazily-opened, once-closed libSQL connection for a command's reads (#646\n * step 4). Lazy because the command that owns it may return before any read\n * happens, and opening a database to do nothing is how a test — or a `--help`\n * path — ends up needing `TURSO_DATABASE_URL`. */\nfunction lazyFleetDb(): { get: () => Promise<Db>; close: () => Promise<void> } {\n let db: Db | null = null;\n return {\n get: async () => {\n if (!db) {\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n db = await openDb(readDbConfig());\n }\n return db;\n },\n close: async () => {\n if (db) await db.destroy();\n },\n };\n}\n\nexport async function runReportCommand(\n slug: string | undefined,\n opts: ReportCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (opts.digest) {\n const { runDigest } = await import(\"../../reports/digest.js\");\n // #646 step 4: the digest's two datasets come from Turso. ONE connection for\n // both reads, opened here (the command is the composition root) and closed\n // when the run is done — Airtable is left holding only the digest-state\n // shadow write. Opened LAZILY, on the first read: a run that never reads\n // (a routing test, a caller that fails earlier) must not need a database.\n const { listSites, listAllReports } = await import(\"../../db/fleet-state.js\");\n const fleetDb = lazyFleetDb();\n try {\n return await runDigest({\n baseUrl: dashboardBaseUrl(),\n roster: async () => listSites(await fleetDb.get()),\n allReports: async () => listAllReports(await fleetDb.get()),\n });\n } finally {\n await fleetDb.close();\n }\n }\n\n if (opts.sendReady) {\n const { sendApprovedReports } = await import(\"../../reports/send/orchestrate.js\");\n // #539 Phase 5: a Launch send flips Status + stamps `Launched at` on the\n // Websites row; #643 retired the hourly sync, so these mirrors are the ONLY\n // way either write reaches Turso.\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n const { mirrorWrite } = await import(\"../../db/freeze.js\");\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n const { mirrorReportPatch, listSendableReports, listSites } =\n await import(\"../../db/fleet-state.js\");\n const { loadHeaderImage } = await import(\"../../db/header-images.js\");\n // #646 step 4: the queue, the roster and the header plate all come from Turso,\n // over ONE connection opened on the first of those reads and closed when the\n // run ends. The Airtable calls left on this path are writes (`stampSent`,\n // `updateLaunched`) plus the one-site header fallback inside the orchestrator.\n type FleetDb = Awaited<ReturnType<typeof openDb>>;\n // Held in an object rather than a `let`: the only assignment happens inside the\n // closure below, and TypeScript's flow analysis then narrows the variable to\n // `null` for the `finally` that has to close it.\n const held: { db: FleetDb | null } = { db: null };\n const getFleetDb = async (): Promise<FleetDb> => (held.db ??= await openDb(readDbConfig()));\n try {\n return await sendApprovedReports({\n sendable: async () => listSendableReports(await getFleetDb()),\n roster: async () => listSites(await getFleetDb()),\n loadHeaderPlate: async (siteId) =>\n (await loadHeaderImage(await getFleetDb(), siteId))?.bytes ?? null,\n siteMirror: await makeSiteMirror(),\n // stampSent's Turso shadow, routed through mirrorWrite so the freeze\n // switch owns the semantics: strict rethrows and the send loop reds the\n // run. Mirrors stampSent exactly — the 409 replay path leaves Airtable's\n // `Resend message ID` untouched, so the shadow omits it there too. The\n // row count is handed through (#647): a stamp for a report row Turso\n // never held is `missed`, not a green no-op.\n reportSentMirror: (reportId, sentAt, messageId) =>\n mirrorWrite(`stamp-sent ${reportId}`, async () => {\n const db = await openDb(readDbConfig());\n return mirrorReportPatch(db, reportId, {\n sent_at: sentAt.toISOString(),\n ...(messageId !== null ? { resend_message_id: messageId } : {}),\n });\n }),\n });\n } finally {\n if (held.db) await held.db.destroy();\n }\n }\n\n // Refresh ONE unsent report's stored body so the console preview reflects\n // commentary edited after drafting. Runs here rather than in a Netlify\n // function because rendering needs sharp — see rerender.ts.\n if (opts.rerender) {\n const { rerenderReport, formatRerenderResult } = await import(\"../../reports/send/rerender.js\");\n const { renderReportFromRow } = await import(\"../../reports/send/render-from-row.js\");\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n const { getReportById, getSiteById, storeRenderedHtml } =\n await import(\"../../db/fleet-state.js\");\n const { loadHeaderImage } = await import(\"../../db/header-images.js\");\n const { fetchAttachmentBytes } = await import(\"../../reports/airtable/attachments.js\");\n const db = await openDb(readDbConfig());\n const result = await rerenderReport(\n {\n getReport: (id) => getReportById(db, id),\n getSite: (id) => getSiteById(db, id),\n loadHeaderPlate: async (id) => (await loadHeaderImage(db, id))?.bytes ?? null,\n fetchAirtableHeader: async (url) => (await fetchAttachmentBytes(url)).bytes,\n render: (site, report, plate) => renderReportFromRow(site, report, plate),\n store: (id, html) => storeRenderedHtml(db, id, html),\n },\n opts.rerender,\n );\n // Exit 1 on anything but a render, so a dispatched run that refused is RED\n // rather than a green run that silently changed nothing.\n return { output: formatRerenderResult(result), code: result.status === \"rendered\" ? 0 : 1 };\n }\n\n if (opts.due) {\n return runDueDraft();\n }\n\n if (slug) {\n // Validate the type BEFORE any Airtable access so a bad --type fails fast (and\n // without needing credentials).\n const reportType = parseSingleSiteReportType(opts.type);\n return runSingleSiteDraft(slug, {\n previewOnly: Boolean(opts.preview),\n enrich: Boolean(opts.enrich),\n reportType,\n });\n }\n\n throw Object.assign(\n new Error(\n \"Usage: reddoor-maint report [<slug>] [--type <Maintenance|Testing>] [--due] [--preview] [--enrich] [--send-ready] [--digest]\",\n ),\n {\n exitCode: 2,\n },\n );\n}\n\nasync function runDueDraft(): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n // Phase 3 dual-write (#539): mirror real next-due writes into site_schedule.\n // Null when libSQL creds are absent — the Airtable path is unchanged.\n const { makeScheduleMirrorBestEffort } = await import(\"../../audits/health-mirror.js\");\n // Phase 5 dual-write (#539): mirror this batch's report writes — the created\n // rows, their bodies, and the queue flags. Unlike the schedule mirror this is\n // never null — creds-absent is reported on the REPORT_MIRROR line rather than\n // being indistinguishable from success, the failure mode that hid #585.\n const { makeReportMirror } = await import(\"../../reports/report-mirror.js\");\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n // #646 step 4: the roster and every report come from Turso, over ONE connection\n // opened on the first read and closed when the run ends. The Airtable calls left\n // on this path are shadow writes (next-due dates, the rendered-body attachment,\n // the queue flag), each skipping a row Airtable cannot hold.\n const { listSites, listAllReports } = await import(\"../../db/fleet-state.js\");\n const fleetDb = lazyFleetDb();\n try {\n const result = await draftDueReports(base, new Date(), {\n roster: async () => listSites(await fleetDb.get()),\n allReports: async () => listAllReports(await fleetDb.get()),\n scheduleMirror: await makeScheduleMirrorBestEffort(),\n reportMirror: await makeReportMirror(),\n siteMirror: await makeSiteMirror(),\n });\n await alertOnFleetAnalyticsFailure(result.health);\n return { output: result.output, code: result.code };\n } finally {\n await fleetDb.close();\n }\n}\n\n/** Best-effort: when a draft run's GA/Search soft-failures look FLEET-WIDE (the shared\n * GA_SUBJECT lost access — see assessAnalyticsAlert), email the operator one alert.\n * NEVER throws — a Resend or config hiccup must not fail the nightly draft cron; the\n * per-site `⚠ GA skipped` warnings + the run-output line still carry the signal. The\n * daily idempotency key dedupes multiple runs in one day (Resend dedupes 24h). */\nasync function alertOnFleetAnalyticsFailure(health: AnalyticsRunHealth): Promise<void> {\n if (!assessAnalyticsAlert(health).fire) return;\n try {\n const to = operatorEmail();\n const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());\n const { defaultResendClient } = await import(\"../../reports/send/resend.js\");\n await defaultResendClient().send({\n from: \"Reddoor Reports <reports@reddoorla.com>\",\n to: [to],\n subject,\n html,\n idempotencyKey: `analytics-alert-${new Date().toISOString().slice(0, 10)}`,\n });\n console.warn(`⚠ ${subject} — operator alerted (${to})`);\n } catch (e) {\n console.warn(`⚠ analytics-failure alert send failed: ${(e as Error).message}`);\n }\n}\n\n/**\n * Write each site's code-computed next-maintenance / next-testing date back to Airtable\n * (date-only, or null when there's no schedule), so the \"next\" dates shown there derive\n * from the SAME `nextDueDate` the scheduler uses — replacing the old Airtable formula +\n * automation. Best-effort and per-site isolated: a missing `Next … at` column or one bad\n * row warns and is skipped, never aborting the nightly draft run.\n *\n * The diff-guard (#539 Phase 3): a site whose computed dates equal what its row\n * already holds is SKIPPED — before it, every one of the 44 sites got a nightly\n * write while only the handful whose schedule actually moved needed one (a\n * never-maintained site re-wrote null over null forever). This also scopes the\n * write to maintained sites by construction: no schedule → computed null →\n * equal to the stored null → skipped. Each real write dual-writes through\n * `scheduleMirror` into site_schedule (null mirror = Turso creds absent; the\n * hourly sync converges either way). The NEXT_DUE_WRITE line is observability,\n * not a CI gate — nothing greps it yet.\n */\nexport async function writeNextDueDates(\n base: AirtableBase,\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n scheduleMirror: ScheduleMirror | null = null,\n): Promise<void> {\n const ymd = (d: Date | null): string | null => (d ? d.toISOString().slice(0, 10) : null);\n let wrote = 0;\n let skipped = 0;\n let failed = 0;\n let mirrored = 0;\n let mirrorFailed = 0;\n let mirrorMissed = 0;\n for (const site of websites) {\n // The whole per-site body sits in ONE try — compute included — so a bad\n // row can only cost its own write, exactly the pre-diff-guard blast radius.\n try {\n const maintenanceAt = ymd(nextDueDate(site, reports, \"Maintenance\", today));\n const testingAt = ymd(nextDueDate(site, reports, \"Testing\", today));\n if (maintenanceAt === site.nextMaintenanceAt && testingAt === site.nextTestingAt) {\n skipped++;\n continue;\n }\n const fields = await updateNextDueDates(base, site.id, { maintenanceAt, testingAt });\n wrote++;\n if (scheduleMirror) {\n try {\n // false = the UPDATE matched no site_schedule row (site created in\n // Airtable since the last hourly import) — missed, not mirrored.\n if (await scheduleMirror(site.id, fields, today.toISOString())) mirrored++;\n else mirrorMissed++;\n } catch (e) {\n mirrorFailed++;\n console.warn(`⚠ [schedule-mirror] ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n failed++;\n console.warn(`⚠ next-due write skipped for ${site.name}: ${(e as Error).message}`);\n }\n }\n // failed= keeps a write outage visible: without it wrote+skipped silently\n // undercounts, and a FULL outage prints wrote=0 skipped=0 — indistinguishable\n // from an empty fleet.\n // A null mirror writes nothing and throws nothing, so its only trace used to\n // be an ABSENT suffix — which reads exactly like a healthy run. `mirror=absent`\n // states it instead. Counters stay off in that case deliberately: `mirrored=0`\n // would claim a write that returned nothing, not one that was never attempted.\n const mirrorNote = scheduleMirror\n ? ` mirrored=${mirrored} mirror_failed=${mirrorFailed} mirror_missed=${mirrorMissed}`\n : \" mirror=absent\";\n console.log(`NEXT_DUE_WRITE wrote=${wrote} skipped=${skipped} failed=${failed}${mirrorNote}`);\n}\n\n/** What the nightly draft batch reads and writes, injected at its composition\n * root (`runDueDraft`).\n *\n * #646 step 4: `roster` and `allReports` READ FROM TURSO. They are required\n * rather than defaulted for the reason every other step-4 reader is — this\n * function is called directly by tests, and a default would let a unit suite\n * open a real store. They also had to move together with report creation: a\n * report row now exists only in Turso, so an Airtable `listAllReports` would\n * stop seeing last night's drafts and the period guard would re-draft every\n * site every night. */\nexport type DraftDueDeps = {\n /** Every site in the fleet — `site_<ULID>` sites included, which an Airtable\n * roster cannot see at all. */\n roster: () => Promise<WebsiteRow[]>;\n /** Every report, unfiltered: the period/idempotency guard and `findDueReports`\n * both match on siteId in memory. */\n allReports: () => Promise<ReportRow[]>;\n scheduleMirror?: ScheduleMirror | null;\n /** #539 Phase 5, and since #646 step 4 the store that MINTS and holds each new\n * report row. Forwarded to every draftReportForSite call — the nightly batch is\n * the only unattended creator of report rows. */\n reportMirror: ReportMirror;\n /** #539 Phase 5: the Websites-row twin — drafting stamps `Analytics soft-fail\n * at` on the SITE row, a different Turso table from the report. */\n siteMirror?: SiteMirror;\n};\n\nexport async function draftDueReports(\n base: AirtableBase,\n today: Date,\n deps: DraftDueDeps,\n): Promise<{ output: string; code: number; health: AnalyticsRunHealth }> {\n const scheduleMirror = deps.scheduleMirror ?? null;\n const mirrorOpt = {\n reportMirror: deps.reportMirror,\n ...(deps.siteMirror ? { siteMirror: deps.siteMirror } : {}),\n };\n const websites = await deps.roster();\n const reports = await deps.allReports();\n\n // Refresh every site's code-owned next-due dates first, so they stay current even on\n // a run where nothing is due (the early return below).\n await writeNextDueDates(base, websites, reports, today, scheduleMirror);\n\n const due = findDueReports(websites, reports, today);\n\n // GA/Search enrichment is configured globally (the impersonation subject) AND\n // per-site (a GA4 property or a search query). `gaConfigured` is the global half;\n // the per-site half is checked as each draft runs, to build the fleet-wide\n // analytics-failure alert's denominator (see alertOnFleetAnalyticsFailure).\n const gaConfigured = readGaConfig() !== null;\n // Truthy (not `!== null`) to mirror fetchGaUsers/fetchSearch's own gate exactly\n // (`!siteRow.ga4PropertyId`), so an empty-string cell counts as not-configured in\n // BOTH places and can't inflate the alert denominator.\n const isAnalyticsConfigured = (s: WebsiteRow): boolean =>\n gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);\n\n if (due.length === 0) {\n return {\n output: \"No reports due.\",\n code: 0,\n health: { softFailedSites: 0, configuredSites: 0 },\n };\n }\n\n const lines: string[] = [];\n let softFailedSites = 0;\n let searchDefaultMisses = 0;\n let searchPropertiesMissing = 0;\n let gaConfiguredSites = 0;\n let skipped = 0;\n for (const item of due) {\n // Idempotency: a re-run must not re-draft a (site, type) already drafted this\n // recurrence. The dueDate's YYYY-MM is the stable per-cycle key. Match against the\n // reports we already fetched — no extra query on the hot path.\n const period = reportPeriodKey(item.dueDate);\n const existing = reports.find(\n (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period,\n );\n\n // A row already exists for THIS period. Two cases:\n // - Draft ready → truly done, skip (the idempotent re-run path).\n // - NOT ready → a crash between createDraft and setDraftReady wedged it: the\n // row exists (so we never re-draft) yet it's never sendable (listSendable\n // needs Draft ready). COMPLETE it in place instead of skipping forever —\n // re-render → re-upload the HTML → flip Draft ready on the EXISTING row.\n if (existing) {\n if (existing.draftReady) {\n skipped++;\n lines.push(`• skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);\n continue;\n }\n // A not-ready row is normally a crash between createDraft and setDraftReady — re-complete\n // it in place. BUT queueDraft also clears Draft ready on rows it supersedes/blocks, and\n // those must NOT be re-completed: doing so would re-render and APPEND a duplicate HTML\n // attachment every nightly run, only to be re-blocked. Distinguish the two: if a\n // higher-or-equal-tier report is still pending for this site, this row was intentionally\n // un-queued (not crashed) — skip it until the blocker is sent/approved or the month rolls.\n const blockedByPending = reports.some(\n (r) =>\n r.siteId === item.site.id &&\n r.id !== existing.id &&\n r.sentAt === null &&\n r.draftReady &&\n reportTier(r.reportType) >= reportTier(item.reportType),\n );\n if (blockedByPending) {\n skipped++;\n lines.push(\n `• skipped (superseded — a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`,\n );\n continue;\n }\n try {\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n completeRowId: existing.id,\n existingRow: existing,\n ...mirrorOpt,\n });\n existing.draftReady = result.queued === true;\n lines.push(\n draftLine(\n result.reportRow?.reportId ?? existing.reportId,\n result.queued,\n result.supersededIds,\n \"completed half-made draft\",\n ),\n );\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n continue;\n }\n\n // Pile-up guard: don't accrue a fresh new-period draft every recurrence for a\n // site nobody ever approves. The period key follows the DUE month, so each\n // recurrence wants a new (later-period) draft — but if a PRIOR draft is still\n // pending approval, a new one just stacks. Skip creating the new one while an\n // earlier-period draft for this (site, type) sits ready-but-unsent.\n //\n // `r.draftReady` is load-bearing: a draft a higher tier SUPERSEDED has\n // draftReady=false and never gets a Sent at, so without this clause it would\n // match (sentAt null + earlier period) and block EVERY future draft for the\n // site forever. Pending-approval means draftReady=true AND sentAt=null.\n const pendingEarlier = reports.find(\n (r) =>\n r.siteId === item.site.id &&\n r.reportType === item.reportType &&\n r.draftReady &&\n r.sentAt === null &&\n r.period !== null &&\n r.period < period,\n );\n if (pendingEarlier) {\n skipped++;\n lines.push(\n `• skipped: ${item.site.name} ${item.reportType} already has an unsent ${pendingEarlier.period} draft pending approval`,\n );\n continue;\n }\n\n try {\n // Pass the SAME key the guard searches by, so the stamped Period always\n // matches a future run's reportPeriodKey(dueDate) — even if this run lags\n // into a later month than the dueDate.\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n ...mirrorOpt,\n });\n lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));\n // Keep the in-memory snapshot current so the guard's `.some()` check on the\n // NEXT iteration of this same run catches a row we JUST created — rather than\n // relying on findDueReports never emitting two items for the same (site, type).\n if (result.reportRow) reports.push(result.reportRow);\n // Count sites (not individual GA/Search failures) so a fleet-wide enrichment\n // outage is one obvious line at the bottom, not 200 buried console.warns.\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n }\n if (skipped > 0) {\n lines.push(`• ${skipped} already drafted or pending this period`);\n }\n if (softFailedSites > 0) {\n lines.push(\n `⚠ ${softFailedSites} site${softFailedSites === 1 ? \"\" : \"s\"} had GA/Search enrichment fail — drafted with blank analytics; check the logs above`,\n );\n }\n if (searchDefaultMisses > 0) {\n lines.push(\n `⚑ ${searchDefaultMisses} site${searchDefaultMisses === 1 ? \"\" : \"s\"} returned no Search Console data for their name — set an explicit \"Search query\" in Airtable to track brand presence.`,\n );\n }\n if (searchPropertiesMissing > 0) {\n lines.push(\n `⚑ ${searchPropertiesMissing} site${searchPropertiesMissing === 1 ? \"\" : \"s\"} matched NO Search Console property — verify the domain property exists and the service account has access (a \"Search query\" change cannot fix this).`,\n );\n }\n return {\n output: lines.join(\"\\n\"),\n code: lines.some((l) => l.startsWith(\"✗\")) ? 1 : 0,\n health: { softFailedSites, configuredSites: gaConfiguredSites },\n };\n}\n\nasync function runSingleSiteDraft(\n slug: string,\n opts: { previewOnly: boolean; enrich: boolean; reportType: ReportType },\n): Promise<{ output: string; code: number }> {\n // Airtable is still opened here: drafting keeps its Airtable SHADOW writes\n // (the rendered-body attachment, the analytics-health stamp, the queue flag),\n // each of which skips a row Airtable cannot hold. What moved in #646 step 4 is\n // the roster READ — an Airtable roster cannot see a `site_<ULID>` site, so this\n // command answered \"No Websites row matched\" for every site created since step 3.\n const base = openBase(readAirtableConfig());\n const { readFleetRoster } = await import(\"../../fleet/roster.js\");\n const websites = await readFleetRoster();\n const site = websites.find((w) => siteSlug(w.name) === slug);\n if (!site) {\n throw Object.assign(new Error(`No site matched slug \"${slug}\"`), { exitCode: 2 });\n }\n // Phase 5 dual-write (#539) — but only on the path that actually creates a\n // row. A preview writes nothing to Airtable, so opening a libSQL handle for\n // it would be pure cost (and `report --preview` is the one draft path that\n // deliberately does no store IO at all).\n const reportMirror = opts.previewOnly\n ? null\n : await (await import(\"../../reports/report-mirror.js\")).makeReportMirror();\n const siteMirror = opts.previewOnly\n ? null\n : await (await import(\"../../db/site-mirror.js\")).makeSiteMirror();\n const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {\n previewOnly: opts.previewOnly,\n // Only forced on. Left undefined, draftReportForSite keeps its default\n // (enrich iff there is a base), so the real drafting path is untouched.\n ...(opts.enrich ? { enrich: true } : {}),\n ...(reportMirror ? { reportMirror } : {}),\n ...(siteMirror ? { siteMirror } : {}),\n });\n if (opts.previewOnly) {\n return { output: `Preview written to ${result.htmlPath}`, code: 0 };\n }\n return {\n output: draftLine(\n result.reportRow?.reportId,\n result.queued,\n result.supersededIds,\n \"Draft created\",\n ),\n code: 0,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
|
+
TURSO_IS_AUTHORITATIVE
|
|
3
|
+
} from "./chunk-WQLNUEZL.js";
|
|
4
|
+
import {
|
|
5
|
+
getReportById,
|
|
6
|
+
insertReportRow,
|
|
7
|
+
listReportsForSite,
|
|
2
8
|
mirrorReportInsert,
|
|
3
9
|
mirrorReportPatch,
|
|
4
10
|
storeRenderedHtml
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-
|
|
7
|
-
import
|
|
8
|
-
TURSO_IS_AUTHORITATIVE
|
|
9
|
-
} from "./chunk-WQLNUEZL.js";
|
|
10
|
-
import "./chunk-F3JWOPTG.js";
|
|
11
|
+
} from "./chunk-BINFZQ2T.js";
|
|
12
|
+
import "./chunk-LF2TJN4X.js";
|
|
13
|
+
import "./chunk-ASLDCWBF.js";
|
|
11
14
|
import "./chunk-VAD43MOR.js";
|
|
12
|
-
import "./chunk-
|
|
15
|
+
import "./chunk-ICSRWW5Q.js";
|
|
13
16
|
import {
|
|
14
17
|
openDb,
|
|
15
18
|
readDbConfig
|
|
16
|
-
} from "./chunk-
|
|
17
|
-
import "./chunk-
|
|
19
|
+
} from "./chunk-MNRWHSFS.js";
|
|
20
|
+
import "./chunk-CP6MVZ5X.js";
|
|
18
21
|
import "./chunk-6VAL7XJT.js";
|
|
19
22
|
|
|
20
23
|
// src/reports/report-mirror.ts
|
|
@@ -47,8 +50,23 @@ async function makeReportMirror(open = () => openDb(readDbConfig()), strict = TU
|
|
|
47
50
|
throw new Error(`REPORT_MIRROR report=${reportId} op=${op}: no such row in Turso`);
|
|
48
51
|
}
|
|
49
52
|
};
|
|
53
|
+
const reading = (op) => {
|
|
54
|
+
if (!db) throw new Error(`REPORT_MIRROR op=${op} unavailable: ${why}`);
|
|
55
|
+
return db;
|
|
56
|
+
};
|
|
50
57
|
return {
|
|
58
|
+
create: async (rec) => {
|
|
59
|
+
await run(rec.id, "create", (d) => insertReportRow(d, rec));
|
|
60
|
+
const row = await getReportById(reading("create"), rec.id);
|
|
61
|
+
if (!row)
|
|
62
|
+
throw new Error(`REPORT_MIRROR report=${rec.id} op=create: row not found after insert`);
|
|
63
|
+
return row;
|
|
64
|
+
},
|
|
51
65
|
created: (rec) => run(rec.id, "created", (d) => mirrorReportInsert(d, rec)),
|
|
66
|
+
// `async` so a missing handle REJECTS rather than throwing synchronously —
|
|
67
|
+
// every caller awaits this, and a synchronous throw would escape a
|
|
68
|
+
// `.catch()` written around the await.
|
|
69
|
+
forSite: async (siteId) => listReportsForSite(reading("forSite"), siteId),
|
|
52
70
|
body: (reportId, html) => run(reportId, "body", (d) => storeRenderedHtml(d, reportId, html)),
|
|
53
71
|
patch: (reportId, patch) => run(reportId, "patch", (d) => mirrorReportPatch(d, reportId, patch))
|
|
54
72
|
};
|
|
@@ -56,4 +74,4 @@ async function makeReportMirror(open = () => openDb(readDbConfig()), strict = TU
|
|
|
56
74
|
export {
|
|
57
75
|
makeReportMirror
|
|
58
76
|
};
|
|
59
|
-
//# sourceMappingURL=report-mirror-
|
|
77
|
+
//# sourceMappingURL=report-mirror-O6IDFDWU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/report-mirror.ts"],"sourcesContent":["/** #539 Phase 5: the Turso write-through for the Reports write surface — and,\n * since #646 step 4, the surface itself: `create` MINTS nothing (the creator\n * does) but it is the only place a report row comes into existence, and\n * `forSite` is the drafting path's read.\n *\n * Before this, the only report mirrors were UPDATEs on the request path\n * (approve, override, delivery status, commentary). Everything the DRAFTING\n * path writes — the row itself, the rendered body, the queue flag, a re-run's\n * refreshed scores — reached Turso only via the hourly sync. Two of those are\n * visible to the operator today: a fresh draft's row does not exist, and its\n * preview route answers \"No rendered body stored\" for up to an hour. At the\n * freeze they stop being windows.\n *\n * Deliberately UNLIKE `makeHealthMirrorBestEffort`, this never returns null.\n * #585 is the reason: Phase 3's next-due mirror silently no-opped in production\n * for weeks because the factory returned null without creds, and a dead\n * dual-write then looked exactly like a healthy one — the only tell was an\n * ABSENT log suffix nobody was watching for. Here creds-absent is a state the\n * mirror REPORTS, so every write emits one REPORT_MIRROR line and an absent\n * line means the wiring is gone, not that conditions were quiet.\n */\nimport { openDb, readDbConfig, type Db } from \"../db/client.js\";\nimport {\n getReportById,\n insertReportRow,\n listReportsForSite,\n mirrorReportInsert,\n mirrorReportPatch,\n storeRenderedHtml,\n type ReportMirrorPatch,\n} from \"../db/fleet-state.js\";\nimport type { ReportRow } from \"./report-row.js\";\nimport { TURSO_IS_AUTHORITATIVE } from \"../db/freeze.js\";\n\n/** The drafting path's whole report surface. Injected as ONE object rather than\n * as separate parameters because they share a db handle and always travel\n * together: a caller holding `create` but not `body` produces exactly the\n * half-written state (row present, preview 404) this module exists to prevent.\n *\n * Since #646 step 4 this is no longer only a mirror. `create` is the PRIMARY\n * write — Turso mints the report id and owns the row, and there is no Airtable\n * record for it to shadow (see `src/reports/create-report.ts`) — and `forSite`\n * is a READ the drafting path used to make against Airtable, which cannot see a\n * report for a `site_<ULID>` site. `body` and `patch` keep their older meaning:\n * write-throughs for state whose `rec…` rows Airtable may still shadow. The name\n * is kept because every composition root and every injection site already uses\n * it, and a rename would churn ten files to say what this comment says. */\nexport type ReportMirror = {\n /** Insert a brand-new report row and return what Turso stored. Not a mirror:\n * the row exists nowhere else. */\n create: (rec: { id: string; fields: Record<string, unknown> }) => Promise<ReportRow>;\n /** A row AIRTABLE created, as Airtable echoed it back — the pre-step-4 shape.\n * NO production path calls it any more: it pairs with the legacy Airtable\n * `createDraft`, which nothing calls either. Both are kept rather than\n * deleted because deleting from the Airtable layer is step 6, and both go\n * together when it comes. */\n created: (rec: { id: string; fields: Record<string, unknown> }) => Promise<void>;\n /** Every report for one site — the single-queue rule's and the period\n * derivation's read. */\n forSite: (siteId: string) => Promise<ReportRow[]>;\n /** A freshly rendered body for an existing row. */\n body: (reportId: string, html: string) => Promise<void>;\n /** Columns just written to an existing row. */\n patch: (reportId: string, patch: ReportMirrorPatch) => Promise<void>;\n};\n\n/** Build the drafting-path mirror. Never throws and never returns null:\n * Airtable is still authoritative through Phase 5, so a mirror problem must\n * not cost a draft the operator is waiting on — the hourly sync converges\n * whatever this misses. `open` is injectable for tests. */\nexport async function makeReportMirror(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n /** #612. `true` = Turso is the store that must succeed, so every failure\n * throws instead of being logged and swallowed. Defaulted from the shipped\n * constant and injected by tests, so both sides stay proven. */\n strict: boolean = TURSO_IS_AUTHORITATIVE,\n): Promise<ReportMirror> {\n let db: Db | null = null;\n let why = \"\";\n try {\n db = await open();\n } catch (e) {\n why = (e as Error).message;\n }\n // Frozen: refuse to hand back a mirror that cannot write — see makeSiteMirror\n // for why this fails at construction rather than per write.\n if (strict && !db) throw new Error(`REPORT_MIRROR unavailable: ${why}`);\n\n const run = async (\n reportId: string,\n op: string,\n /** `false` = the UPDATE matched no row; void = a writer that reports no count. */\n work: (db: Db) => Promise<void | boolean>,\n ) => {\n if (!db) {\n console.log(`REPORT_MIRROR report=${reportId} op=${op} mirrored=absent reason=${why}`);\n return;\n }\n // Every path below logs EXACTLY ONE line, then strict adds a throw — the\n // run log reads the same in both worlds, not just a stack.\n let matched: boolean;\n try {\n matched = (await work(db)) !== false;\n } catch (e) {\n console.log(\n `REPORT_MIRROR report=${reportId} op=${op} mirrored=0 error=${(e as Error).message}`,\n );\n if (strict) throw e;\n return;\n }\n // `missed` is its own outcome (#647), the same one `makeSiteMirror` already\n // reports: the UPDATE matched no row. Before the freeze the hourly sync\n // would import the row — a transient; after it no importer exists, so an\n // absent row stays absent and reporting mirrored=1 would claim a write\n // that never landed.\n console.log(`REPORT_MIRROR report=${reportId} op=${op} mirrored=${matched ? \"1\" : \"missed\"}`);\n if (strict && !matched) {\n throw new Error(`REPORT_MIRROR report=${reportId} op=${op}: no such row in Turso`);\n }\n };\n\n /** The two READS. They take the same handle but none of `run`'s write\n * semantics: there is no `mirrored=` outcome to report for a read, and a read\n * that cannot reach the store is never something to swallow — under the freeze\n * a missing handle already threw at construction, so this only restates it for\n * the non-strict world, where a drafting read silently answering \"no reports\"\n * would break the single-queue rule instead of failing. */\n const reading = (op: string): Db => {\n if (!db) throw new Error(`REPORT_MIRROR op=${op} unavailable: ${why}`);\n return db;\n };\n\n return {\n create: async (rec) => {\n await run(rec.id, \"create\", (d) => insertReportRow(d, rec));\n // Read back rather than map the input: the caller gets what Turso STORED,\n // so a coercion in the mapper can never diverge the returned row from the\n // persisted one. The same rule the Airtable create followed by returning\n // Airtable's echo.\n const row = await getReportById(reading(\"create\"), rec.id);\n if (!row)\n throw new Error(`REPORT_MIRROR report=${rec.id} op=create: row not found after insert`);\n return row;\n },\n created: (rec) => run(rec.id, \"created\", (d) => mirrorReportInsert(d, rec)),\n // `async` so a missing handle REJECTS rather than throwing synchronously —\n // every caller awaits this, and a synchronous throw would escape a\n // `.catch()` written around the await.\n forSite: async (siteId) => listReportsForSite(reading(\"forSite\"), siteId),\n body: (reportId, html) => run(reportId, \"body\", (d) => storeRenderedHtml(d, reportId, html)),\n patch: (reportId, patch) =>\n run(reportId, \"patch\", (d) => mirrorReportPatch(d, reportId, patch)),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAsEA,eAAsB,iBACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GAIrD,SAAkB,wBACK;AACvB,MAAI,KAAgB;AACpB,MAAI,MAAM;AACV,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,UAAO,EAAY;AAAA,EACrB;AAGA,MAAI,UAAU,CAAC,GAAI,OAAM,IAAI,MAAM,8BAA8B,GAAG,EAAE;AAEtE,QAAM,MAAM,OACV,UACA,IAEA,SACG;AACH,QAAI,CAAC,IAAI;AACP,cAAQ,IAAI,wBAAwB,QAAQ,OAAO,EAAE,2BAA2B,GAAG,EAAE;AACrF;AAAA,IACF;AAGA,QAAI;AACJ,QAAI;AACF,gBAAW,MAAM,KAAK,EAAE,MAAO;AAAA,IACjC,SAAS,GAAG;AACV,cAAQ;AAAA,QACN,wBAAwB,QAAQ,OAAO,EAAE,qBAAsB,EAAY,OAAO;AAAA,MACpF;AACA,UAAI,OAAQ,OAAM;AAClB;AAAA,IACF;AAMA,YAAQ,IAAI,wBAAwB,QAAQ,OAAO,EAAE,aAAa,UAAU,MAAM,QAAQ,EAAE;AAC5F,QAAI,UAAU,CAAC,SAAS;AACtB,YAAM,IAAI,MAAM,wBAAwB,QAAQ,OAAO,EAAE,wBAAwB;AAAA,IACnF;AAAA,EACF;AAQA,QAAM,UAAU,CAAC,OAAmB;AAClC,QAAI,CAAC,GAAI,OAAM,IAAI,MAAM,oBAAoB,EAAE,iBAAiB,GAAG,EAAE;AACrE,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,QAAQ,OAAO,QAAQ;AACrB,YAAM,IAAI,IAAI,IAAI,UAAU,CAAC,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAK1D,YAAM,MAAM,MAAM,cAAc,QAAQ,QAAQ,GAAG,IAAI,EAAE;AACzD,UAAI,CAAC;AACH,cAAM,IAAI,MAAM,wBAAwB,IAAI,EAAE,wCAAwC;AACxF,aAAO;AAAA,IACT;AAAA,IACA,SAAS,CAAC,QAAQ,IAAI,IAAI,IAAI,WAAW,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAAA;AAAA;AAAA;AAAA,IAI1E,SAAS,OAAO,WAAW,mBAAmB,QAAQ,SAAS,GAAG,MAAM;AAAA,IACxE,MAAM,CAAC,UAAU,SAAS,IAAI,UAAU,QAAQ,CAAC,MAAM,kBAAkB,GAAG,UAAU,IAAI,CAAC;AAAA,IAC3F,OAAO,CAAC,UAAU,UAChB,IAAI,UAAU,SAAS,CAAC,MAAM,kBAAkB,GAAG,UAAU,KAAK,CAAC;AAAA,EACvE;AACF;","names":[]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/fleet/roster.ts
|
|
2
|
+
async function readFleetRoster(open) {
|
|
3
|
+
const { listSites } = await import("./fleet-state-RJROYXKO.js");
|
|
4
|
+
let opener = open;
|
|
5
|
+
if (!opener) {
|
|
6
|
+
const { openDb, readDbConfig } = await import("./client-3XGSZMLU.js");
|
|
7
|
+
opener = () => openDb(readDbConfig());
|
|
8
|
+
}
|
|
9
|
+
const db = await opener();
|
|
10
|
+
try {
|
|
11
|
+
return await listSites(db);
|
|
12
|
+
} finally {
|
|
13
|
+
await db.destroy();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
readFleetRoster
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=roster-EJ3ILR52.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/fleet/roster.ts"],"sourcesContent":["/**\n * The fleet roster for batch jobs — every site row, read from Turso (#646 step 4).\n *\n * Until step 4 each batch job (the audit write-back, `github-signals`,\n * `renovate-dispatch`, `header-image`, the Prismic verdict sink) listed sites\n * with the Airtable `listWebsites` and matched its results against that list.\n * Since step 3 a site created by `ensure-site` has a `site_<ULID>` id and no\n * Airtable record, so an Airtable roster is silently short: the site is never\n * swept, or it is swept and then fails its write-back with \"no Websites row\n * matched\". Turso holds every site.\n *\n * Same contract as `listWebsites` — every site, one `WebsiteRow` each — and the\n * rows are the ones the reader-equivalence instrument pins field-for-field to\n * Airtable's `mapRow` (tests/db/fleet-state.test.ts).\n *\n * The Airtable SHADOW writes these jobs still make are untouched: each Airtable\n * writer skips a non-`rec` id itself (`AIRTABLE_SHADOW skipped=non-rec-id`).\n *\n * `open` defaults to `TURSO_DATABASE_URL`/`TURSO_AUTH_TOKEN`; tests inject a temp\n * `file:` db or a whole `FleetRoster`. The connection this opens is closed before\n * returning — the roster is a snapshot, not a handle. The db stack is imported\n * lazily: `kysely` and the libSQL client are devDependencies.\n */\nimport type { Db } from \"../db/client.js\";\nimport type { WebsiteRow } from \"./site-row.js\";\n\n/** A source of the fleet's site rows. Injected by tests; defaulted to Turso. */\nexport type FleetRoster = () => Promise<WebsiteRow[]>;\n\nexport async function readFleetRoster(open?: () => Promise<Db>): Promise<WebsiteRow[]> {\n const { listSites } = await import(\"../db/fleet-state.js\");\n let opener = open;\n if (!opener) {\n const { openDb, readDbConfig } = await import(\"../db/client.js\");\n opener = () => openDb(readDbConfig());\n }\n const db = await opener();\n try {\n return await listSites(db);\n } finally {\n await db.destroy();\n }\n}\n"],"mappings":";AA6BA,eAAsB,gBAAgB,MAAiD;AACrF,QAAM,EAAE,UAAU,IAAI,MAAM,OAAO,2BAAsB;AACzD,MAAI,SAAS;AACb,MAAI,CAAC,QAAQ;AACX,UAAM,EAAE,QAAQ,aAAa,IAAI,MAAM,OAAO,sBAAiB;AAC/D,aAAS,MAAM,OAAO,aAAa,CAAC;AAAA,EACtC;AACA,QAAM,KAAK,MAAM,OAAO;AACxB,MAAI;AACF,WAAO,MAAM,UAAU,EAAE;AAAA,EAC3B,UAAE;AACA,UAAM,GAAG,QAAQ;AAAA,EACnB;AACF;","names":[]}
|
|
@@ -34,6 +34,18 @@ async function listScreenOutsSince(db, sinceDate) {
|
|
|
34
34
|
}
|
|
35
35
|
return out;
|
|
36
36
|
}
|
|
37
|
+
async function screenOutTotalsForSite(db, siteId, sinceDate) {
|
|
38
|
+
const bucket = await db.selectFrom("spam_screenouts").select((eb) => [
|
|
39
|
+
eb.fn.sum("honeypot").as("honeypot"),
|
|
40
|
+
eb.fn.sum("too_fast").as("too_fast")
|
|
41
|
+
]).where("site_id", "=", siteId).where("date", ">=", sinceDate).executeTakeFirst();
|
|
42
|
+
const marked = await db.selectFrom("submissions").select((eb) => eb.fn.countAll().as("marked_spam")).where("site_id", "=", siteId).where("status", "=", "spam").where("submitted_at", ">=", sinceDate).executeTakeFirst();
|
|
43
|
+
return {
|
|
44
|
+
honeypot: Number(bucket?.honeypot) || 0,
|
|
45
|
+
tooFast: Number(bucket?.too_fast) || 0,
|
|
46
|
+
markedSpam: Number(marked?.marked_spam) || 0
|
|
47
|
+
};
|
|
48
|
+
}
|
|
37
49
|
function screenOutsSince(now, days) {
|
|
38
50
|
const d = new Date(now.getTime() - days * 24 * 60 * 60 * 1e3);
|
|
39
51
|
return d.toISOString().slice(0, 10);
|
|
@@ -52,6 +64,7 @@ export {
|
|
|
52
64
|
backfillScreenoutBucket,
|
|
53
65
|
listScreenOutsSince,
|
|
54
66
|
recordScreenOut,
|
|
67
|
+
screenOutTotalsForSite,
|
|
55
68
|
screenOutsSince
|
|
56
69
|
};
|
|
57
|
-
//# sourceMappingURL=screenouts-
|
|
70
|
+
//# sourceMappingURL=screenouts-7NQUWHOJ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/screenouts.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Db } from \"./client.js\";\n\nexport type ScreenOutReason = \"honeypot\" | \"too-fast\";\nexport type ScreenOutTotals = { honeypot: number; tooFast: number; markedSpam: number };\n\nconst COLUMN: Record<ScreenOutReason, \"honeypot\" | \"too_fast\"> = {\n honeypot: \"honeypot\",\n \"too-fast\": \"too_fast\",\n};\n\n/** Atomically increment the caught counter for a reason on the (site, date) bucket.\n * ON CONFLICT keeps the count exact — no read-modify-write race, no duplicate\n * buckets. A swallowed failure must never error a screened bot (caller's job). */\nexport async function recordScreenOut(\n db: Db,\n siteId: string,\n reason: ScreenOutReason,\n date: string,\n): Promise<void> {\n const col = COLUMN[reason];\n await sql`\n INSERT INTO spam_screenouts (site_id, date, ${sql.ref(col)})\n VALUES (${siteId}, ${date}, 1)\n ON CONFLICT (site_id, date) DO UPDATE SET ${sql.ref(col)} = ${sql.ref(col)} + 1\n `.execute(db);\n}\n\n/** Per-site spam totals over the window `>= sinceDate`.\n *\n * `honeypot`/`tooFast` are summed from the ingest-time `spam_screenouts` buckets\n * (bots screened BEFORE storage — there's no row to count, so an atomic counter\n * is the right source).\n *\n * `markedSpam` is DERIVED from the submissions table — a live `COUNT(*) WHERE\n * status = 'spam'` — NOT a mutable counter. A counter incremented on every\n * →spam transition double-counts when an operator re-marks a submission\n * (spam → new → spam) and can never self-correct an un-mark; counting the rows\n * themselves is exact and idempotent. Windowed by `submitted_at` so it's\n * arrival-dated, matching the honeypot/too-fast buckets (which key on the\n * screen = arrival day). The legacy `marked_spam` column is no longer read. */\nexport async function listScreenOutsSince(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, ScreenOutTotals>> {\n const screenoutRows = await db\n .selectFrom(\"spam_screenouts\")\n .select((eb) => [\n \"site_id\",\n eb.fn.sum<number>(\"honeypot\").as(\"honeypot\"),\n eb.fn.sum<number>(\"too_fast\").as(\"too_fast\"),\n ])\n .where(\"date\", \">=\", sinceDate)\n .groupBy(\"site_id\")\n .execute();\n\n const markedSpamRows = await db\n .selectFrom(\"submissions\")\n .select((eb) => [\"site_id\", eb.fn.countAll<number>().as(\"marked_spam\")])\n .where(\"status\", \"=\", \"spam\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .groupBy(\"site_id\")\n .execute();\n\n const out = new Map<string, ScreenOutTotals>();\n for (const r of screenoutRows) {\n out.set(r.site_id, {\n honeypot: Number(r.honeypot) || 0,\n tooFast: Number(r.too_fast) || 0,\n markedSpam: 0,\n });\n }\n for (const r of markedSpamRows) {\n const totals = out.get(r.site_id) ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n totals.markedSpam = Number(r.marked_spam) || 0;\n out.set(r.site_id, totals);\n }\n return out;\n}\n\n/** The same totals for ONE site — the `/s/:slug` spam panel (MED-11).\n *\n * Same two sources and the same window as `listScreenOutsSince`, each narrowed\n * by `site_id`: the buckets are served by `spam_screenouts`' own (site_id, date)\n * primary key, and the marked-spam count by `idx_submissions_site_submitted`.\n * Both plan as SEARCHes. The site page used to run the fleet-wide version and\n * `.get(site.id)` one entry out of it, which meant a per-request GROUP BY over\n * every bucket and every spam-marked lead in the fleet.\n *\n * Returns zeroes rather than null for a site with nothing screened — `null` is\n * the CALLER's way of saying \"the read failed\", and a quiet site must not be\n * reported as an outage. */\nexport async function screenOutTotalsForSite(\n db: Db,\n siteId: string,\n sinceDate: string,\n): Promise<ScreenOutTotals> {\n const bucket = await db\n .selectFrom(\"spam_screenouts\")\n .select((eb) => [\n eb.fn.sum<number>(\"honeypot\").as(\"honeypot\"),\n eb.fn.sum<number>(\"too_fast\").as(\"too_fast\"),\n ])\n .where(\"site_id\", \"=\", siteId)\n .where(\"date\", \">=\", sinceDate)\n .executeTakeFirst();\n\n const marked = await db\n .selectFrom(\"submissions\")\n .select((eb) => eb.fn.countAll<number>().as(\"marked_spam\"))\n .where(\"site_id\", \"=\", siteId)\n .where(\"status\", \"=\", \"spam\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .executeTakeFirst();\n\n return {\n honeypot: Number(bucket?.honeypot) || 0,\n tooFast: Number(bucket?.too_fast) || 0,\n markedSpam: Number(marked?.marked_spam) || 0,\n };\n}\n\n/** The ISO date (YYYY-MM-DD) `days` before `now`, for the window queries.\n * Verbatim from the Airtable module so the windows match exactly. */\nexport function screenOutsSince(now: Date, days: number): string {\n const d = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);\n return d.toISOString().slice(0, 10);\n}\n\n/** Set the (site, date) bucket to exact totals. Replace-upsert (DO UPDATE SET col\n * = excluded.col) so re-running the backfill is idempotent. The caller pre-sums\n * duplicate same-day Airtable buckets in JS before calling this. */\nexport async function backfillScreenoutBucket(\n db: Db,\n b: { siteId: string; date: string; honeypot: number; tooFast: number; markedSpam: number },\n): Promise<void> {\n await sql`\n INSERT INTO spam_screenouts (site_id, date, honeypot, too_fast, marked_spam)\n VALUES (${b.siteId}, ${b.date}, ${b.honeypot}, ${b.tooFast}, ${b.markedSpam})\n ON CONFLICT (site_id, date) DO UPDATE SET\n honeypot = excluded.honeypot,\n too_fast = excluded.too_fast,\n marked_spam = excluded.marked_spam\n `.execute(db);\n}\n"],"mappings":"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","names":[]}
|