@reddoorla/maintenance 0.96.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/README.md +5 -6
  2. package/dist/{announce-45HVP4J4.js → announce-2FH2KBQQ.js} +39 -31
  3. package/dist/announce-2FH2KBQQ.js.map +1 -0
  4. package/dist/attachments-JFGFQRXQ.js +10 -0
  5. package/dist/{blux-RUFMNOUJ.js → blux-NXQLMS4W.js} +5 -5
  6. package/dist/{bump-deps-EL3TKMIL.js → bump-deps-54K4ECP3.js} +6 -6
  7. package/dist/{chunk-IMYL67JV.js → chunk-3RLDX3Q2.js} +5 -5
  8. package/dist/{chunk-EQQ4H6L3.js → chunk-3YALLSBF.js} +2 -2
  9. package/dist/{chunk-LAJ3FF54.js → chunk-5UI4FERC.js} +16 -5
  10. package/dist/chunk-5UI4FERC.js.map +1 -0
  11. package/dist/{chunk-CA6ADGOC.js → chunk-6PGXKN6F.js} +2 -2
  12. package/dist/{chunk-Y5BVR4HW.js → chunk-6THEZQLA.js} +3 -3
  13. package/dist/chunk-ASLDCWBF.js +41 -0
  14. package/dist/chunk-ASLDCWBF.js.map +1 -0
  15. package/dist/{chunk-DVJNYPFR.js → chunk-AYDQQBNG.js} +2 -2
  16. package/dist/{chunk-OTEVKRAH.js → chunk-B2BRFWJY.js} +7 -16
  17. package/dist/chunk-B2BRFWJY.js.map +1 -0
  18. package/dist/{chunk-GEUC5VGM.js → chunk-BINFZQ2T.js} +49 -4
  19. package/dist/chunk-BINFZQ2T.js.map +1 -0
  20. package/dist/{chunk-T76MA2FH.js → chunk-CP6MVZ5X.js} +27 -1
  21. package/dist/{chunk-T76MA2FH.js.map → chunk-CP6MVZ5X.js.map} +1 -1
  22. package/dist/{chunk-72FJZ6LO.js → chunk-DRNZVORX.js} +26 -22
  23. package/dist/chunk-DRNZVORX.js.map +1 -0
  24. package/dist/{chunk-XY7YV2YQ.js → chunk-DXZRWW2F.js} +65 -10
  25. package/dist/chunk-DXZRWW2F.js.map +1 -0
  26. package/dist/{chunk-SHGIDTYF.js → chunk-DYTYPKFN.js} +2 -2
  27. package/dist/chunk-DZZ5KRTB.js +23 -0
  28. package/dist/chunk-DZZ5KRTB.js.map +1 -0
  29. package/dist/{chunk-NVLREB5R.js → chunk-EN6CTBUN.js} +7 -7
  30. package/dist/{chunk-ZH5LOQET.js → chunk-FHFXJ3EM.js} +7 -5
  31. package/dist/chunk-FHFXJ3EM.js.map +1 -0
  32. package/dist/{chunk-3EA43S3R.js → chunk-GRF2RYEO.js} +12 -12
  33. package/dist/{chunk-RRE74VWI.js → chunk-HSMN35LU.js} +2 -2
  34. package/dist/chunk-ICSRWW5Q.js +148 -0
  35. package/dist/chunk-ICSRWW5Q.js.map +1 -0
  36. package/dist/{chunk-XG2A6GIG.js → chunk-IDBPII6G.js} +4 -4
  37. package/dist/{chunk-XG2A6GIG.js.map → chunk-IDBPII6G.js.map} +1 -1
  38. package/dist/{chunk-ECDJDHKT.js → chunk-JHJNNJLG.js} +2 -2
  39. package/dist/{chunk-QFSL2L2B.js → chunk-L5F3WYIY.js} +81 -31
  40. package/dist/chunk-L5F3WYIY.js.map +1 -0
  41. package/dist/{chunk-XED2DNAI.js → chunk-LAG6IP7Y.js} +7 -7
  42. package/dist/{chunk-UIITN37J.js → chunk-LF2TJN4X.js} +2 -2
  43. package/dist/chunk-LF2TJN4X.js.map +1 -0
  44. package/dist/{chunk-SQ4FSNYL.js → chunk-LUF52EIY.js} +2 -2
  45. package/dist/{chunk-MZZDMBDT.js → chunk-LZMAG73U.js} +19 -19
  46. package/dist/chunk-LZMAG73U.js.map +1 -0
  47. package/dist/{chunk-6INOBNRO.js → chunk-MNRWHSFS.js} +2 -2
  48. package/dist/{chunk-CGQZFQAY.js → chunk-MVKNU4QD.js} +2 -3
  49. package/dist/chunk-MVKNU4QD.js.map +1 -0
  50. package/dist/{chunk-62YWOFVN.js → chunk-ORTOMEYR.js} +3 -3
  51. package/dist/{chunk-62YWOFVN.js.map → chunk-ORTOMEYR.js.map} +1 -1
  52. package/dist/{chunk-44SDGPGQ.js → chunk-PZZ6HM5T.js} +3 -3
  53. package/dist/chunk-RQJFGK3A.js +51 -0
  54. package/dist/chunk-RQJFGK3A.js.map +1 -0
  55. package/dist/{chunk-H445EH33.js → chunk-S2PLX4XK.js} +10 -7
  56. package/dist/chunk-S2PLX4XK.js.map +1 -0
  57. package/dist/{chunk-KN52PM63.js → chunk-TC4GANMX.js} +47 -149
  58. package/dist/chunk-TC4GANMX.js.map +1 -0
  59. package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
  60. package/dist/chunk-TEMQPSCM.js.map +1 -0
  61. package/dist/{chunk-4UTJ4G6P.js → chunk-TJLKWQOX.js} +2 -2
  62. package/dist/{chunk-A5QKNOXW.js → chunk-TTT6HYPW.js} +47 -10
  63. package/dist/chunk-TTT6HYPW.js.map +1 -0
  64. package/dist/{chunk-VEIIUCYL.js → chunk-TV76A6GN.js} +2 -2
  65. package/dist/{chunk-D3CTOCUR.js → chunk-TVZ6QCOE.js} +3 -3
  66. package/dist/chunk-UPE6D4AT.js +69 -0
  67. package/dist/chunk-UPE6D4AT.js.map +1 -0
  68. package/dist/{chunk-BIYQN7FH.js → chunk-VCPTOZ56.js} +31 -1
  69. package/dist/{chunk-BIYQN7FH.js.map → chunk-VCPTOZ56.js.map} +1 -1
  70. package/dist/chunk-VQMAAQ5M.js +53 -0
  71. package/dist/chunk-VQMAAQ5M.js.map +1 -0
  72. package/dist/{chunk-EASYCD6F.js → chunk-WMF5S2MB.js} +1 -1
  73. package/dist/{chunk-EASYCD6F.js.map → chunk-WMF5S2MB.js.map} +1 -1
  74. package/dist/{chunk-37B4LVB3.js → chunk-X56LAKPR.js} +3 -3
  75. package/dist/chunk-X56LAKPR.js.map +1 -0
  76. package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
  77. package/dist/chunk-YJBSB4LP.js.map +1 -0
  78. package/dist/{chunk-P4A6D6HO.js → chunk-ZEE5WQYU.js} +32 -18
  79. package/dist/chunk-ZEE5WQYU.js.map +1 -0
  80. package/dist/cli/bin.js +92 -50
  81. package/dist/cli/bin.js.map +1 -1
  82. package/dist/cli/commands/audit.d.ts +33 -3
  83. package/dist/cli/commands/audit.js +26 -23
  84. package/dist/cli/commands/audit.js.map +1 -1
  85. package/dist/client-3XGSZMLU.js +11 -0
  86. package/dist/{convert-to-pnpm-4DON7IFS.js → convert-to-pnpm-U2CRNKNY.js} +6 -6
  87. package/dist/{db-5K4CDEGJ.js → db-7XYTBIPH.js} +25 -18
  88. package/dist/db-7XYTBIPH.js.map +1 -0
  89. package/dist/{deadletter-G4M3HSVM.js → deadletter-A7RQVNG3.js} +8 -4
  90. package/dist/{digest-5AJGWS33.js → digest-AMXRGWZA.js} +34 -33
  91. package/dist/digest-AMXRGWZA.js.map +1 -0
  92. package/dist/{digest-collectors-MC5NWEUA.js → digest-collectors-CX2NCGS7.js} +15 -12
  93. package/dist/ensure-site-F2WFCWWA.js +70 -0
  94. package/dist/ensure-site-F2WFCWWA.js.map +1 -0
  95. package/dist/ensure-site-JEKV2ODE.js +105 -0
  96. package/dist/ensure-site-JEKV2ODE.js.map +1 -0
  97. package/dist/{ensure-site-XESZHZEB.js → ensure-site-OO4UPWNQ.js} +19 -42
  98. package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
  99. package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
  100. package/dist/{fleet-state-GJWGKTLD.js → fleet-state-RJROYXKO.js} +17 -7
  101. package/dist/{forms-notify-target-4NPLX2UG.js → forms-notify-target-DK5R326N.js} +17 -13
  102. package/dist/forms-notify-target-DK5R326N.js.map +1 -0
  103. package/dist/{github-signals-DMU74SAX.js → github-signals-RGRPYTUB.js} +20 -15
  104. package/dist/github-signals-RGRPYTUB.js.map +1 -0
  105. package/dist/{header-image-BRTOLHJF.js → header-image-43ZIEJKP.js} +13 -17
  106. package/dist/header-image-43ZIEJKP.js.map +1 -0
  107. package/dist/{health-endpoint-UCLMSSCN.js → health-endpoint-K5LXDPGE.js} +7 -7
  108. package/dist/{health-mirror-ONRT4DQ5.js → health-mirror-WZUHWCCV.js} +10 -10
  109. package/dist/{import-airtable-IIK2QUUF.js → import-airtable-UBF5GYAL.js} +3 -3
  110. package/dist/index.d.ts +102 -494
  111. package/dist/index.js +62 -52
  112. package/dist/index.js.map +1 -1
  113. package/dist/{init-3LMZTCVD.js → init-OFITGMOP.js} +16 -16
  114. package/dist/{launch-XTDMC6LD.js → launch-AJY3TGTQ.js} +37 -35
  115. package/dist/launch-AJY3TGTQ.js.map +1 -0
  116. package/dist/{lighthouse-52WSPZ5D.js → lighthouse-RFMFREEX.js} +2 -2
  117. package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
  118. package/dist/{chunk-IHHAYXKY.js → match-harness-CFELJA57.js} +70 -4
  119. package/dist/match-harness-CFELJA57.js.map +1 -0
  120. package/dist/migrate-EZL55AWA.js +7 -0
  121. package/dist/notify-K3WVH3DM.js +1 -1
  122. package/dist/{onboard-CZYBWVWH.js → onboard-F7RTLMDZ.js} +6 -6
  123. package/dist/{orchestrate-TGDFGY52.js → orchestrate-SAIFXZIW.js} +13 -10
  124. package/dist/parity-2JGVUCZD.js +14 -0
  125. package/dist/{pipeline-UUPXOS2J.js → pipeline-TCSNVDQZ.js} +3 -3
  126. package/dist/{preflight-POZZI3GN.js → preflight-6XGVVO2U.js} +35 -11
  127. package/dist/preflight-6XGVVO2U.js.map +1 -0
  128. package/dist/{prismic-ci-AHY5ULJR.js → prismic-ci-XMX7A5E4.js} +11 -11
  129. package/dist/prismic-ci-XMX7A5E4.js.map +1 -0
  130. package/dist/{prismic-models-URYGODNY.js → prismic-models-7GKFAAFA.js} +19 -16
  131. package/dist/prismic-models-7GKFAAFA.js.map +1 -0
  132. package/dist/prospect/types.d.ts +44 -2
  133. package/dist/prospect/types.js +1 -1
  134. package/dist/{prospect-audit-AJEE3Z4L.js → prospect-audit-KWP5P5U2.js} +37 -5
  135. package/dist/prospect-audit-KWP5P5U2.js.map +1 -0
  136. package/dist/{protection-audit-3J3UEOVT.js → protection-audit-YXJKFU6O.js} +3 -3
  137. package/dist/{protection-audit-3J3UEOVT.js.map → protection-audit-YXJKFU6O.js.map} +1 -1
  138. package/dist/recipes/sync-configs.js +3 -3
  139. package/dist/{render-MHUUHPLN.js → render-NWUAX6RN.js} +3 -3
  140. package/dist/{render-from-row-65DC6ZP3.js → render-from-row-75JTED2Z.js} +6 -4
  141. package/dist/{renovate-dispatch-ONJMBUHJ.js → renovate-dispatch-ZZQXT7DA.js} +13 -8
  142. package/dist/{renovate-dispatch-ONJMBUHJ.js.map → renovate-dispatch-ZZQXT7DA.js.map} +1 -1
  143. package/dist/replay-HB44TAII.js +77 -0
  144. package/dist/replay-HB44TAII.js.map +1 -0
  145. package/dist/{report-RC5LGPU3.js → report-MVPWS7WP.js} +102 -58
  146. package/dist/report-MVPWS7WP.js.map +1 -0
  147. package/dist/{report-mirror-KKZZ6RMZ.js → report-mirror-O6IDFDWU.js} +28 -10
  148. package/dist/report-mirror-O6IDFDWU.js.map +1 -0
  149. package/dist/roster-EJ3ILR52.js +19 -0
  150. package/dist/roster-EJ3ILR52.js.map +1 -0
  151. package/dist/{screenouts-7P5GOZMV.js → screenouts-7NQUWHOJ.js} +14 -1
  152. package/dist/screenouts-7NQUWHOJ.js.map +1 -0
  153. package/dist/{self-updating-RKKIYNV4.js → self-updating-KX5FMGEO.js} +7 -7
  154. package/dist/{selftest-AFLR7YV7.js → selftest-KABY2CYJ.js} +21 -17
  155. package/dist/{selftest-AFLR7YV7.js.map → selftest-KABY2CYJ.js.map} +1 -1
  156. package/dist/site-create-ZU3ACBTO.js +45 -0
  157. package/dist/site-create-ZU3ACBTO.js.map +1 -0
  158. package/dist/{site-mirror-H2FYMN55.js → site-mirror-TKTDWYP3.js} +10 -10
  159. package/dist/site-mirror-TKTDWYP3.js.map +1 -0
  160. package/dist/{smoke-suite-BWHR3XX5.js → smoke-suite-QQMV7TNA.js} +7 -7
  161. package/dist/{submissions-JDZP35JM.js → submissions-4I66W6QK.js} +11 -1
  162. package/dist/{submissions-JDZP35JM.js.map → submissions-4I66W6QK.js.map} +1 -1
  163. package/dist/{submissions-II5J35QM.js → submissions-K5UXGZUN.js} +12 -7
  164. package/dist/submissions-K5UXGZUN.js.map +1 -0
  165. package/dist/{svelte-codemods-QWGIRNN6.js → svelte-codemods-YFN4TEGV.js} +6 -6
  166. package/dist/{sync-NKPDCMYW.js → sync-F4USL4K5.js} +6 -6
  167. package/dist/{sync-configs-HGLQV55H.js → sync-configs-HBXS3D7C.js} +6 -6
  168. package/dist/turso-KQKBBQCK.js +24 -0
  169. package/dist/turso-KQKBBQCK.js.map +1 -0
  170. package/dist/{upgrade-WZ44IARG.js → upgrade-XOJMDIT4.js} +6 -6
  171. package/dist/util/git.d.ts +1 -1
  172. package/dist/util/git.js +1 -1
  173. package/dist/{websites-6DHIHJ2D.js → websites-Y6FN2XFD.js} +26 -21
  174. package/dist/{write-audits-to-airtable-BIK6LNVC.js → write-audits-to-airtable-TI3JMWSY.js} +6 -4
  175. package/package.json +1 -1
  176. package/dist/airtable-SJV4ZSMS.js +0 -9
  177. package/dist/announce-45HVP4J4.js.map +0 -1
  178. package/dist/attachments-NLZIJ5NU.js +0 -9
  179. package/dist/chunk-37B4LVB3.js.map +0 -1
  180. package/dist/chunk-72FJZ6LO.js.map +0 -1
  181. package/dist/chunk-74QRWE6G.js +0 -53
  182. package/dist/chunk-74QRWE6G.js.map +0 -1
  183. package/dist/chunk-A5QKNOXW.js.map +0 -1
  184. package/dist/chunk-CGQZFQAY.js.map +0 -1
  185. package/dist/chunk-F3JWOPTG.js.map +0 -1
  186. package/dist/chunk-GEUC5VGM.js.map +0 -1
  187. package/dist/chunk-H445EH33.js.map +0 -1
  188. package/dist/chunk-IHHAYXKY.js.map +0 -1
  189. package/dist/chunk-IY6256GK.js.map +0 -1
  190. package/dist/chunk-KN52PM63.js.map +0 -1
  191. package/dist/chunk-LAJ3FF54.js.map +0 -1
  192. package/dist/chunk-MZZDMBDT.js.map +0 -1
  193. package/dist/chunk-OTEVKRAH.js.map +0 -1
  194. package/dist/chunk-P4A6D6HO.js.map +0 -1
  195. package/dist/chunk-QFSL2L2B.js.map +0 -1
  196. package/dist/chunk-UIITN37J.js.map +0 -1
  197. package/dist/chunk-XY7YV2YQ.js.map +0 -1
  198. package/dist/chunk-YFAXOD7Z.js +0 -42
  199. package/dist/chunk-YFAXOD7Z.js.map +0 -1
  200. package/dist/chunk-ZH5LOQET.js.map +0 -1
  201. package/dist/client-L7NAUG6J.js +0 -11
  202. package/dist/db-5K4CDEGJ.js.map +0 -1
  203. package/dist/digest-5AJGWS33.js.map +0 -1
  204. package/dist/ensure-site-XESZHZEB.js.map +0 -1
  205. package/dist/fleet-events-cF2Om88w.d.ts +0 -24
  206. package/dist/forms-notify-target-4NPLX2UG.js.map +0 -1
  207. package/dist/github-signals-DMU74SAX.js.map +0 -1
  208. package/dist/header-image-BRTOLHJF.js.map +0 -1
  209. package/dist/launch-XTDMC6LD.js.map +0 -1
  210. package/dist/lighthouse-airtable-3NOFB42B.js +0 -12
  211. package/dist/match-harness-OW45CB5H.js +0 -77
  212. package/dist/match-harness-OW45CB5H.js.map +0 -1
  213. package/dist/migrate-NE3DUMTM.js +0 -7
  214. package/dist/parity-AEKYSLN6.js +0 -14
  215. package/dist/preflight-POZZI3GN.js.map +0 -1
  216. package/dist/prismic-ci-AHY5ULJR.js.map +0 -1
  217. package/dist/prismic-models-URYGODNY.js.map +0 -1
  218. package/dist/prospect-audit-AJEE3Z4L.js.map +0 -1
  219. package/dist/replay-4FVFOMHH.js +0 -54
  220. package/dist/replay-4FVFOMHH.js.map +0 -1
  221. package/dist/report-RC5LGPU3.js.map +0 -1
  222. package/dist/report-mirror-KKZZ6RMZ.js.map +0 -1
  223. package/dist/screenouts-7P5GOZMV.js.map +0 -1
  224. package/dist/site-mirror-H2FYMN55.js.map +0 -1
  225. package/dist/submissions-II5J35QM.js.map +0 -1
  226. package/dist/write-audits-to-airtable-BIK6LNVC.js.map +0 -1
  227. /package/dist/{airtable-SJV4ZSMS.js.map → attachments-JFGFQRXQ.js.map} +0 -0
  228. /package/dist/{blux-RUFMNOUJ.js.map → blux-NXQLMS4W.js.map} +0 -0
  229. /package/dist/{bump-deps-EL3TKMIL.js.map → bump-deps-54K4ECP3.js.map} +0 -0
  230. /package/dist/{chunk-IMYL67JV.js.map → chunk-3RLDX3Q2.js.map} +0 -0
  231. /package/dist/{chunk-EQQ4H6L3.js.map → chunk-3YALLSBF.js.map} +0 -0
  232. /package/dist/{chunk-CA6ADGOC.js.map → chunk-6PGXKN6F.js.map} +0 -0
  233. /package/dist/{chunk-Y5BVR4HW.js.map → chunk-6THEZQLA.js.map} +0 -0
  234. /package/dist/{chunk-DVJNYPFR.js.map → chunk-AYDQQBNG.js.map} +0 -0
  235. /package/dist/{chunk-SHGIDTYF.js.map → chunk-DYTYPKFN.js.map} +0 -0
  236. /package/dist/{chunk-NVLREB5R.js.map → chunk-EN6CTBUN.js.map} +0 -0
  237. /package/dist/{chunk-3EA43S3R.js.map → chunk-GRF2RYEO.js.map} +0 -0
  238. /package/dist/{chunk-RRE74VWI.js.map → chunk-HSMN35LU.js.map} +0 -0
  239. /package/dist/{chunk-ECDJDHKT.js.map → chunk-JHJNNJLG.js.map} +0 -0
  240. /package/dist/{chunk-XED2DNAI.js.map → chunk-LAG6IP7Y.js.map} +0 -0
  241. /package/dist/{chunk-SQ4FSNYL.js.map → chunk-LUF52EIY.js.map} +0 -0
  242. /package/dist/{chunk-6INOBNRO.js.map → chunk-MNRWHSFS.js.map} +0 -0
  243. /package/dist/{chunk-44SDGPGQ.js.map → chunk-PZZ6HM5T.js.map} +0 -0
  244. /package/dist/{chunk-4UTJ4G6P.js.map → chunk-TJLKWQOX.js.map} +0 -0
  245. /package/dist/{chunk-VEIIUCYL.js.map → chunk-TV76A6GN.js.map} +0 -0
  246. /package/dist/{chunk-D3CTOCUR.js.map → chunk-TVZ6QCOE.js.map} +0 -0
  247. /package/dist/{attachments-NLZIJ5NU.js.map → client-3XGSZMLU.js.map} +0 -0
  248. /package/dist/{convert-to-pnpm-4DON7IFS.js.map → convert-to-pnpm-U2CRNKNY.js.map} +0 -0
  249. /package/dist/{client-L7NAUG6J.js.map → deadletter-A7RQVNG3.js.map} +0 -0
  250. /package/dist/{deadletter-G4M3HSVM.js.map → digest-collectors-CX2NCGS7.js.map} +0 -0
  251. /package/dist/{digest-collectors-MC5NWEUA.js.map → fleet-state-RJROYXKO.js.map} +0 -0
  252. /package/dist/{health-endpoint-UCLMSSCN.js.map → health-endpoint-K5LXDPGE.js.map} +0 -0
  253. /package/dist/{health-mirror-ONRT4DQ5.js.map → health-mirror-WZUHWCCV.js.map} +0 -0
  254. /package/dist/{fleet-state-GJWGKTLD.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
  255. /package/dist/{init-3LMZTCVD.js.map → init-OFITGMOP.js.map} +0 -0
  256. /package/dist/{import-airtable-IIK2QUUF.js.map → lighthouse-RFMFREEX.js.map} +0 -0
  257. /package/dist/{lighthouse-52WSPZ5D.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
  258. /package/dist/{lighthouse-airtable-3NOFB42B.js.map → migrate-EZL55AWA.js.map} +0 -0
  259. /package/dist/{onboard-CZYBWVWH.js.map → onboard-F7RTLMDZ.js.map} +0 -0
  260. /package/dist/{migrate-NE3DUMTM.js.map → orchestrate-SAIFXZIW.js.map} +0 -0
  261. /package/dist/{orchestrate-TGDFGY52.js.map → parity-2JGVUCZD.js.map} +0 -0
  262. /package/dist/{parity-AEKYSLN6.js.map → pipeline-TCSNVDQZ.js.map} +0 -0
  263. /package/dist/{render-MHUUHPLN.js.map → render-NWUAX6RN.js.map} +0 -0
  264. /package/dist/{pipeline-UUPXOS2J.js.map → render-from-row-75JTED2Z.js.map} +0 -0
  265. /package/dist/{self-updating-RKKIYNV4.js.map → self-updating-KX5FMGEO.js.map} +0 -0
  266. /package/dist/{smoke-suite-BWHR3XX5.js.map → smoke-suite-QQMV7TNA.js.map} +0 -0
  267. /package/dist/{svelte-codemods-QWGIRNN6.js.map → svelte-codemods-YFN4TEGV.js.map} +0 -0
  268. /package/dist/{sync-NKPDCMYW.js.map → sync-F4USL4K5.js.map} +0 -0
  269. /package/dist/{sync-configs-HGLQV55H.js.map → sync-configs-HBXS3D7C.js.map} +0 -0
  270. /package/dist/{upgrade-WZ44IARG.js.map → upgrade-XOJMDIT4.js.map} +0 -0
  271. /package/dist/{render-from-row-65DC6ZP3.js.map → websites-Y6FN2XFD.js.map} +0 -0
  272. /package/dist/{websites-6DHIHJ2D.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
@@ -1,19 +1,48 @@
1
+ import {
2
+ parseAutoEvidence,
3
+ toReportType
4
+ } from "./chunk-ASLDCWBF.js";
1
5
  import {
2
6
  ALL_CHECKLIST_FIELDS
3
7
  } from "./chunk-VAD43MOR.js";
8
+ import {
9
+ skipsAirtableShadow
10
+ } from "./chunk-RQJFGK3A.js";
4
11
 
5
- // src/reports/airtable/reports.ts
6
- var REPORTS_TABLE = "Reports";
7
- var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
8
- function toReportType(raw) {
9
- if (raw && REPORT_TYPES.includes(raw)) return raw;
10
- if (raw)
11
- console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} \u2014 treating as Maintenance`);
12
- return "Maintenance";
12
+ // src/reports/draft-fields.ts
13
+ function ymd(d) {
14
+ return d.toISOString().slice(0, 10);
13
15
  }
14
- function isPendingApproval(r) {
15
- return r.draftReady && !r.approvedToSend && r.sentAt === null;
16
+ function draftFields(input) {
17
+ const fields = {
18
+ "Report ID": input.reportId,
19
+ Site: [input.siteId],
20
+ "Report type": input.reportType,
21
+ "Period start": ymd(input.periodStart),
22
+ "Period end": ymd(input.periodEnd),
23
+ "Completed on": ymd(input.completedOn),
24
+ "Lighthouse \u2014 Performance": input.lighthouse.performance,
25
+ "Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
26
+ "Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
27
+ "Lighthouse \u2014 SEO": input.lighthouse.seo,
28
+ "Delivery status": "pending"
29
+ };
30
+ if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
31
+ if (input.gaUsersCurrent !== void 0) fields["GA users (period)"] = input.gaUsersCurrent;
32
+ if (input.gaUsersPrevious !== void 0) fields["GA users (prev period)"] = input.gaUsersPrevious;
33
+ if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
34
+ if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
35
+ if (input.period !== void 0) fields["Period"] = input.period;
36
+ if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
37
+ for (const field of input.checklistTicks ?? []) fields[field] = true;
38
+ if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
39
+ fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
40
+ }
41
+ return fields;
16
42
  }
43
+
44
+ // src/reports/airtable/reports.ts
45
+ var REPORTS_TABLE = "Reports";
17
46
  function mapRow(rec) {
18
47
  const f = rec.fields;
19
48
  const linkSites = f["Site"] ?? [];
@@ -51,29 +80,6 @@ function mapRow(rec) {
51
80
  overrideAt: f["Override at"] ?? null
52
81
  };
53
82
  }
54
- function parseAutoEvidence(raw) {
55
- if (typeof raw !== "string" || !raw.trim()) return null;
56
- let parsed;
57
- try {
58
- parsed = JSON.parse(raw);
59
- } catch {
60
- return null;
61
- }
62
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
63
- const out = {};
64
- for (const [field, v] of Object.entries(parsed)) {
65
- if (!v || typeof v !== "object") continue;
66
- const o = v;
67
- if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown" && o.result !== "n/a")
68
- continue;
69
- out[field] = {
70
- result: o.result,
71
- checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
72
- note: typeof o.note === "string" ? o.note : ""
73
- };
74
- }
75
- return Object.keys(out).length > 0 ? out : null;
76
- }
77
83
  function lighthouseFromFields(f) {
78
84
  const p = f["Lighthouse \u2014 Performance"];
79
85
  const a = f["Lighthouse \u2014 Accessibility"];
@@ -83,48 +89,12 @@ function lighthouseFromFields(f) {
83
89
  return null;
84
90
  return { performance: p, accessibility: a, bestPractices: b, seo: s };
85
91
  }
86
- function ymd(d) {
87
- return d.toISOString().slice(0, 10);
88
- }
89
- function escapeFormulaString(s) {
90
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
91
- }
92
- async function createDraft(base, input, mirror) {
93
- const fields = {
94
- "Report ID": input.reportId,
95
- Site: [input.siteId],
96
- "Report type": input.reportType,
97
- "Period start": ymd(input.periodStart),
98
- "Period end": ymd(input.periodEnd),
99
- "Completed on": ymd(input.completedOn),
100
- "Lighthouse \u2014 Performance": input.lighthouse.performance,
101
- "Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
102
- "Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
103
- "Lighthouse \u2014 SEO": input.lighthouse.seo,
104
- "Delivery status": "pending"
105
- };
106
- if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
107
- if (input.gaUsersCurrent !== void 0) fields["GA users (period)"] = input.gaUsersCurrent;
108
- if (input.gaUsersPrevious !== void 0) fields["GA users (prev period)"] = input.gaUsersPrevious;
109
- if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
110
- if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
111
- if (input.period !== void 0) fields["Period"] = input.period;
112
- if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
113
- for (const field of input.checklistTicks ?? []) fields[field] = true;
114
- if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
115
- fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
116
- }
117
- const created = await base(REPORTS_TABLE).create([{ fields }]);
118
- const rec = created[0];
119
- if (!rec) throw new Error("Airtable create returned no records");
120
- const raw = { id: rec.id, fields: rec.fields };
121
- if (mirror) await mirror(raw);
122
- return mapRow(raw);
123
- }
124
92
  async function setDraftReady(base, recordId, ready) {
93
+ if (skipsAirtableShadow("setDraftReady", recordId)) return;
125
94
  await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
126
95
  }
127
96
  async function updateReportScores(base, recordId, scores, completedOn, enrichment) {
97
+ if (skipsAirtableShadow("updateReportScores", recordId)) return;
128
98
  const fields = {
129
99
  "Lighthouse \u2014 Performance": scores.performance,
130
100
  "Lighthouse \u2014 Accessibility": scores.accessibility,
@@ -142,17 +112,6 @@ async function updateReportScores(base, recordId, scores, completedOn, enrichmen
142
112
  fields["Search position"] = enrichment.searchPosition;
143
113
  await base(REPORTS_TABLE).update([{ id: recordId, fields }]);
144
114
  }
145
- async function listSendableReports(base) {
146
- const out = [];
147
- await base(REPORTS_TABLE).select({
148
- filterByFormula: "AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())",
149
- pageSize: 100
150
- }).eachPage((records, fetchNextPage) => {
151
- for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
152
- fetchNextPage();
153
- });
154
- return out;
155
- }
156
115
  async function listAllReports(base) {
157
116
  const out = [];
158
117
  await base(REPORTS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
@@ -161,10 +120,8 @@ async function listAllReports(base) {
161
120
  });
162
121
  return out;
163
122
  }
164
- async function listReportsForSite(base, siteId) {
165
- return (await listAllReports(base)).filter((r) => r.siteId === siteId);
166
- }
167
123
  async function stampSent(base, recordId, sentAt, messageId) {
124
+ if (skipsAirtableShadow("stampSent", recordId)) return;
168
125
  const fields = { "Sent at": sentAt.toISOString() };
169
126
  if (messageId !== null) fields["Resend message ID"] = messageId;
170
127
  await base(REPORTS_TABLE).update([
@@ -174,29 +131,12 @@ async function stampSent(base, recordId, sentAt, messageId) {
174
131
  }
175
132
  ]);
176
133
  }
177
- async function findReportByPeriod(base, siteId, reportType, period) {
178
- const safeType = escapeFormulaString(reportType);
179
- const safePeriod = escapeFormulaString(period);
180
- const formula = `AND({Report type} = "${safeType}", {Period} = "${safePeriod}")`;
181
- const rows = [];
182
- await base(REPORTS_TABLE).select({ filterByFormula: formula, pageSize: 100 }).eachPage((records, fetchNextPage) => {
183
- for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));
184
- fetchNextPage();
185
- });
186
- return rows.find((r) => r.siteId === siteId) ?? null;
187
- }
188
134
 
189
135
  export {
190
- toReportType,
191
- isPendingApproval,
192
- parseAutoEvidence,
193
- createDraft,
136
+ draftFields,
194
137
  setDraftReady,
195
138
  updateReportScores,
196
- listSendableReports,
197
139
  listAllReports,
198
- listReportsForSite,
199
- stampSent,
200
- findReportByPeriod
140
+ stampSent
201
141
  };
202
- //# sourceMappingURL=chunk-F3JWOPTG.js.map
142
+ //# sourceMappingURL=chunk-TEMQPSCM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/draft-fields.ts","../src/reports/airtable/reports.ts"],"sourcesContent":["/** The fields a new report row carries, as ONE pure function (#646 step 4).\n *\n * This used to live inside `createDraft` in the Airtable layer, because Airtable\n * was the primary write. Turso mints the report id and owns the row now, so the\n * field set has two consumers: the Turso creator (`createReportDraft`, via the\n * importer's `mapReportRecord`) and the legacy Airtable `createDraft`. It lives\n * OUT of `src/reports/airtable/` so the creator survives step 6 deleting that\n * directory.\n *\n * The keys are Airtable COLUMN NAMES and stay that way on purpose: they are the\n * vocabulary `mapReportRecord` reads, so a draft maps to a `reports` row through\n * the very function the parity harness diffs against — parity-clean by\n * construction rather than by a second column list someone has to remember to\n * extend. The column names outlive the Airtable client either way (design D1 kept\n * them as the import vocabulary; #646's own checklist notes that step 6 must\n * replace those maps, and this does not add a new place that has to change).\n */\nimport type { ReportType, LighthouseScores } from \"./types.js\";\nimport type { EvidenceRecord } from \"./auto-tick.js\";\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\n/** `YYYY-MM-DD`, the date shape every report date column stores. */\nexport function ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The full field set for a new report row.\n *\n * `Delivery status` is set to \"pending\" at CREATION time, not at send time. This\n * matters for H4: if the send's stamp wrote \"pending\" after the webhook had\n * already written \"delivered\" (a race), the operator would see a regressed status.\n *\n * Optional values are OMITTED rather than written as null — the pre-GA behaviour,\n * where a blank cell means \"fill this in by hand\" and a written null would claim\n * the value was measured as absent.\n */\nexport function draftFields(input: DraftInput): Record<string, unknown> {\n const fields: Record<string, unknown> = {\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 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 return fields;\n}\n","import type { FieldSet, Records } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport { isAirtableRecordId, skipsAirtableShadow } from \"../../fleet/site-id.js\";\nimport { draftFields, ymd, type DraftInput } from \"../draft-fields.js\";\nimport type { ReportType, LighthouseScores } from \"../types.js\";\nimport { ALL_CHECKLIST_FIELDS } from \"../checklist.js\";\nimport {\n toReportType,\n parseAutoEvidence,\n type DeliveryStatus,\n type ReportRow,\n} from \"../report-row.js\";\n\n// The report-row model and its pure coercers live in src/reports/report-row.ts\n// since #539 Phase 6 step 1 (#646). Re-exported so existing imports resolve unchanged.\nexport {\n toReportType,\n isPendingApproval,\n parseAutoEvidence,\n type DeliveryStatus,\n type ReportRow,\n} from \"../report-row.js\";\n\nexport const REPORTS_TABLE = \"Reports\";\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\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\n// The draft's field set and its `DraftInput` moved to src/reports/draft-fields.ts\n// in #646 step 4: Turso creates reports now, and the creator must outlive this\n// directory. Re-exported so existing imports resolve unchanged.\nexport { draftFields, ymd, type DraftInput } from \"../draft-fields.js\";\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\n/**\n * Create a Reports row in Airtable, letting Airtable mint the report id.\n *\n * LEGACY since #646 step 4 (operator decision 2026-09-17): no production path\n * calls this any more. Reports are created in Turso with a minted\n * `report_<ULID>` id (`src/reports/create-report.ts`), because Airtable cannot be\n * told what record id to use and every later write addresses the report by its\n * id. Kept — not deleted — because deleting anything from this directory is step\n * 6, which needs its own operator go.\n */\nexport async function createDraft(\n base: AirtableBase,\n input: DraftInput,\n mirror?: CreatedDraftMirror,\n): Promise<ReportRow> {\n // #646 step 3: NOT a shadow write, so it does not skip. This path MINTS the\n // report id — Airtable's record id is the report id — and a `site_<ULID>` site\n // has no Websites record for the `Site` link to point at. Skipping would silently\n // produce no report; letting Airtable reject the link would surface as an opaque\n // INVALID_RECORD_ID. Refuse by name; the Turso creator is what such a site uses.\n if (!isAirtableRecordId(input.siteId)) {\n throw new Error(\n `createDraft: site ${input.siteId} was created in Turso and has no Airtable record — ` +\n `create the report in Turso instead (createReportDraft, #646 step 4)`,\n );\n }\n const fields = draftFields(input) as FieldSet;\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\n/**\n * Every writer below is a SHADOW of a Turso row since the freeze, and since #646\n * step 4 a report's id may be a minted `report_<ULID>` that Airtable has never\n * heard of. Sending such an id to Airtable is a guaranteed 404 on the drafting,\n * approve, send and webhook paths alike, so each writer asks the same question\n * the site writers already ask — could Airtable hold this row at all? — and skips\n * with one greppable `AIRTABLE_SHADOW skipped=non-rec-id writer=<name>` line\n * when it could not. A `rec…` report is untouched: its shadow row is still\n * written until step 6 deletes this layer.\n *\n * The test that covers this (tests/reports/airtable/shadow-skip-report-ids.test.ts)\n * fails if an exported writer here is missing from it.\n */\nexport async function setDraftReady(\n base: AirtableBase,\n recordId: string,\n ready: boolean,\n): Promise<void> {\n if (skipsAirtableShadow(\"setDraftReady\", recordId)) return;\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 if (skipsAirtableShadow(\"updateReportScores\", recordId)) return;\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 if (skipsAirtableShadow(\"updateReportCommentary\", recordId)) return;\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 if (skipsAirtableShadow(\"stampSent\", recordId)) return;\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 if (skipsAirtableShadow(\"setDeliveryStatus\", recordId)) return;\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 if (skipsAirtableShadow(\"approveReportRow\", recordId)) return;\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 if (skipsAirtableShadow(\"overrideReportRow\", recordId)) return;\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,7 +1,7 @@
1
1
  import {
2
2
  ignoreRulesFor,
3
3
  pathsMissingFromHead
4
- } from "./chunk-EASYCD6F.js";
4
+ } from "./chunk-WMF5S2MB.js";
5
5
 
6
6
  // src/recipes/_head-guard.ts
7
7
  import { rm, rmdir } from "fs/promises";
@@ -49,4 +49,4 @@ export {
49
49
  refusedByGit,
50
50
  undoRefusedWrites
51
51
  };
52
- //# sourceMappingURL=chunk-4UTJ4G6P.js.map
52
+ //# sourceMappingURL=chunk-TJLKWQOX.js.map
@@ -17,16 +17,47 @@ async function createDeadLetter(db, input) {
17
17
  }).execute();
18
18
  return { id };
19
19
  }
20
+ async function readUnreplayedDeadLetters(db) {
21
+ const raw = await db.selectFrom("submission_deadletter").select(["id", "site_slug", "payload", "turnstile", "error", "received_at"]).where("replayed_at", "is", null).where("abandoned_at", "is", null).orderBy("received_at", "asc").execute();
22
+ const queue = { rows: [], unreadable: [] };
23
+ for (const r of raw) {
24
+ let payload;
25
+ try {
26
+ payload = JSON.parse(r.payload);
27
+ } catch (err) {
28
+ queue.unreadable.push({
29
+ id: r.id,
30
+ siteSlug: r.site_slug,
31
+ error: `payload is not decodable JSON: ${String(err)}`,
32
+ receivedAt: r.received_at
33
+ });
34
+ continue;
35
+ }
36
+ let turnstile;
37
+ try {
38
+ turnstile = JSON.parse(r.turnstile);
39
+ } catch (err) {
40
+ queue.unreadable.push({
41
+ id: r.id,
42
+ siteSlug: r.site_slug,
43
+ error: `turnstile is not decodable JSON: ${String(err)}`,
44
+ receivedAt: r.received_at
45
+ });
46
+ continue;
47
+ }
48
+ queue.rows.push({
49
+ id: r.id,
50
+ siteSlug: r.site_slug,
51
+ payload,
52
+ turnstile,
53
+ error: r.error,
54
+ receivedAt: r.received_at
55
+ });
56
+ }
57
+ return queue;
58
+ }
20
59
  async function listUnreplayedDeadLetters(db) {
21
- const rows = await db.selectFrom("submission_deadletter").select(["id", "site_slug", "payload", "turnstile", "error", "received_at"]).where("replayed_at", "is", null).where("abandoned_at", "is", null).orderBy("received_at", "asc").execute();
22
- return rows.map((r) => ({
23
- id: r.id,
24
- siteSlug: r.site_slug,
25
- payload: JSON.parse(r.payload),
26
- turnstile: JSON.parse(r.turnstile),
27
- error: r.error,
28
- receivedAt: r.received_at
29
- }));
60
+ return (await readUnreplayedDeadLetters(db)).rows;
30
61
  }
31
62
  async function markDeadLetterReplayed(db, id, outcome, submissionId, now) {
32
63
  await db.updateTable("submission_deadletter").set({
@@ -39,6 +70,10 @@ async function countUnreplayedDeadLettersBySlug(db) {
39
70
  const rows = await db.selectFrom("submission_deadletter").select((eb) => ["site_slug", eb.fn.countAll().as("n")]).where("replayed_at", "is", null).where("abandoned_at", "is", null).groupBy("site_slug").execute();
40
71
  return new Map(rows.map((r) => [r.site_slug, Number(r.n)]));
41
72
  }
73
+ async function countUnreplayedDeadLettersForSlug(db, slug) {
74
+ const row = await db.selectFrom("submission_deadletter").select((eb) => eb.fn.countAll().as("n")).where("site_slug", "=", slug).where("replayed_at", "is", null).where("abandoned_at", "is", null).executeTakeFirst();
75
+ return Number(row?.n) || 0;
76
+ }
42
77
  async function abandonDeadLetters(db, opts) {
43
78
  const bySlug = opts.slug !== void 0;
44
79
  const byId = opts.id !== void 0;
@@ -64,9 +99,11 @@ async function abandonDeadLetters(db, opts) {
64
99
  export {
65
100
  newDeadLetterId,
66
101
  createDeadLetter,
102
+ readUnreplayedDeadLetters,
67
103
  listUnreplayedDeadLetters,
68
104
  markDeadLetterReplayed,
69
105
  countUnreplayedDeadLettersBySlug,
106
+ countUnreplayedDeadLettersForSlug,
70
107
  abandonDeadLetters
71
108
  };
72
- //# sourceMappingURL=chunk-A5QKNOXW.js.map
109
+ //# sourceMappingURL=chunk-TTT6HYPW.js.map
@@ -0,0 +1 @@
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/** A queued row whose stored JSON could not be decoded. Carries no payload by\n * construction: the bytes that failed to parse are exactly the bytes nothing\n * should be guessing at, and they may hold a client's PII. */\nexport type UnreadableDeadLetterRow = {\n id: string;\n siteSlug: string;\n /** Which column failed, and the parser's own message. */\n error: string;\n receivedAt: string;\n};\n\nexport type DeadLetterQueue = {\n /** Rows that decoded — the ones replay can actually run. */\n rows: DeadLetterRow[];\n /** Rows that did not. Reported, never dropped: they stay queued, they still\n * count toward the cockpit alarm (which is computed in SQL and never touches\n * these columns), and #786's `--abandon` is the only way to retire one. */\n unreadable: UnreadableDeadLetterRow[];\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.\n *\n * MED-10(c). Decoding is PER ROW. This used to `JSON.parse` inside a `.map`\n * over the whole result set, so a single undecodable payload threw before any\n * row was returned and wedged replay for every other lead in the queue — the\n * `import-reap` shape, where one bad record froze the sync permanently. A row\n * that cannot be decoded is now separated out and handed back for the caller\n * to surface; the rows around it replay normally.\n */\nexport async function readUnreplayedDeadLetters(db: Db): Promise<DeadLetterQueue> {\n const raw = 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\n const queue: DeadLetterQueue = { rows: [], unreadable: [] };\n for (const r of raw) {\n let payload: unknown;\n try {\n payload = JSON.parse(r.payload) as unknown;\n } catch (err) {\n queue.unreadable.push({\n id: r.id,\n siteSlug: r.site_slug,\n error: `payload is not decodable JSON: ${String(err)}`,\n receivedAt: r.received_at,\n });\n continue;\n }\n let turnstile: TurnstileVerification;\n try {\n turnstile = JSON.parse(r.turnstile) as TurnstileVerification;\n } catch (err) {\n queue.unreadable.push({\n id: r.id,\n siteSlug: r.site_slug,\n error: `turnstile is not decodable JSON: ${String(err)}`,\n receivedAt: r.received_at,\n });\n continue;\n }\n queue.rows.push({\n id: r.id,\n siteSlug: r.site_slug,\n payload,\n turnstile,\n error: r.error,\n receivedAt: r.received_at,\n });\n }\n return queue;\n}\n\n/** The decodable rows only. Kept for callers that have no way to act on an\n * undecodable row; anything that REPLAYS must use `readUnreplayedDeadLetters`\n * so the rows it skipped are reported rather than silently missing. */\nexport async function listUnreplayedDeadLetters(db: Db): Promise<DeadLetterRow[]> {\n return (await readUnreplayedDeadLetters(db)).rows;\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/** The same count for ONE slug — the `/s/:slug` dead-letter chip (MED-11).\n *\n * The fleet homepage genuinely needs the whole map (it renders every site), so\n * `countUnreplayedDeadLettersBySlug` stays. The site page did not: it built the\n * fleet-wide map and read one key out of it.\n *\n * Needs `idx_deadletter_slug_unreplayed` (0028) to be a per-slug read rather\n * than a per-slug filter. Without it SQLite serves the predicate from\n * `idx_deadletter_unreplayed (replayed_at)` and visits every unreplayed row in\n * the fleet, then a rowid lookup into the payload-bearing table for each — the\n * exact cost the grouped version's docblock says it exists to avoid. The plan\n * still reads `SEARCH`, which is why the query-plan gate cannot see the\n * difference and the index has to be a deliberate choice. */\nexport async function countUnreplayedDeadLettersForSlug(db: Db, slug: string): Promise<number> {\n const row = await db\n .selectFrom(\"submission_deadletter\")\n .select((eb) => eb.fn.countAll<number>().as(\"n\"))\n .where(\"site_slug\", \"=\", slug)\n .where(\"replayed_at\", \"is\", null)\n .where(\"abandoned_at\", \"is\", null)\n .executeTakeFirst();\n return Number(row?.n) || 0;\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,6 +1,6 @@
1
1
  import {
2
2
  withRecipe
3
- } from "./chunk-SQ4FSNYL.js";
3
+ } from "./chunk-LUF52EIY.js";
4
4
  import {
5
5
  defaultSpawn
6
6
  } from "./chunk-LBYOLBW7.js";
@@ -76,4 +76,4 @@ async function bumpDeps(site, opts = {}) {
76
76
  export {
77
77
  bumpDeps
78
78
  };
79
- //# sourceMappingURL=chunk-VEIIUCYL.js.map
79
+ //# sourceMappingURL=chunk-TV76A6GN.js.map
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  REPORT_LIST_COLUMNS
3
- } from "./chunk-GEUC5VGM.js";
3
+ } from "./chunk-BINFZQ2T.js";
4
4
  import {
5
5
  mapReportRecord,
6
6
  mapWebsiteRecord
7
- } from "./chunk-UIITN37J.js";
7
+ } from "./chunk-LF2TJN4X.js";
8
8
 
9
9
  // src/db/parity.ts
10
10
  var SKIP_COLUMNS = /* @__PURE__ */ new Set([
@@ -129,4 +129,4 @@ export {
129
129
  checkFleetParity,
130
130
  formatParityResult
131
131
  };
132
- //# sourceMappingURL=chunk-D3CTOCUR.js.map
132
+ //# sourceMappingURL=chunk-TVZ6QCOE.js.map
@@ -0,0 +1,69 @@
1
+ import {
2
+ draftFields,
3
+ setDraftReady
4
+ } from "./chunk-TEMQPSCM.js";
5
+ import {
6
+ isPendingApproval
7
+ } from "./chunk-ASLDCWBF.js";
8
+ import {
9
+ encodeUlid,
10
+ skipsAirtableShadow
11
+ } from "./chunk-RQJFGK3A.js";
12
+
13
+ // src/reports/queue.ts
14
+ var REPORT_TIER = {
15
+ Maintenance: 1,
16
+ Testing: 2,
17
+ Announcement: 3,
18
+ Launch: 3
19
+ };
20
+ function reportTier(type) {
21
+ return REPORT_TIER[type];
22
+ }
23
+ async function queueDraft(base, report, mirror) {
24
+ const setReady = async (id, ready) => {
25
+ await setDraftReady(base, id, ready);
26
+ await mirror.patch(id, { draft_ready: ready ? 1 : 0 });
27
+ };
28
+ const newTier = reportTier(report.reportType);
29
+ const others = (await mirror.forSite(report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
30
+ const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
31
+ if (blocker) {
32
+ await setReady(report.id, false);
33
+ return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
34
+ }
35
+ const supersededIds = [];
36
+ for (const r of others) {
37
+ await setReady(r.id, false);
38
+ supersededIds.push(r.id);
39
+ }
40
+ await setReady(report.id, true);
41
+ return { queued: true, supersededIds };
42
+ }
43
+
44
+ // src/fleet/report-id.ts
45
+ function mintReportId(now = Date.now()) {
46
+ const random = new Uint8Array(10);
47
+ globalThis.crypto.getRandomValues(random);
48
+ return `report_${encodeUlid(now, random)}`;
49
+ }
50
+
51
+ // src/reports/create-report.ts
52
+ async function createReportDraft(input, deps) {
53
+ const id = (deps.mintId ?? mintReportId)();
54
+ const row = await deps.create({ id, fields: draftFields(input) });
55
+ skipsAirtableShadow("createReportDraft", id);
56
+ return row;
57
+ }
58
+ async function findReportForPeriod(store, siteId, reportType, period) {
59
+ const rows = await store.forSite(siteId);
60
+ return rows.find((r) => r.reportType === reportType && r.period === period) ?? null;
61
+ }
62
+
63
+ export {
64
+ createReportDraft,
65
+ findReportForPeriod,
66
+ reportTier,
67
+ queueDraft
68
+ };
69
+ //# sourceMappingURL=chunk-UPE6D4AT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/queue.ts","../src/fleet/report-id.ts","../src/reports/create-report.ts"],"sourcesContent":["import type { ReportType } from \"./types.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { isPendingApproval, setDraftReady } from \"./airtable/reports.js\";\nimport type { ReportMirror } from \"./report-mirror.js\";\n\n/**\n * Report tiers — a higher tier is a SUPERSET of the lower ones, so only the highest-tier\n * un-sent draft is worth keeping in a site's approve queue. Maintenance ⊂ Testing ⊂\n * {Announcement, Launch}. Announcement and Launch share tier 3 (neither supersets the other).\n */\nexport const REPORT_TIER: Record<ReportType, number> = {\n Maintenance: 1,\n Testing: 2,\n Announcement: 3,\n Launch: 3,\n};\n\nexport function reportTier(type: ReportType): number {\n return REPORT_TIER[type];\n}\n\nexport type QueueOutcome = {\n /** Whether `report` was placed in (kept in) the approve queue. */\n queued: boolean;\n /** When `queued` is false, the queued report type that subsumes this one. */\n blockedBy?: ReportType;\n /** Ids of lower-tier queued reports that this one superseded (un-queued). */\n supersededIds: string[];\n};\n\n/**\n * Enforce \"one report queued per site, highest tier wins\" at draft time. Given a freshly\n * created/reused report, look at the OTHER reports already pending approval for the same site\n * (Draft ready ∧ ¬Approved ∧ ¬Sent) and:\n *\n * - If any is of an EQUAL-OR-HIGHER tier → the new report is subsumed: leave it un-queued\n * (Draft ready = false) and report `blockedBy`. (Equal-tier, e.g. a queued Launch when an\n * Announcement is drafted, keeps the existing one rather than silently replacing it.)\n * - Otherwise the new report is strictly the highest → un-queue every (strictly lower) pending\n * report for the site (superseded, not deleted — the row is kept) and queue the new one.\n *\n * Returns what happened so the caller can surface it. PURE side effects are all `setDraftReady`.\n *\n * #539 Phase 5: each flag is mirrored into Turso. The SUPERSEDED rows matter as\n * much as the new one — un-queueing them is the whole point of this function, so\n * a mirror covering only `report.id` would leave the console showing a site with\n * two queued reports until the next hourly sync.\n *\n * #646 step 4: the site's other reports are READ from Turso (`mirror.forSite`),\n * which is why `mirror` is no longer optional. The Airtable read this replaces\n * could not see a report drafted for a `site_<ULID>` site — and an empty answer\n * does not fail, it silently queues a second report for a site that already had\n * one. The Airtable `setDraftReady` stays as the shadow; it skips a report id\n * Airtable cannot hold.\n */\nexport async function queueDraft(\n base: AirtableBase,\n report: { id: string; siteId: string; reportType: ReportType },\n mirror: ReportMirror,\n): Promise<QueueOutcome> {\n const setReady = async (id: string, ready: boolean): Promise<void> => {\n await setDraftReady(base, id, ready);\n await mirror.patch(id, { draft_ready: ready ? 1 : 0 });\n };\n const newTier = reportTier(report.reportType);\n const others = (await mirror.forSite(report.siteId))\n .filter(isPendingApproval)\n .filter((r) => r.id !== report.id);\n\n const blocker = others.find((r) => reportTier(r.reportType) >= newTier);\n if (blocker) {\n // A queued report already covers this one. Make sure the new draft is NOT queued (the reuse\n // path may hand us a row that was Draft-ready from a prior run) and stand down.\n await setReady(report.id, false);\n return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };\n }\n\n // The new report is strictly the highest-tier pending — supersede the rest (un-queue), keep it.\n const supersededIds: string[] = [];\n for (const r of others) {\n await setReady(r.id, false);\n supersededIds.push(r.id);\n }\n await setReady(report.id, true);\n return { queued: true, supersededIds };\n}\n","/**\n * Report ids after Airtable (#646 step 4, operator decision 2026-09-17).\n *\n * The site decision (`site-id.ts`), applied to the other table Airtable used to\n * mint for us. Two shapes coexist PERMANENTLY:\n *\n * - `rec…` every report drafted before this. Airtable's record id\n * became the Turso primary key at import (design D1) and is\n * never rewritten — `reports.id` is what the approve route,\n * the preview url, the re-render and the send all address.\n * - `report_<ULID>` every report drafted since, minted here.\n *\n * The ULID encoding itself is NOT duplicated: `encodeUlid` is the one in\n * `site-id.ts`, which carries the reasoning for implementing it locally. This\n * module is the report half of the same decision, kept separate only so the two\n * prefixes cannot be confused at a call site.\n *\n * Unlike a site, a report has no slug: nothing outside the database addresses a\n * report by anything but this id, so the id IS the external handle (the preview\n * link, the approve POST, the `--rerender` argument). That is why the three\n * Netlify routes validate its shape, and why {@link isReportId} exists — the\n * routes must accept both shapes, and a shape check that only knew `rec` would\n * 404 every report drafted from now on.\n */\nimport { encodeUlid } from \"./site-id.js\";\n\n/** Mint a new report id: `report_<ULID>`. Time-sortable, like the site ids. */\nexport function mintReportId(now: number = Date.now()): string {\n const random = new Uint8Array(10);\n globalThis.crypto.getRandomValues(random);\n return `report_${encodeUlid(now, random)}`;\n}\n\n/** A report id minted by {@link mintReportId}. */\nexport function isMintedReportId(id: string): boolean {\n return /^report_[0-9A-HJKMNP-TV-Z]{26}$/.test(id);\n}\n\n/**\n * Either shape a report id can legitimately take — the predicate the request\n * routes validate with.\n *\n * Deliberately NOT a \"looks roughly like an id\" test: these values come off a\n * query string, so the route answers 404 for anything that is not one of the two\n * minters' output shapes. The `rec` half is the routes' own `/^rec[A-Za-z0-9]+$/`\n * kept EXACTLY as it was — widening it here would quietly relax three request\n * paths that this change is only supposed to teach a second shape. That is also\n * why it is stricter than `isAirtableRecordId`, which is the SHADOW-WRITE\n * question (\"could Airtable hold this row at all\") and answers it for readable\n * test fixtures like `rec_report_1` too.\n */\nexport function isReportId(id: string): boolean {\n return /^rec[A-Za-z0-9]+$/.test(id) || isMintedReportId(id);\n}\n","/**\n * Creating a report, Turso-native (#539 Phase 6 step 4, #646; operator decision\n * 2026-09-17).\n *\n * Until now Airtable MINTED the report id: `createDraft` posted to the Reports\n * table and whatever record id came back became `reports.id` in Turso. That is why\n * a `site_<ULID>` site could not receive a report at all — Airtable has no\n * Websites record for the `Site` link to point at, so `createDraft` refused the\n * site by name (#646 step 3). Turso mints `report_<ULID>` here instead and owns\n * the row, exactly as `ensure-site` does for sites.\n *\n * ## The Airtable shadow for a NEW report: none\n *\n * Airtable cannot be told what record id to use. A shadow record would therefore\n * carry an id that is NOT the report id, which every later write addresses — the\n * approve stamp, the queue flag, `Sent at`, the delivery status — so the shadow\n * would be unreachable from the moment it was written and would drift on its\n * first update. Every Airtable report writer now skips a non-`rec` id with the\n * same `AIRTABLE_SHADOW skipped=non-rec-id` line the site writers log, and a\n * minted report id is never a `rec` id, so the create skips too. This function\n * logs that skip rather than staying silent about it, the same way\n * `ensureSite` does for a new site.\n *\n * Pre-existing `rec…` reports are untouched: both shapes coexist permanently, and\n * a `rec` report's Airtable row still receives its shadow writes until step 6.\n */\nimport { draftFields, type DraftInput } from \"./draft-fields.js\";\nimport { mintReportId } from \"../fleet/report-id.js\";\nimport { skipsAirtableShadow } from \"../fleet/site-id.js\";\nimport type { ReportRow } from \"./report-row.js\";\nimport type { ReportType } from \"./types.js\";\n\n/** The one Turso operation creating a report needs: insert the row and hand back\n * what was STORED. Implemented by the report writer (`makeReportMirror().create`),\n * which owns the db handle and the freeze's error semantics. Reading the row back\n * rather than mapping the input is the same rule the create mirror already\n * followed: the caller gets what the store holds, so a coercion can never\n * diverge the returned row from the persisted one. */\nexport type ReportInserter = (rec: {\n id: string;\n fields: Record<string, unknown>;\n}) => Promise<ReportRow>;\n\nexport type CreateReportDeps = {\n create: ReportInserter;\n /** Injectable for tests; defaults to {@link mintReportId}. */\n mintId?: () => string;\n};\n\n/** Mint a report id, write the row to Turso, and return the stored row. */\nexport async function createReportDraft(\n input: DraftInput,\n deps: CreateReportDeps,\n): Promise<ReportRow> {\n const id = (deps.mintId ?? mintReportId)();\n const row = await deps.create({ id, fields: draftFields(input) });\n // Logged, not attempted — see \"The Airtable shadow for a NEW report\" above.\n skipsAirtableShadow(\"createReportDraft\", id);\n return row;\n}\n\n/**\n * The `(site, type, period)` idempotency lookup behind search-before-create\n * drafting — the re-run dedupe the `launch` and `announce` recipes make before\n * they draft.\n *\n * It replaces the Airtable `findReportByPeriod`, whose whole design was a\n * workaround: Airtable's formula layer renders a linked-record field as the\n * linked rows' primary-field NAMES, so no formula could filter by site id and\n * the site had to be matched client-side after fetching every row of the\n * (type, period). Here the site scope IS the query (`forSite`, served by\n * idx_reports_site) and the triple is matched in memory over one site's handful\n * of reports.\n */\nexport async function findReportForPeriod(\n store: { forSite: (siteId: string) => Promise<ReportRow[]> },\n siteId: string,\n reportType: ReportType,\n period: string,\n): Promise<ReportRow | null> {\n const rows = await store.forSite(siteId);\n return rows.find((r) => r.reportType === reportType && r.period === period) ?? null;\n}\n"],"mappings":";;;;;;;;;;;;;AAUO,IAAM,cAA0C;AAAA,EACrD,aAAa;AAAA,EACb,SAAS;AAAA,EACT,cAAc;AAAA,EACd,QAAQ;AACV;AAEO,SAAS,WAAW,MAA0B;AACnD,SAAO,YAAY,IAAI;AACzB;AAoCA,eAAsB,WACpB,MACA,QACA,QACuB;AACvB,QAAM,WAAW,OAAO,IAAY,UAAkC;AACpE,UAAM,cAAc,MAAM,IAAI,KAAK;AACnC,UAAM,OAAO,MAAM,IAAI,EAAE,aAAa,QAAQ,IAAI,EAAE,CAAC;AAAA,EACvD;AACA,QAAM,UAAU,WAAW,OAAO,UAAU;AAC5C,QAAM,UAAU,MAAM,OAAO,QAAQ,OAAO,MAAM,GAC/C,OAAO,iBAAiB,EACxB,OAAO,CAAC,MAAM,EAAE,OAAO,OAAO,EAAE;AAEnC,QAAM,UAAU,OAAO,KAAK,CAAC,MAAM,WAAW,EAAE,UAAU,KAAK,OAAO;AACtE,MAAI,SAAS;AAGX,UAAM,SAAS,OAAO,IAAI,KAAK;AAC/B,WAAO,EAAE,QAAQ,OAAO,WAAW,QAAQ,YAAY,eAAe,CAAC,EAAE;AAAA,EAC3E;AAGA,QAAM,gBAA0B,CAAC;AACjC,aAAW,KAAK,QAAQ;AACtB,UAAM,SAAS,EAAE,IAAI,KAAK;AAC1B,kBAAc,KAAK,EAAE,EAAE;AAAA,EACzB;AACA,QAAM,SAAS,OAAO,IAAI,IAAI;AAC9B,SAAO,EAAE,QAAQ,MAAM,cAAc;AACvC;;;AC1DO,SAAS,aAAa,MAAc,KAAK,IAAI,GAAW;AAC7D,QAAM,SAAS,IAAI,WAAW,EAAE;AAChC,aAAW,OAAO,gBAAgB,MAAM;AACxC,SAAO,UAAU,WAAW,KAAK,MAAM,CAAC;AAC1C;;;ACmBA,eAAsB,kBACpB,OACA,MACoB;AACpB,QAAM,MAAM,KAAK,UAAU,cAAc;AACzC,QAAM,MAAM,MAAM,KAAK,OAAO,EAAE,IAAI,QAAQ,YAAY,KAAK,EAAE,CAAC;AAEhE,sBAAoB,qBAAqB,EAAE;AAC3C,SAAO;AACT;AAeA,eAAsB,oBACpB,OACA,QACA,YACA,QAC2B;AAC3B,QAAM,OAAO,MAAM,MAAM,QAAQ,MAAM;AACvC,SAAO,KAAK,KAAK,CAAC,MAAM,EAAE,eAAe,cAAc,EAAE,WAAW,MAAM,KAAK;AACjF;","names":[]}
@@ -50,6 +50,35 @@ async function readSiteConfig(sitePath) {
50
50
  if (gate === "dev" || gate === "preview") out.gateServer = gate;
51
51
  return out;
52
52
  }
53
+ var PLACEHOLDER_PRISMIC_REPO = "your-prismic-repo-name";
54
+ var PRISMIC_CONFIG_FILES = ["slicemachine.config.json", "prismic.config.json"];
55
+ async function readsPlaceholderPrismicRepo(sitePath) {
56
+ let sawPlaceholder = false;
57
+ for (const name of PRISMIC_CONFIG_FILES) {
58
+ let raw;
59
+ try {
60
+ raw = await readFile(join(sitePath, name), "utf-8");
61
+ } catch {
62
+ continue;
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
71
+ const repo = parsed.repositoryName;
72
+ if (typeof repo !== "string") continue;
73
+ const trimmed = repo.trim();
74
+ if (trimmed === PLACEHOLDER_PRISMIC_REPO) {
75
+ sawPlaceholder = true;
76
+ continue;
77
+ }
78
+ if (trimmed.length > 0) return false;
79
+ }
80
+ return sawPlaceholder;
81
+ }
53
82
 
54
83
  // src/audits/lighthouse.ts
55
84
  async function readJsonMaybe(path) {
@@ -240,6 +269,7 @@ async function lighthouseAudit(ctx) {
240
269
 
241
270
  export {
242
271
  readSiteConfig,
272
+ readsPlaceholderPrismicRepo,
243
273
  lighthouseAudit
244
274
  };
245
- //# sourceMappingURL=chunk-BIYQN7FH.js.map
275
+ //# sourceMappingURL=chunk-VCPTOZ56.js.map