@reddoorla/maintenance 0.95.1 → 0.97.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 (316) hide show
  1. package/README.md +41 -7
  2. package/dist/{announce-CLQYX5I4.js → announce-5ZD2SBGD.js} +39 -31
  3. package/dist/announce-5ZD2SBGD.js.map +1 -0
  4. package/dist/attachments-JFGFQRXQ.js +10 -0
  5. package/dist/{blux-4KNXUP2K.js → blux-NXQLMS4W.js} +1 -1
  6. package/dist/{bump-deps-C5G2EFBB.js → bump-deps-775VLDYB.js} +6 -6
  7. package/dist/{chunk-FUWFLAWQ.js → chunk-3RLDX3Q2.js} +5 -5
  8. package/dist/{chunk-XHHMM5HX.js → chunk-43SW3FGW.js} +63 -1
  9. package/dist/chunk-43SW3FGW.js.map +1 -0
  10. package/dist/{chunk-R53VL3RI.js → chunk-5CJVUPJU.js} +1 -1
  11. package/dist/{chunk-R53VL3RI.js.map → chunk-5CJVUPJU.js.map} +1 -1
  12. package/dist/{chunk-S7RYFUIK.js → chunk-6PGXKN6F.js} +2 -2
  13. package/dist/{chunk-HTABUCF4.js → chunk-6THEZQLA.js} +3 -3
  14. package/dist/chunk-A5QKNOXW.js +72 -0
  15. package/dist/chunk-A5QKNOXW.js.map +1 -0
  16. package/dist/chunk-ASLDCWBF.js +41 -0
  17. package/dist/chunk-ASLDCWBF.js.map +1 -0
  18. package/dist/{chunk-CWXTNPNZ.js → chunk-AYDQQBNG.js} +3 -3
  19. package/dist/chunk-AYDQQBNG.js.map +1 -0
  20. package/dist/{chunk-WVNDEYXL.js → chunk-BINFZQ2T.js} +57 -6
  21. package/dist/chunk-BINFZQ2T.js.map +1 -0
  22. package/dist/{chunk-2NQUCS7O.js → chunk-BIYQN7FH.js} +3 -1
  23. package/dist/{chunk-2NQUCS7O.js.map → chunk-BIYQN7FH.js.map} +1 -1
  24. package/dist/{chunk-YLYXW5PH.js → chunk-CXICCLNM.js} +34 -4
  25. package/dist/chunk-CXICCLNM.js.map +1 -0
  26. package/dist/{chunk-BYGPUUCT.js → chunk-DRNZVORX.js} +66 -30
  27. package/dist/chunk-DRNZVORX.js.map +1 -0
  28. package/dist/{chunk-LM2NHRCJ.js → chunk-DYTYPKFN.js} +4 -4
  29. package/dist/chunk-DYTYPKFN.js.map +1 -0
  30. package/dist/{chunk-DFKBTBO3.js → chunk-EN6CTBUN.js} +35 -6
  31. package/dist/chunk-EN6CTBUN.js.map +1 -0
  32. package/dist/{chunk-5EEMTJMT.js → chunk-FHFXJ3EM.js} +8 -6
  33. package/dist/chunk-FHFXJ3EM.js.map +1 -0
  34. package/dist/{chunk-Z2K4JPQ3.js → chunk-FP6HOW6J.js} +10 -7
  35. package/dist/chunk-FP6HOW6J.js.map +1 -0
  36. package/dist/{chunk-2LYOAB2E.js → chunk-HSMN35LU.js} +2 -2
  37. package/dist/chunk-ICSRWW5Q.js +148 -0
  38. package/dist/chunk-ICSRWW5Q.js.map +1 -0
  39. package/dist/{chunk-FSGICFXB.js → chunk-IDBPII6G.js} +17 -5
  40. package/dist/chunk-IDBPII6G.js.map +1 -0
  41. package/dist/{chunk-C3IQQKXL.js → chunk-IOTOOTII.js} +121 -1
  42. package/dist/{chunk-C3IQQKXL.js.map → chunk-IOTOOTII.js.map} +1 -1
  43. package/dist/{chunk-BCKEP5S6.js → chunk-JHJNNJLG.js} +2 -2
  44. package/dist/{chunk-TO7DJYLZ.js → chunk-L3S3QZXL.js} +178 -39
  45. package/dist/chunk-L3S3QZXL.js.map +1 -0
  46. package/dist/{chunk-WSJ7RO54.js → chunk-LAG6IP7Y.js} +23 -5
  47. package/dist/chunk-LAG6IP7Y.js.map +1 -0
  48. package/dist/{chunk-WJMPCVOR.js → chunk-LAJ3FF54.js} +12 -4
  49. package/dist/chunk-LAJ3FF54.js.map +1 -0
  50. package/dist/{chunk-2ZAZ7ETT.js → chunk-LDHRIQUW.js} +32 -18
  51. package/dist/chunk-LDHRIQUW.js.map +1 -0
  52. package/dist/{chunk-6R56OKJZ.js → chunk-LF2TJN4X.js} +2 -2
  53. package/dist/chunk-LF2TJN4X.js.map +1 -0
  54. package/dist/{chunk-ZQV7UQNE.js → chunk-LUF52EIY.js} +2 -2
  55. package/dist/{chunk-5UINJNAJ.js → chunk-LZMAG73U.js} +34 -21
  56. package/dist/chunk-LZMAG73U.js.map +1 -0
  57. package/dist/{chunk-VSGMCCGQ.js → chunk-MMSMZC7S.js} +2 -2
  58. package/dist/chunk-MVKNU4QD.js +37 -0
  59. package/dist/chunk-MVKNU4QD.js.map +1 -0
  60. package/dist/{chunk-XPJQXSC4.js → chunk-P7323YVW.js} +27 -5
  61. package/dist/chunk-P7323YVW.js.map +1 -0
  62. package/dist/{chunk-KJRVMMM3.js → chunk-PARKXDXI.js} +36 -5
  63. package/dist/chunk-PARKXDXI.js.map +1 -0
  64. package/dist/{chunk-4763KQ6O.js → chunk-PXRN2MER.js} +2 -2
  65. package/dist/{chunk-WMBLR5VA.js → chunk-PZZ6HM5T.js} +3 -3
  66. package/dist/{chunk-TR3HFH6J.js → chunk-QFSL2L2B.js} +67 -10
  67. package/dist/chunk-QFSL2L2B.js.map +1 -0
  68. package/dist/chunk-RQJFGK3A.js +51 -0
  69. package/dist/chunk-RQJFGK3A.js.map +1 -0
  70. package/dist/{chunk-3Z6X3BHU.js → chunk-TC4GANMX.js} +53 -150
  71. package/dist/chunk-TC4GANMX.js.map +1 -0
  72. package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
  73. package/dist/chunk-TEMQPSCM.js.map +1 -0
  74. package/dist/chunk-TJLKWQOX.js +52 -0
  75. package/dist/chunk-TJLKWQOX.js.map +1 -0
  76. package/dist/{chunk-CJ2RMTT7.js → chunk-TV76A6GN.js} +2 -2
  77. package/dist/{chunk-KZRR4NFG.js → chunk-TVZ6QCOE.js} +3 -3
  78. package/dist/{chunk-BVEDKMNG.js → chunk-U2ZI6G7H.js} +29 -11
  79. package/dist/chunk-U2ZI6G7H.js.map +1 -0
  80. package/dist/chunk-UPE6D4AT.js +69 -0
  81. package/dist/chunk-UPE6D4AT.js.map +1 -0
  82. package/dist/{chunk-R5IMFAFR.js → chunk-VMGHQUTV.js} +7 -16
  83. package/dist/chunk-VMGHQUTV.js.map +1 -0
  84. package/dist/chunk-VQMAAQ5M.js +53 -0
  85. package/dist/chunk-VQMAAQ5M.js.map +1 -0
  86. package/dist/{chunk-AVEQUR44.js → chunk-WMF5S2MB.js} +60 -17
  87. package/dist/chunk-WMF5S2MB.js.map +1 -0
  88. package/dist/{chunk-SVAAONYB.js → chunk-WQLNUEZL.js} +12 -2
  89. package/dist/{chunk-SVAAONYB.js.map → chunk-WQLNUEZL.js.map} +1 -1
  90. package/dist/{chunk-XN37VTDB.js → chunk-X56LAKPR.js} +3 -3
  91. package/dist/chunk-X56LAKPR.js.map +1 -0
  92. package/dist/{chunk-ZCL7C5LH.js → chunk-Y6FCXRIC.js} +1661 -387
  93. package/dist/chunk-Y6FCXRIC.js.map +1 -0
  94. package/dist/{chunk-2SLED3NB.js → chunk-YDZ7XGPZ.js} +1 -1
  95. package/dist/{chunk-2SLED3NB.js.map → chunk-YDZ7XGPZ.js.map} +1 -1
  96. package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
  97. package/dist/chunk-YJBSB4LP.js.map +1 -0
  98. package/dist/{chunk-BI2VI6EM.js → chunk-YXWBM26P.js} +2 -2
  99. package/dist/{chunk-QF6IQQFP.js → chunk-Z7FDJ35L.js} +18 -12
  100. package/dist/chunk-Z7FDJ35L.js.map +1 -0
  101. package/dist/cli/bin.js +111 -51
  102. package/dist/cli/bin.js.map +1 -1
  103. package/dist/cli/commands/audit.d.ts +33 -3
  104. package/dist/cli/commands/audit.js +28 -25
  105. package/dist/cli/commands/audit.js.map +1 -1
  106. package/dist/client-DGTO65WB.js +11 -0
  107. package/dist/configs/playwright-a11y.d.ts +11 -1
  108. package/dist/configs/playwright-a11y.js +3 -1
  109. package/dist/{convert-to-pnpm-YTFREUJK.js → convert-to-pnpm-KLWA53N5.js} +6 -6
  110. package/dist/{db-XKZYDCKM.js → db-II5WAK4D.js} +45 -20
  111. package/dist/db-II5WAK4D.js.map +1 -0
  112. package/dist/deadletter-G4M3HSVM.js +17 -0
  113. package/dist/{digest-7UBGUBRK.js → digest-CWKK2DMR.js} +56 -32
  114. package/dist/digest-CWKK2DMR.js.map +1 -0
  115. package/dist/{digest-collectors-DJFN5ZDK.js → digest-collectors-CYTN2EUX.js} +17 -12
  116. package/dist/{digest-state-3KHZF7GD.js → digest-state-ZDPKR7MW.js} +18 -2
  117. package/dist/digest-state-ZDPKR7MW.js.map +1 -0
  118. package/dist/ensure-site-2QZHFVQ3.js +70 -0
  119. package/dist/ensure-site-2QZHFVQ3.js.map +1 -0
  120. package/dist/ensure-site-JEKV2ODE.js +105 -0
  121. package/dist/ensure-site-JEKV2ODE.js.map +1 -0
  122. package/dist/{ensure-site-OAAE5YE2.js → ensure-site-OO4UPWNQ.js} +49 -44
  123. package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
  124. package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
  125. package/dist/{fleet-state-UEVZ3X3J.js → fleet-state-RJROYXKO.js} +19 -7
  126. package/dist/{forms-notify-target-UC6PF3AE.js → forms-notify-target-X6UEOI2M.js} +19 -15
  127. package/dist/forms-notify-target-X6UEOI2M.js.map +1 -0
  128. package/dist/{freeze-A6MUCZU4.js → freeze-GUQ43EAU.js} +2 -2
  129. package/dist/{github-signals-TX5FPYP6.js → github-signals-QIRTHHET.js} +22 -17
  130. package/dist/github-signals-QIRTHHET.js.map +1 -0
  131. package/dist/{header-image-PSPT7CUM.js → header-image-H7ZJAV26.js} +15 -18
  132. package/dist/header-image-H7ZJAV26.js.map +1 -0
  133. package/dist/{health-endpoint-KVCJYWET.js → health-endpoint-BJ6BKRHF.js} +8 -7
  134. package/dist/{health-endpoint-KVCJYWET.js.map → health-endpoint-BJ6BKRHF.js.map} +1 -1
  135. package/dist/{health-mirror-XJAKA3EY.js → health-mirror-KU3G7V2Y.js} +10 -10
  136. package/dist/{import-airtable-TAKSN4NV.js → import-airtable-UBF5GYAL.js} +3 -3
  137. package/dist/index.d.ts +158 -419
  138. package/dist/index.js +91 -46
  139. package/dist/index.js.map +1 -1
  140. package/dist/{init-FFE4FCO2.js → init-GOWDA5QS.js} +19 -18
  141. package/dist/{init-FFE4FCO2.js.map → init-GOWDA5QS.js.map} +1 -1
  142. package/dist/{launch-UD36AHUN.js → launch-7N3X2MXB.js} +229 -79
  143. package/dist/launch-7N3X2MXB.js.map +1 -0
  144. package/dist/{lighthouse-VHI77PFB.js → lighthouse-52WSPZ5D.js} +2 -2
  145. package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
  146. package/dist/match-harness-GBCDSVPS.js +417 -0
  147. package/dist/match-harness-GBCDSVPS.js.map +1 -0
  148. package/dist/migrate-XZ7U5DFC.js +7 -0
  149. package/dist/{notify-IISTHNBR.js → notify-K3WVH3DM.js} +4 -4
  150. package/dist/{onboard-TM2KESGF.js → onboard-JQQIMQW7.js} +6 -6
  151. package/dist/{orchestrate-HYVQPRCE.js → orchestrate-L2A5BZFY.js} +13 -10
  152. package/dist/parity-2JGVUCZD.js +14 -0
  153. package/dist/{pipeline-YROAY6DT.js → pipeline-UUPXOS2J.js} +3 -3
  154. package/dist/{preflight-RYIP54NY.js → preflight-O7Q6FWHU.js} +35 -11
  155. package/dist/preflight-O7Q6FWHU.js.map +1 -0
  156. package/dist/{prismic-ci-6GKKZGB5.js → prismic-ci-NAPWOXXD.js} +39 -9
  157. package/dist/prismic-ci-NAPWOXXD.js.map +1 -0
  158. package/dist/{prismic-models-P6FWWVRS.js → prismic-models-LL6ORTQ2.js} +20 -17
  159. package/dist/prismic-models-LL6ORTQ2.js.map +1 -0
  160. package/dist/prospect/types.d.ts +72 -0
  161. package/dist/prospect/types.js +1 -1
  162. package/dist/{prospect-audit-PRHU53HS.js → prospect-audit-FMPGQIRQ.js} +16 -7
  163. package/dist/prospect-audit-FMPGQIRQ.js.map +1 -0
  164. package/dist/{prospect-audits-R3LWF6U7.js → prospect-audits-IZVQY3OQ.js} +4 -2
  165. package/dist/protection-audit-YXJKFU6O.js +413 -0
  166. package/dist/protection-audit-YXJKFU6O.js.map +1 -0
  167. package/dist/recipes/sync-configs.js +3 -3
  168. package/dist/{render-YGNGTX3W.js → render-MHUUHPLN.js} +36 -9
  169. package/dist/render-MHUUHPLN.js.map +1 -0
  170. package/dist/{render-from-row-IFSBHWDH.js → render-from-row-75JTED2Z.js} +6 -4
  171. package/dist/{renovate-dispatch-U5Q52SKT.js → renovate-dispatch-2IHAZDVA.js} +14 -9
  172. package/dist/{renovate-dispatch-U5Q52SKT.js.map → renovate-dispatch-2IHAZDVA.js.map} +1 -1
  173. package/dist/{replay-PCSQOGCZ.js → replay-4FVFOMHH.js} +10 -23
  174. package/dist/replay-4FVFOMHH.js.map +1 -0
  175. package/dist/{report-LQS4ECTM.js → report-IAHXN5CW.js} +103 -57
  176. package/dist/report-IAHXN5CW.js.map +1 -0
  177. package/dist/{report-mirror-HDTOJE64.js → report-mirror-4QM57LMS.js} +34 -12
  178. package/dist/report-mirror-4QM57LMS.js.map +1 -0
  179. package/dist/roster-GDNSIRAK.js +19 -0
  180. package/dist/roster-GDNSIRAK.js.map +1 -0
  181. package/dist/{schema-A4WCPIF4.js → schema-KWUQEZ5O.js} +1 -1
  182. package/dist/schema-KWUQEZ5O.js.map +1 -0
  183. package/dist/{self-updating-MWXIZLL7.js → self-updating-ZUWPGYUP.js} +8 -7
  184. package/dist/{self-updating-MWXIZLL7.js.map → self-updating-ZUWPGYUP.js.map} +1 -1
  185. package/dist/{selftest-VXTHRPRN.js → selftest-G4W3LIBQ.js} +21 -17
  186. package/dist/{selftest-VXTHRPRN.js.map → selftest-G4W3LIBQ.js.map} +1 -1
  187. package/dist/site-create-ZU3ACBTO.js +45 -0
  188. package/dist/site-create-ZU3ACBTO.js.map +1 -0
  189. package/dist/site-lookup-DTOLJ4EA.js +27 -0
  190. package/dist/site-lookup-DTOLJ4EA.js.map +1 -0
  191. package/dist/{site-mirror-WON4FTJ2.js → site-mirror-WR7ACXOV.js} +26 -12
  192. package/dist/site-mirror-WR7ACXOV.js.map +1 -0
  193. package/dist/{smoke-suite-7DGITCTZ.js → smoke-suite-WAUPI6EJ.js} +8 -7
  194. package/dist/{smoke-suite-7DGITCTZ.js.map → smoke-suite-WAUPI6EJ.js.map} +1 -1
  195. package/dist/{submissions-OVZPVBYG.js → submissions-2SHCFOKD.js} +12 -7
  196. package/dist/submissions-2SHCFOKD.js.map +1 -0
  197. package/dist/{submissions-3I3GAQAX.js → submissions-JDZP35JM.js} +49 -9
  198. package/dist/submissions-JDZP35JM.js.map +1 -0
  199. package/dist/{svelte-codemods-WSV2FGNG.js → svelte-codemods-VOLOKKFI.js} +6 -6
  200. package/dist/{sync-K3PKHVLH.js → sync-F4USL4K5.js} +6 -6
  201. package/dist/{sync-configs-RRIHNLIJ.js → sync-configs-OXSWKVZ2.js} +6 -6
  202. package/dist/turso-KQKBBQCK.js +24 -0
  203. package/dist/turso-KQKBBQCK.js.map +1 -0
  204. package/dist/{upgrade-VTD6V4HS.js → upgrade-EVAL3JTO.js} +6 -6
  205. package/dist/util/git.d.ts +49 -10
  206. package/dist/util/git.js +1 -1
  207. package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
  208. package/dist/{websites-TB4TCAWM.js → websites-Y6FN2XFD.js} +27 -20
  209. package/dist/{write-audits-to-airtable-53EVXKMU.js → write-audits-to-airtable-TI3JMWSY.js} +7 -5
  210. package/package.json +3 -2
  211. package/dist/airtable-JGCPS7JH.js +0 -9
  212. package/dist/announce-CLQYX5I4.js.map +0 -1
  213. package/dist/attachments-NLZIJ5NU.js +0 -9
  214. package/dist/chunk-2ZAZ7ETT.js.map +0 -1
  215. package/dist/chunk-3Z6X3BHU.js.map +0 -1
  216. package/dist/chunk-5EEMTJMT.js.map +0 -1
  217. package/dist/chunk-5GUJ36TL.js +0 -53
  218. package/dist/chunk-5GUJ36TL.js.map +0 -1
  219. package/dist/chunk-5UINJNAJ.js.map +0 -1
  220. package/dist/chunk-6R56OKJZ.js.map +0 -1
  221. package/dist/chunk-AVEQUR44.js.map +0 -1
  222. package/dist/chunk-BVEDKMNG.js.map +0 -1
  223. package/dist/chunk-BYGPUUCT.js.map +0 -1
  224. package/dist/chunk-CWXTNPNZ.js.map +0 -1
  225. package/dist/chunk-DFKBTBO3.js.map +0 -1
  226. package/dist/chunk-F3JWOPTG.js.map +0 -1
  227. package/dist/chunk-FSGICFXB.js.map +0 -1
  228. package/dist/chunk-GASBX52O.js +0 -12
  229. package/dist/chunk-GASBX52O.js.map +0 -1
  230. package/dist/chunk-IY6256GK.js.map +0 -1
  231. package/dist/chunk-KJRVMMM3.js.map +0 -1
  232. package/dist/chunk-LM2NHRCJ.js.map +0 -1
  233. package/dist/chunk-QF6IQQFP.js.map +0 -1
  234. package/dist/chunk-R5IMFAFR.js.map +0 -1
  235. package/dist/chunk-TO7DJYLZ.js.map +0 -1
  236. package/dist/chunk-TR3HFH6J.js.map +0 -1
  237. package/dist/chunk-WJMPCVOR.js.map +0 -1
  238. package/dist/chunk-WSJ7RO54.js.map +0 -1
  239. package/dist/chunk-WVNDEYXL.js.map +0 -1
  240. package/dist/chunk-XHHMM5HX.js.map +0 -1
  241. package/dist/chunk-XN37VTDB.js.map +0 -1
  242. package/dist/chunk-XPJQXSC4.js.map +0 -1
  243. package/dist/chunk-YFAXOD7Z.js +0 -42
  244. package/dist/chunk-YFAXOD7Z.js.map +0 -1
  245. package/dist/chunk-YLYXW5PH.js.map +0 -1
  246. package/dist/chunk-Z2K4JPQ3.js.map +0 -1
  247. package/dist/chunk-ZCL7C5LH.js.map +0 -1
  248. package/dist/client-G2WGWPCA.js +0 -11
  249. package/dist/db-XKZYDCKM.js.map +0 -1
  250. package/dist/digest-7UBGUBRK.js.map +0 -1
  251. package/dist/digest-state-3KHZF7GD.js.map +0 -1
  252. package/dist/ensure-site-OAAE5YE2.js.map +0 -1
  253. package/dist/fleet-events-cF2Om88w.d.ts +0 -24
  254. package/dist/forms-notify-target-UC6PF3AE.js.map +0 -1
  255. package/dist/github-signals-TX5FPYP6.js.map +0 -1
  256. package/dist/header-image-PSPT7CUM.js.map +0 -1
  257. package/dist/launch-UD36AHUN.js.map +0 -1
  258. package/dist/lighthouse-airtable-YMH4UA7R.js +0 -12
  259. package/dist/match-harness-QRG6XZAR.js +0 -70
  260. package/dist/match-harness-QRG6XZAR.js.map +0 -1
  261. package/dist/migrate-Y7R4CESL.js +0 -7
  262. package/dist/parity-CAJ477PS.js +0 -14
  263. package/dist/preflight-RYIP54NY.js.map +0 -1
  264. package/dist/prismic-ci-6GKKZGB5.js.map +0 -1
  265. package/dist/prismic-models-P6FWWVRS.js.map +0 -1
  266. package/dist/prospect-audit-PRHU53HS.js.map +0 -1
  267. package/dist/protection-audit-SJO53MEQ.js +0 -168
  268. package/dist/protection-audit-SJO53MEQ.js.map +0 -1
  269. package/dist/render-YGNGTX3W.js.map +0 -1
  270. package/dist/replay-PCSQOGCZ.js.map +0 -1
  271. package/dist/report-LQS4ECTM.js.map +0 -1
  272. package/dist/report-mirror-HDTOJE64.js.map +0 -1
  273. package/dist/schema-A4WCPIF4.js.map +0 -1
  274. package/dist/site-mirror-WON4FTJ2.js.map +0 -1
  275. package/dist/submissions-3I3GAQAX.js.map +0 -1
  276. package/dist/submissions-OVZPVBYG.js.map +0 -1
  277. /package/dist/{airtable-JGCPS7JH.js.map → attachments-JFGFQRXQ.js.map} +0 -0
  278. /package/dist/{blux-4KNXUP2K.js.map → blux-NXQLMS4W.js.map} +0 -0
  279. /package/dist/{bump-deps-C5G2EFBB.js.map → bump-deps-775VLDYB.js.map} +0 -0
  280. /package/dist/{chunk-FUWFLAWQ.js.map → chunk-3RLDX3Q2.js.map} +0 -0
  281. /package/dist/{chunk-S7RYFUIK.js.map → chunk-6PGXKN6F.js.map} +0 -0
  282. /package/dist/{chunk-HTABUCF4.js.map → chunk-6THEZQLA.js.map} +0 -0
  283. /package/dist/{chunk-2LYOAB2E.js.map → chunk-HSMN35LU.js.map} +0 -0
  284. /package/dist/{chunk-BCKEP5S6.js.map → chunk-JHJNNJLG.js.map} +0 -0
  285. /package/dist/{chunk-ZQV7UQNE.js.map → chunk-LUF52EIY.js.map} +0 -0
  286. /package/dist/{chunk-VSGMCCGQ.js.map → chunk-MMSMZC7S.js.map} +0 -0
  287. /package/dist/{chunk-4763KQ6O.js.map → chunk-PXRN2MER.js.map} +0 -0
  288. /package/dist/{chunk-WMBLR5VA.js.map → chunk-PZZ6HM5T.js.map} +0 -0
  289. /package/dist/{chunk-CJ2RMTT7.js.map → chunk-TV76A6GN.js.map} +0 -0
  290. /package/dist/{chunk-KZRR4NFG.js.map → chunk-TVZ6QCOE.js.map} +0 -0
  291. /package/dist/{chunk-BI2VI6EM.js.map → chunk-YXWBM26P.js.map} +0 -0
  292. /package/dist/{attachments-NLZIJ5NU.js.map → client-DGTO65WB.js.map} +0 -0
  293. /package/dist/{convert-to-pnpm-YTFREUJK.js.map → convert-to-pnpm-KLWA53N5.js.map} +0 -0
  294. /package/dist/{client-G2WGWPCA.js.map → deadletter-G4M3HSVM.js.map} +0 -0
  295. /package/dist/{digest-collectors-DJFN5ZDK.js.map → digest-collectors-CYTN2EUX.js.map} +0 -0
  296. /package/dist/{fleet-state-UEVZ3X3J.js.map → fleet-state-RJROYXKO.js.map} +0 -0
  297. /package/dist/{freeze-A6MUCZU4.js.map → freeze-GUQ43EAU.js.map} +0 -0
  298. /package/dist/{health-mirror-XJAKA3EY.js.map → health-mirror-KU3G7V2Y.js.map} +0 -0
  299. /package/dist/{import-airtable-TAKSN4NV.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
  300. /package/dist/{lighthouse-VHI77PFB.js.map → lighthouse-52WSPZ5D.js.map} +0 -0
  301. /package/dist/{lighthouse-airtable-YMH4UA7R.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
  302. /package/dist/{migrate-Y7R4CESL.js.map → migrate-XZ7U5DFC.js.map} +0 -0
  303. /package/dist/{notify-IISTHNBR.js.map → notify-K3WVH3DM.js.map} +0 -0
  304. /package/dist/{onboard-TM2KESGF.js.map → onboard-JQQIMQW7.js.map} +0 -0
  305. /package/dist/{orchestrate-HYVQPRCE.js.map → orchestrate-L2A5BZFY.js.map} +0 -0
  306. /package/dist/{parity-CAJ477PS.js.map → parity-2JGVUCZD.js.map} +0 -0
  307. /package/dist/{pipeline-YROAY6DT.js.map → pipeline-UUPXOS2J.js.map} +0 -0
  308. /package/dist/{prospect-audits-R3LWF6U7.js.map → prospect-audits-IZVQY3OQ.js.map} +0 -0
  309. /package/dist/{render-from-row-IFSBHWDH.js.map → render-from-row-75JTED2Z.js.map} +0 -0
  310. /package/dist/{svelte-codemods-WSV2FGNG.js.map → svelte-codemods-VOLOKKFI.js.map} +0 -0
  311. /package/dist/{sync-K3PKHVLH.js.map → sync-F4USL4K5.js.map} +0 -0
  312. /package/dist/{sync-configs-RRIHNLIJ.js.map → sync-configs-OXSWKVZ2.js.map} +0 -0
  313. /package/dist/{upgrade-VTD6V4HS.js.map → upgrade-EVAL3JTO.js.map} +0 -0
  314. /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
  315. /package/dist/{websites-TB4TCAWM.js.map → websites-Y6FN2XFD.js.map} +0 -0
  316. /package/dist/{write-audits-to-airtable-53EVXKMU.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/digest.ts"],"sourcesContent":["// src/reports/digest.ts\nimport { openBase, readAirtableConfig, type AirtableBase } from \"./airtable/client.js\";\nimport { listAllReports, isPendingApproval } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport type { NotifyBounceCounts } from \"../db/submissions.js\";\nimport { siteSlug, type WebsiteRow } from \"../fleet/site-row.js\";\nimport { defaultResendClient, type ResendClient } from \"./send/resend.js\";\nimport { isIdempotencyConflict } from \"./send/idempotency.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n collectDeadLetterAlerts,\n collectPrismicDriftAlerts,\n NOTIFY_BOUNCE_WINDOW_DAYS,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention } from \"../alerts/digest-state.js\";\nimport {\n writeDigestState as writeAirtableDigestState,\n type DigestSnapshot,\n} from \"../alerts/digest-state.js\";\nimport { escapeHtml as esc } from \"../util/html.js\";\nimport { operatorEmail } from \"../util/operator.js\";\nimport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n// Type-only: erased at compile time, so the kysely/libsql devDependency never loads\n// in a consuming fleet site (the runtime read is the dynamic import in\n// fetchSubmissionsDigestCounts, same rule as fetchNotifyBounceCounts).\nimport type { SiteSubmissionCounts } from \"../db/submissions.js\";\n\n// The attention/digest contract lives in `../alerts/attention.ts` (a dependency-free\n// types module) so the `alerts/*` collectors can depend on it without importing back\n// from this renderer/IO module — see attention.ts for the cycle it breaks. Re-exported\n// here so existing `from \"./digest.js\"` type importers keep resolving.\nexport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n\nconst GREY = \"#757575\";\nconst RED = \"#C00\";\n\n/** Shared anchor style — Gmail does not inherit font-family into <a> tags. */\nconst ANCHOR_STYLE = `color:${RED};font-family:helvetica,sans-serif`;\n\nfunction readySection(items: ReadyItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Ready for your yes</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">Nothing waiting on you.</p>`;\n }\n const rows = items\n .map((it) => {\n const safeUrl = it.dashboardUrl.startsWith(\"https://\") ? it.dashboardUrl : undefined;\n const link = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">review &amp; approve</a>`\n : `review &amp; approve`;\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(it.siteName)}</strong> — ${esc(it.reportType)} (${esc(it.period)})\n — ${link}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${rows}</table>`;\n}\n\nconst SEVERITY_ORDER: Record<AttentionSeverity, number> = { critical: 0, warning: 1 };\n\n/** Render the per-item status badge (\"NEW\"/\"WORSE\"); standing items get nothing. */\nfunction attentionBadge(status?: AttentionStatus): string {\n if (status === \"new\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">NEW</strong> `;\n if (status === \"worse\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">WORSE</strong> `;\n return \"\";\n}\n\nfunction attentionSection(items: AttentionItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Needs attention</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">All clear — nothing needs attention.</p>`;\n }\n\n // Group by siteName, preserving first-seen site order; sort within a site by\n // severity (critical first).\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of items) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const groups = [...bySite.entries()]\n .map(([siteName, siteItems]) => {\n const sorted = [...siteItems].sort(\n (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],\n );\n const rows = sorted\n .map((it) => {\n const safeUrl = it.url?.startsWith(\"https://\") ? it.url : undefined;\n const titleHtml = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">${esc(it.title)}</a>`\n : esc(it.title);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">${attentionBadge(it.status)}${titleHtml}</td>\n </tr>`;\n })\n .join(\"\");\n return `\n <tr>\n <td style=\"color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px\">${esc(siteName)}</td>\n </tr>\n ${rows}`;\n })\n .join(\"\");\n\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${groups}</table>`;\n}\n\n/** The \"Submissions (24h)\" telemetry block: fleet totals + a per-site glance line.\n * Context, not an alarm — absent/null section or all-zero totals render nothing\n * (no-noise), and the section never triggers a send on its own (see runDigest). */\nfunction submissionsSection(s: SubmissionsDigestSection | null | undefined): string {\n if (!s) return \"\";\n if (s.leads === 0 && s.signups === 0 && s.spamAuto === 0) return \"\";\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Submissions (24h)</h2>`;\n // All three terms always render together — omitting a zero term next to a nonzero\n // one reads as \"not measured\" rather than \"none\"; simplest honest form.\n const totals = [\n `${s.leads} new lead${s.leads === 1 ? \"\" : \"s\"}`,\n `${s.signups} newsletter/RSVP signup${s.signups === 1 ? \"\" : \"s\"}`,\n `${s.spamAuto} auto-filtered spam`,\n ].join(\" · \");\n const totalLine = `<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">${totals}</p>`;\n if (s.bySite.length === 0) return `${heading}${totalLine}`;\n const rows = s.bySite\n .map((site) => {\n const parts: string[] = [];\n if (site.leads > 0) parts.push(`${site.leads} lead${site.leads === 1 ? \"\" : \"s\"}`);\n if (site.signups > 0) parts.push(`${site.signups} signup${site.signups === 1 ? \"\" : \"s\"}`);\n if (site.spamAuto > 0) parts.push(`${site.spamAuto} auto-filtered`);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(site.siteName)}</strong> — ${parts.join(\" · \")}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}${totalLine}<table role=\"presentation\" style=\"border-collapse:collapse;margin:8px 0 0\">${rows}</table>`;\n}\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n\n/** UTC \"YYYY-MM-DD\" — the Resend idempotency key suffix, so a same-day cron re-fire dedupes. */\nfunction digestDateKey(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The gate for \"Ready for your yes\": Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n *\n * Implemented as `listAllReports(base).filter(...)` (the authorized deviation from the plan's\n * draft, which pre-dated Slice 1's merged fix): `listAllReports` already calls `mapRow`, which\n * handles `Period` correctly, so no local `rawToReportRow` duplicate is needed. The JS filter\n * does real work — the test fake does NOT evaluate `filterByFormula` — so correctness is\n * test-provable here.\n *\n * Exported: the fleet homepage (Task 3.5b) reuses it for the pending-approval count.\n */\nexport async function listPendingApproval(base: AirtableBase): Promise<ReportRow[]> {\n return (await listAllReports(base)).filter(isPendingApproval);\n}\n\n// ── collectAttention (IO wrapper, sibling to runDigest) ──────────────────────\n\nexport type CollectAttentionDeps = {\n /** Same baseUrl value runDigest threads; used for the /s/<slug> links. */\n baseUrl: string;\n /** The run's Websites rows. Read ONCE by runDigest — from Turso since #646\n * step 4 — and threaded in; collectAttention never opens a store of its own\n * for them. Required since that step: an optional array with a store-reading\n * fallback is how a test silently reaches a live base. */\n websites: WebsiteRow[];\n /** The run's Reports rows, on the same contract as `websites`. */\n reports: ReportRow[];\n /** Clock for the GitHub-signals staleness gate (collectCiAlerts /\n * collectRenovateAlerts skip a >3-day-stale sweep). Defaults to wall-clock;\n * runDigest threads its run-start `today` so a quiet repo's frozen CI/Renovate\n * signal stops badging once its sweep goes stale. */\n now?: Date;\n /** Pre-fetched per-site bounced-lead-notification counts (tests, or a caller with\n * an open db). When omitted, collectAttention reads them from libSQL itself —\n * defensively, so a missing TURSO env or a Turso blip drops just this signal. */\n notifyBounces?: ReadonlyMap<string, NotifyBounceCounts>;\n /** Pre-fetched unreplayed dead-letter counts per SLUG (#645). Same contract as\n * `notifyBounces`: omitted → read from libSQL, defensively. */\n deadLetters?: ReadonlyMap<string, number>;\n};\n\n/** Per-site bounced-notification counts for the notify-bounce collector, read from\n * libSQL over the collector's own window. The db modules are imported DYNAMICALLY:\n * src/db/submissions.ts pulls kysely at top level, and kysely/libsql live in\n * devDependencies consuming fleet sites don't install — a static import here would\n * crash their CLI at require time (same rule as openDb's own lazy loads). Any\n * failure (no TURSO env, Turso down) logs and yields an empty map: the digest\n * must never blank over a missing optional signal. */\nasync function fetchNotifyBounceCounts(\n now: Date,\n): Promise<ReadonlyMap<string, NotifyBounceCounts>> {\n try {\n const [{ openDb, readDbConfig }, { countNotifyBouncedBySite }, { screenOutsSince }] =\n await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n import(\"../db/screenouts.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countNotifyBouncedBySite(db, screenOutsSince(now, NOTIFY_BOUNCE_WINDOW_DAYS));\n } catch (e) {\n console.warn(`⚠ notify-bounce counts unavailable (libSQL): ${(e as Error).message}`);\n return new Map();\n }\n}\n\n/** Unreplayed dead-letter counts per slug for the #645 collector. Dynamically\n * imported and defensive for exactly the same reasons as\n * `fetchNotifyBounceCounts` above: kysely/libsql are devDependencies consuming\n * fleet sites do not install, and the digest must never blank over a missing\n * optional signal. */\nasync function fetchDeadLetterCounts(): Promise<ReadonlyMap<string, number>> {\n try {\n const [{ openDb, readDbConfig }, { countUnreplayedDeadLettersBySlug }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/deadletter.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countUnreplayedDeadLettersBySlug(db);\n } catch (e) {\n console.warn(`⚠ dead-letter counts unavailable (libSQL): ${(e as Error).message}`);\n return new Map();\n }\n}\n\n/** The prior snapshot, from Turso (#609). Dynamically imported for the same\n * reason as fetchNotifyBounceCounts: kysely/libsql are devDependencies that\n * consuming fleet sites do not install, so a static import would crash their\n * CLI at require time.\n *\n * NOT defensive, deliberately — the Airtable read it replaces was not either.\n * Swallowing a failure here would silently badge every item NEW, which reads\n * as \"the whole fleet degraded overnight\" in the operator's inbox. A thrown\n * error is the honest outcome. */\nasync function readDigestStateFromDb(): Promise<DigestSnapshot> {\n const [{ openDb, readDbConfig }, { readDigestState }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/digest-state.js\"),\n ]);\n return readDigestState(await openDb(readDbConfig()));\n}\n\n/** Bridge the injectable seam (returns void) onto the three-state contract the\n * log line reports. An injected writer that resolves is a real write. */\nasync function writeRollupOnce(\n options: DigestRunOptions,\n now: Date,\n): Promise<\"written\" | \"absent\"> {\n if (options.cockpitRollup) {\n await options.cockpitRollup.write(now);\n return \"written\";\n }\n return writeCockpitRollupToDb(now);\n}\n\n/** The write half of {@link readDigestStateFromDb}, same lazy-import rule. */\nasync function writeDigestStateToDb(next: DigestSnapshot): Promise<void> {\n const [{ openDb, readDbConfig }, { writeDigestState }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/digest-state.js\"),\n ]);\n await writeDigestState(await openDb(readDbConfig()), next);\n}\n\n/**\n * Compute the cockpit's \"since a window\" roll-ups once, here, instead of on\n * every page load (MED-16 of the 2026-08-26 review).\n *\n * Both aggregates read the whole `submissions` table — the one unbounded-growth\n * table in the schema — and the cockpit root is the operator's most-loaded page.\n * Turso meters row scans, so recomputing them per request is a cost that grows\n * forever against numbers that move once a day.\n *\n * The trade is explicit: the figures become up to 24h old. That is why the\n * payload carries `computedAt` and the windows it used, and why an absent\n * roll-up reads as null rather than zero — a stale or unmeasured number\n * presented as live is worse than no number.\n *\n * Best-effort, like the snapshot write beside it: the digest has already been\n * sent by the time this runs, and a failed roll-up costs one strip on one page\n * until tomorrow, never the digest.\n */\nasync function writeCockpitRollupToDb(now: Date): Promise<\"written\" | \"absent\"> {\n // No Turso configured is a THIRD state, not a failure: it is what every unit\n // test looks like, and reporting it as `rollup=0` would train the eye to\n // ignore the number that is supposed to catch a dead writer. Same three-state\n // vocabulary the site mirrors already use (`mirrored=1|0|absent`).\n if (!process.env.TURSO_DATABASE_URL) return \"absent\";\n const [{ openDb, readDbConfig }, { writeCockpitRollup }, screenouts, submissions, collectors] =\n await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/digest-state.js\"),\n import(\"../db/screenouts.js\"),\n import(\"../db/submissions.js\"),\n import(\"../alerts/digest-collectors.js\"),\n ]);\n const db = await openDb(readDbConfig());\n const SCREEN_OUT_WINDOW_DAYS = 30;\n const screenOutSince = screenouts.screenOutsSince(now, SCREEN_OUT_WINDOW_DAYS);\n const bounceSince = screenouts.screenOutsSince(now, collectors.NOTIFY_BOUNCE_WINDOW_DAYS);\n\n const totals = { honeypot: 0, tooFast: 0, markedSpam: 0 };\n for (const t of (await screenouts.listScreenOutsSince(db, screenOutSince)).values()) {\n totals.honeypot += t.honeypot;\n totals.tooFast += t.tooFast;\n totals.markedSpam += t.markedSpam;\n }\n const bounces = await submissions.countNotifyBouncedBySite(db, bounceSince);\n\n await writeCockpitRollup(db, {\n spamTotals: totals,\n notifyBounces: Object.fromEntries(bounces),\n windowDays: {\n screenOuts: SCREEN_OUT_WINDOW_DAYS,\n bounces: collectors.NOTIFY_BOUNCE_WINDOW_DAYS,\n },\n computedAt: now.toISOString(),\n });\n return \"written\";\n}\n\n/** Persist the next snapshot to BOTH stores (#609 dual-write).\n *\n * Turso is the read side, so a failure there is the one that actually costs\n * correct NEW badges tomorrow — but Airtable keeps being written so the move\n * stays reversible while Phase 5 is in flight. Both are best-effort: the\n * digest has already been sent (or deliberately skipped) by the time this\n * runs, and a snapshot write failure re-news at worst.\n *\n * The DIGEST_STATE_WRITE line exists because of #585: a dual-write that\n * silently stopped running looked identical to a healthy one for weeks. One\n * line, always emitted, naming both halves. */\nasync function persistDigestState(\n base: AirtableBase,\n next: DigestSnapshot,\n writeState?: (snap: DigestSnapshot) => Promise<void>,\n writeRollup?: () => Promise<\"written\" | \"absent\">,\n): Promise<void> {\n let turso = 0;\n let airtable = 0;\n let rollup: \"1\" | \"0\" | \"absent\" = \"0\";\n try {\n await (writeState ?? writeDigestStateToDb)(next);\n turso = 1;\n } catch (e) {\n console.warn(`⚠ digest state write failed (turso): ${(e as Error).message}`);\n }\n try {\n await writeAirtableDigestState(base, next);\n airtable = 1;\n } catch (e) {\n console.warn(`⚠ digest state write failed (airtable): ${(e as Error).message}`);\n }\n // The cockpit roll-up rides the same \"already sent, best-effort\" contract, and\n // gets its own counter on the line for the #585 reason: a dual-write that\n // silently stopped running looked identical to a healthy one for weeks. An\n // ABSENT `rollup=` is a different failure from `rollup=0`.\n if (writeRollup) {\n try {\n rollup = (await writeRollup()) === \"absent\" ? \"absent\" : \"1\";\n } catch (e) {\n rollup = \"0\";\n console.warn(`⚠ cockpit rollup write failed: ${(e as Error).message}`);\n }\n }\n console.log(`DIGEST_STATE_WRITE turso=${turso} airtable=${airtable} rollup=${rollup}`);\n}\n\n/** The \"Submissions (24h)\" telemetry window — a precise 24h ISO-timestamp compare\n * (unlike screenOutsSince's date-only strings). */\nconst SUBMISSIONS_DIGEST_WINDOW_MS = 24 * 60 * 60 * 1000;\n\n/** Per-site submission counts for the digest telemetry section, read from libSQL.\n * Dynamic imports + fail-soft for the same reason as fetchNotifyBounceCounts:\n * kysely/libsql are devDependencies consuming fleet sites don't install, and a\n * Turso blip must drop just this section, never the digest. Returns null (not an\n * empty map) on failure so the renderer can omit the section rather than claim a\n * quiet fleet. */\nasync function fetchSubmissionsDigestCounts(\n now: Date,\n): Promise<ReadonlyMap<string, SiteSubmissionCounts> | null> {\n try {\n const [{ openDb, readDbConfig }, { countSubmissionsSinceBySite }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countSubmissionsSinceBySite(\n db,\n new Date(now.getTime() - SUBMISSIONS_DIGEST_WINDOW_MS).toISOString(),\n );\n } catch (e) {\n console.warn(`⚠ submissions telemetry unavailable (libSQL): ${(e as Error).message}`);\n return null;\n }\n}\n\n/** Assemble the \"Submissions (24h)\" section from the per-site counts. PURE (exported\n * for direct tests). Totals sum ALL map entries — an orphan site id still counts in\n * the fleet totals. bySite lists only ids that resolve in `sitesById` AND carry any\n * nonzero count, sorted by total volume desc then siteName A-Z. null counts (libSQL\n * unavailable) → null (section omitted). */\nexport function buildSubmissionsDigestSection(\n counts: ReadonlyMap<string, SiteSubmissionCounts> | null,\n sitesById: ReadonlyMap<string, WebsiteRow>,\n): SubmissionsDigestSection | null {\n if (counts === null) return null;\n let leads = 0;\n let signups = 0;\n let spamAuto = 0;\n const bySite: SubmissionsDigestSection[\"bySite\"] = [];\n for (const [siteId, c] of counts) {\n leads += c.leads;\n signups += c.signups;\n spamAuto += c.spamAuto;\n const site = sitesById.get(siteId);\n if (site && c.leads + c.signups + c.spamAuto > 0) {\n bySite.push({ siteName: site.name, ...c });\n }\n }\n bySite.sort(\n (a, b) =>\n b.leads + b.signups + b.spamAuto - (a.leads + a.signups + a.spamAuto) ||\n a.siteName.localeCompare(b.siteName),\n );\n return { leads, signups, spamAuto, bySite };\n}\n\n/** Run a single collector under a try/catch: a thrown collector logs and yields []\n * so one broken signal never blanks the whole \"Needs attention\" section. */\nfunction runCollector(label: string, fn: () => AttentionItem[]): AttentionItem[] {\n try {\n return fn();\n } catch (e) {\n console.warn(`⚠ attention collector \"${label}\" failed: ${(e as Error).message}`);\n return [];\n }\n}\n\n/**\n * Take the run's `reports`/`websites` (runDigest read them once, from Turso since\n * #646 step 4), build the sitesById map the delivery collector needs, and run each\n * pure collector isolated. Returns the union of items; diffing/badging happens in\n * runDigest. This function opens no store of its own for those two datasets.\n *\n * The Renovate + CI signals come from the SAME persisted collectors the operator\n * cockpit (`buildCockpitModel`) runs — `collectRenovateAlerts` (key\n * `renovate:<siteId>`) and `collectCiAlerts` (key `ci:<siteId>`), reading the\n * nightly-persisted `renovateFailingCis`/`defaultBranchCi` fields. This is the\n * key-space unification: because the digest writes the shared Digest State\n * snapshot with these same keys, the cockpit's NEW/WORSE diff finds them and the\n * two surfaces agree (the prior live per-PR `renovate:<repo>#<n>` sweep never\n * matched the cockpit's keys, so its cards badged NEW forever).\n */\nexport async function collectAttention(deps: CollectAttentionDeps): Promise<AttentionItem[]> {\n const { reports, websites } = deps;\n const now = deps.now ?? new Date();\n const notifyBounces = deps.notifyBounces ?? (await fetchNotifyBounceCounts(now));\n const deadLetters = deps.deadLetters ?? (await fetchDeadLetterCounts());\n const sitesById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n return [\n ...runCollector(\"vuln\", () => collectVulnAlerts(websites, deps.baseUrl)),\n ...runCollector(\"delivery\", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"preflight\", () => collectPreflightBlocked(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"lighthouse\", () => collectLighthouseAlerts(websites, deps.baseUrl)),\n ...runCollector(\"renovate\", () => collectRenovateAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"ci\", () => collectCiAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"analytics\", () => collectAnalyticsFailures(websites, deps.baseUrl, now)),\n ...runCollector(\"turnstile\", () =>\n collectTurnstileGuardrailAlerts(websites, deps.baseUrl, now),\n ),\n ...runCollector(\"notify-bounce\", () =>\n collectNotifyBounceAlerts(websites, notifyBounces, deps.baseUrl),\n ),\n // #645. The dead-letter alarm. The digest is the SURFACE that matters for\n // this kind: the cockpit groups items onto site cards, and the worst flavour\n // — a slug that resolves to no fleet site — has no card by construction. The\n // digest renders every item flat by title, so it carries both flavours.\n ...runCollector(\"deadletter\", () =>\n collectDeadLetterAlerts(websites, deadLetters, deps.baseUrl),\n ),\n // Same collector the cockpit runs, over the same persisted `Prismic Models`\n // columns and the same keys — so the snapshot this digest writes is the one\n // the cockpit diffs NEW/WORSE against, and the two surfaces agree.\n ...runCollector(\"prismic-drift\", () => collectPrismicDriftAlerts(websites, deps.baseUrl, now)),\n ];\n}\n\nexport type DigestRunOptions = {\n resend?: ResendClient;\n /** Dashboard origin for the /s/<slug> links, e.g. \"https://reddoor-maintenance.netlify.app\". */\n baseUrl: string;\n /**\n * The run's two datasets. Since #646 step 4 they come from TURSO, read by the\n * command that calls this (one connection, both reads) — Airtable cannot see a\n * `site_<ULID>` site at all, so a digest built from it is silently short.\n *\n * Functions rather than arrays, and required rather than defaulted: a read\n * failure must still be able to red the run (it is caught below and reported as\n * `digest failed:`), and no caller — least of all a test — can reach a store\n * this did not hand it.\n */\n roster: () => Promise<WebsiteRow[]>;\n allReports: () => Promise<ReportRow[]>;\n /**\n * Inject a pre-opened Airtable base (tests, server handlers) for the digest-state\n * SHADOW write — the only Airtable call left in this run. When omitted,\n * `openBase(readAirtableConfig())` is called from the environment.\n */\n base?: AirtableBase;\n /** Per-site submission counts for the \"Submissions (24h)\" section. undefined =\n * fetch from libSQL; null = simulate unavailable (section omitted); a Map =\n * injected (tests). */\n submissionCounts?: ReadonlyMap<string, SiteSubmissionCounts> | null;\n /**\n * The prior-run snapshot store (#609). Production omits it and gets Turso.\n *\n * Injectable because the read is deliberately NOT defensive: swallowing a\n * failure would badge every item NEW, which lands in the operator's inbox\n * reading as \"the whole fleet degraded overnight\". That makes Turso a hard\n * requirement of a real run — so tests, which have no libSQL, need a seam\n * rather than an env var.\n */\n digestState?: {\n read: () => Promise<DigestSnapshot>;\n write: (snap: DigestSnapshot) => Promise<void>;\n };\n /**\n * The cockpit roll-up writer (MED-16). Same seam and same reason as\n * `digestState`: production omits it and gets Turso, tests inject.\n *\n * Without a seam this would be untestable in exactly the way that matters —\n * `writeCockpitRollupToDb` opens a db from env, so in a test it throws and is\n * caught, and the run reports `rollup=0` forever while looking healthy. That\n * is the #585 shape the counter on the log line exists to expose.\n */\n cockpitRollup?: { write: (now: Date) => Promise<void> };\n /**\n * The run clock. Production omits it and gets `new Date()`.\n *\n * Injectable because several collectors this run feeds are AGE-SENSITIVE —\n * `collectPrismicDriftAlerts` alone changes an item's key and its wording once\n * a verdict crosses `PRISMIC_DRIFT_STALE_DAYS` — so a digest test that cannot\n * fix the clock is asserting on the day it happens to run. Two did, and both\n * went red four days after they were written, reporting the staleness wording\n * as if the drift wiring had broken. `collectAttention` already takes a `now`\n * for exactly this reason; this threads the same lever one layer out.\n */\n now?: Date;\n};\n\nexport async function runDigest(\n options: DigestRunOptions,\n): Promise<{ output: string; code: number }> {\n // Capture clock BEFORE any await so the idempotency key can't roll past midnight mid-run.\n const today = options.now ?? new Date();\n try {\n // Airtable is opened for ONE thing now: the digest-state shadow write at the\n // end of the run (#646 step 4 moved both reads to Turso).\n const base = options.base ?? openBase(readAirtableConfig());\n // Read each dataset ONCE for the whole run, then thread the arrays into\n // collectAttention so it doesn't re-fetch. Pending is derived in-line with\n // listPendingApproval's exact predicate.\n const reports = await options.allReports();\n const websites = await options.roster();\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const pending = reports.filter(isPendingApproval);\n\n const readyForYourYes: ReadyItem[] = [];\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n for (const r of pending) {\n const site = sites.get(r.siteId);\n if (!site) continue; // orphan report → skip rather than render a broken link\n // An empty Name slugs to \"\" → `/s/` is a dead link (getWebsiteBySlug can't\n // match it). Fall back to the fleet homepage so the operator still lands\n // somewhere usable instead of a 404.\n const slug = siteSlug(site.name);\n readyForYourYes.push({\n siteName: site.name,\n reportType: r.reportType,\n period: r.period ?? \"—\",\n dashboardUrl: slug ? `${baseUrl}/s/${slug}` : baseUrl,\n });\n }\n\n // M5: collect the free signals (isolated), diff against yesterday's snapshot.\n // Renovate + CI come from the persisted collectors (the same ones the cockpit\n // runs), so the snapshot this digest writes carries the `renovate:<siteId>` /\n // `ci:<siteId>` keys the cockpit diffs against — no live GitHub sweep here.\n const collected = await collectAttention({\n baseUrl: options.baseUrl,\n websites,\n reports,\n now: today,\n });\n // #609: Turso is the read side now. Airtable keeps being WRITTEN below\n // (dual-write, Phase 5's pattern everywhere else) so the move stays\n // reversible, but nothing reads it any more.\n const prior = await (options.digestState?.read ?? readDigestStateFromDb)();\n const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));\n // The operator only HEARS about a vuln once Renovate's auto-fix is exhausted\n // (tried and failed a couple of nightly cycles) — before that the fleet is\n // still self-patching and the cockpit's amber Watch band is the only surface.\n // Filter the EMAIL list only: `next` (written below) must keep every vuln key\n // so the cockpit's diff against this same snapshot stays consistent, and so\n // the exhausted-flip diffs as WORSE instead of arriving pre-badged-away.\n const needsAttention = tagged.filter(\n (it) => it.kind !== \"vuln\" || it.autoFixExhausted === true,\n );\n\n // No-noise default: skip entirely when there's nothing to report.\n if (readyForYourYes.length === 0 && needsAttention.length === 0) {\n // Persist `next` even on a skip: a key that resolved on a quiet day clears so a\n // later recurrence diffs as NEW (spec §10), and — since the skip can now fire\n // with muted pre-exhaustion vulns collected — their keys keep snapshotting so\n // the eventual exhausted-flip diffs as WORSE. Wrapped: a write failure can't\n // fail the skip.\n await persistDigestState(base, next, options.digestState?.write, () =>\n writeRollupOnce(options, today),\n );\n return { output: \"Digest skipped (nothing ready, nothing needs attention).\", code: 0 };\n }\n\n // Submissions telemetry rides only on a digest that is ALREADY sending — the\n // skip predicate above is deliberately unchanged (leads already fire their own\n // ingest-time notification; this is context, not an alarm), and fetching AFTER\n // the skip check keeps quiet days from touching libSQL at all.\n const counts =\n options.submissionCounts !== undefined\n ? options.submissionCounts\n : await fetchSubmissionsDigestCounts(today);\n const submissions = buildSubmissionsDigestSection(counts, sites);\n\n const html = renderDigestHtml({ readyForYourYes, needsAttention, submissions });\n const client = options.resend ?? defaultResendClient();\n const to = [operatorEmail()];\n const n = readyForYourYes.length;\n const reportWord = n === 1 ? \"report\" : \"reports\";\n let result: Awaited<ReturnType<typeof client.send>>;\n try {\n result = await client.send({\n from: FROM_ADDRESS,\n to,\n subject: `Your fleet — ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,\n html,\n idempotencyKey: `digest-${digestDateKey(today)}`,\n });\n } catch (err) {\n // A same-UTC-day re-run whose content changed re-sends with the same\n // `digest-<date>` idempotency key but a DIFFERENT body. Resend rejects that\n // with a 409 (`invalid_idempotent_request`) — \"This idempotency key has been\n // used ... but the request body was modified ...\". The operator already got\n // today's digest on the first send, so re-sending a changed version same-day\n // would just be a duplicate: treat it as an \"already sent today\" no-op.\n //\n // ResendClient (send/resend.ts) wraps the API error as a plain Error and only\n // preserves the *message* string (no name/statusCode), so the message\n // substring is the only reliable discriminator — match defensively on it.\n // Any OTHER send error re-throws to the outer catch → {code:1}, so a genuine\n // Resend/network failure still fails loudly.\n if (isIdempotencyConflict(err)) {\n // Do NOT write the snapshot: the first send already persisted it; writing\n // this run's `next` would diff against the first run's snapshot and mis-badge.\n return {\n output:\n \"Digest already sent today (content changed since the first send) — skipped to avoid a duplicate.\",\n code: 0,\n };\n }\n throw err;\n }\n // Persist the next snapshot AFTER a successful send. A write failure is caught +\n // logged: the digest already went out, tomorrow re-news at worst. (The send-FAILURE\n // path never reaches here — the outer catch returns code 1 with no write, preserving\n // the NEW badge for the retry.)\n await persistDigestState(base, next, options.digestState?.write, () =>\n writeRollupOnce(options, today),\n );\n return { output: `Digest sent to ${to.join(\", \")} (${result.messageId})`, code: 0 };\n } catch (err) {\n // Re-throw config errors (exitCode=2: missing env vars, bad config) so runOrExit\n // surfaces them with the correct process exit code rather than collapsing to 1.\n if (typeof (err as { exitCode?: unknown }).exitCode === \"number\") {\n throw err;\n }\n const message = err instanceof Error ? err.message : String(err);\n return { output: `digest failed: ${message}`, code: 1 };\n }\n}\n\n/** Pure render of the unified daily operator digest. No IO — the caller (runDigest)\n * collects the rows and decides whether to send. */\nexport function renderDigestHtml(sections: DigestSections): string {\n return `<!doctype html>\n<html>\n <head><meta charset=\"utf-8\"></head>\n <body style=\"margin:0;padding:0;background:#ffffff\">\n <table width=\"100%\" style=\"border-collapse:collapse\">\n <tr>\n <td align=\"center\" style=\"padding:24px\">\n <table width=\"600\" style=\"border-collapse:collapse\">\n <tr>\n <td>\n <h1 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px\">Your fleet today</h1>\n ${readySection(sections.readyForYourYes)}\n ${attentionSection(sections.needsAttention)}\n ${submissionsSection(sections.submissions)}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n</html>`;\n}\n"],"mappings":"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","names":["writeDigestState"]}
@@ -3,6 +3,7 @@ import {
3
3
  NOTIFY_BOUNCE_WINDOW_DAYS,
4
4
  collectAnalyticsFailures,
5
5
  collectCiAlerts,
6
+ collectDeadLetterAlerts,
6
7
  collectDeliveryFailures,
7
8
  collectLighthouseAlerts,
8
9
  collectNotifyBounceAlerts,
@@ -12,24 +13,27 @@ import {
12
13
  collectTurnstileGuardrailAlerts,
13
14
  collectVulnAlerts,
14
15
  prismicAckIsLive
15
- } from "./chunk-KJRVMMM3.js";
16
- import "./chunk-R5IMFAFR.js";
16
+ } from "./chunk-PARKXDXI.js";
17
+ import "./chunk-VMGHQUTV.js";
17
18
  import "./chunk-PZR4EBPY.js";
18
- import "./chunk-2ZAZ7ETT.js";
19
- import "./chunk-F3JWOPTG.js";
19
+ import "./chunk-LDHRIQUW.js";
20
+ import "./chunk-TEMQPSCM.js";
21
+ import "./chunk-ASLDCWBF.js";
20
22
  import "./chunk-VAD43MOR.js";
21
- import "./chunk-IY6256GK.js";
23
+ import "./chunk-YJBSB4LP.js";
22
24
  import "./chunk-HN6I2XWI.js";
23
- import "./chunk-HTABUCF4.js";
25
+ import "./chunk-6THEZQLA.js";
24
26
  import "./chunk-GECESMVS.js";
25
27
  import "./chunk-KJGKZLCT.js";
26
28
  import "./chunk-BGTYPVLT.js";
27
- import "./chunk-5UINJNAJ.js";
28
- import "./chunk-3Z6X3BHU.js";
29
+ import "./chunk-LZMAG73U.js";
30
+ import "./chunk-TC4GANMX.js";
31
+ import "./chunk-ICSRWW5Q.js";
32
+ import "./chunk-RQJFGK3A.js";
29
33
  import "./chunk-WEKL2HYJ.js";
30
- import "./chunk-BI2VI6EM.js";
31
- import "./chunk-VSGMCCGQ.js";
32
- import "./chunk-C3IQQKXL.js";
34
+ import "./chunk-YXWBM26P.js";
35
+ import "./chunk-MMSMZC7S.js";
36
+ import "./chunk-IOTOOTII.js";
33
37
  import "./chunk-RARREDQE.js";
34
38
  import "./chunk-6VAL7XJT.js";
35
39
  export {
@@ -37,6 +41,7 @@ export {
37
41
  NOTIFY_BOUNCE_WINDOW_DAYS,
38
42
  collectAnalyticsFailures,
39
43
  collectCiAlerts,
44
+ collectDeadLetterAlerts,
40
45
  collectDeliveryFailures,
41
46
  collectLighthouseAlerts,
42
47
  collectNotifyBounceAlerts,
@@ -47,4 +52,4 @@ export {
47
52
  collectVulnAlerts,
48
53
  prismicAckIsLive
49
54
  };
50
- //# sourceMappingURL=digest-collectors-DJFN5ZDK.js.map
55
+ //# sourceMappingURL=digest-collectors-CYTN2EUX.js.map
@@ -18,6 +18,22 @@ async function writeDigestState(db, snap, updatedAt = (/* @__PURE__ */ new Date(
18
18
  await db.insertInto("digest_state").values(row).onConflict((oc) => oc.column("id").doUpdateSet(row)).execute();
19
19
  }
20
20
  var COCKPIT_ROLLUP_ID = "cockpit_rollup";
21
+ function coerceBounces(raw) {
22
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
23
+ const out = {};
24
+ for (const [siteId, v] of Object.entries(raw)) {
25
+ if (typeof v === "number" && Number.isFinite(v)) {
26
+ out[siteId] = { total: v, permanent: 0 };
27
+ continue;
28
+ }
29
+ if (typeof v !== "object" || v === null || Array.isArray(v)) continue;
30
+ const o = v;
31
+ if (typeof o.total === "number" && Number.isFinite(o.total) && typeof o.permanent === "number" && Number.isFinite(o.permanent)) {
32
+ out[siteId] = { total: o.total, permanent: o.permanent };
33
+ }
34
+ }
35
+ return out;
36
+ }
21
37
  async function readCockpitRollup(db) {
22
38
  const row = await db.selectFrom("digest_state").select("snapshot").where("id", "=", COCKPIT_ROLLUP_ID).executeTakeFirst();
23
39
  if (!row) return null;
@@ -26,7 +42,7 @@ async function readCockpitRollup(db) {
26
42
  if (typeof parsed !== "object" || parsed === null || typeof parsed.computedAt !== "string" || typeof parsed.spamTotals !== "object" || parsed.spamTotals === null || typeof parsed.notifyBounces !== "object" || parsed.notifyBounces === null) {
27
43
  return null;
28
44
  }
29
- return parsed;
45
+ return { ...parsed, notifyBounces: coerceBounces(parsed.notifyBounces) };
30
46
  } catch {
31
47
  return null;
32
48
  }
@@ -47,4 +63,4 @@ export {
47
63
  writeCockpitRollup,
48
64
  writeDigestState
49
65
  };
50
- //# sourceMappingURL=digest-state-3KHZF7GD.js.map
66
+ //# sourceMappingURL=digest-state-ZDPKR7MW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/digest-state.ts"],"sourcesContent":["/** #609 (#539 Phase 5): the digest's prior-run snapshot, on Turso.\n *\n * Unlike every other Phase 5 slice this is a MIGRATION, not a mirror — there\n * was no Turso table to dual-write into, so these REPLACE the Airtable pair in\n * `src/alerts/digest-state.ts` rather than shadowing them. Nothing writes the\n * Airtable \"Digest State\" table after this, so parity does not cover the new\n * one: it has no counterpart to compare against.\n *\n * The pure half — `diffAttention`, and the `DigestSnapshot` type it is written\n * against — stays exactly where it is. Only the IO moved.\n *\n * Stored as ONE row holding the whole snapshot as JSON. Both readers\n * (`runDigest`'s diff and the fleet homepage's NEW badges) need the entire map,\n * so a keyed table would buy nothing on reads and its \"give me everything\"\n * query would be a raw scan needing a justified entry in the EXPLAIN gate's\n * allowlist. One row by primary key needs neither.\n *\n * Worth stating plainly: the homepage's read of this used to be an AIRTABLE\n * call on a request path — a Phase 2 leftover, since digest state was never in\n * that phase's scope. Moving it removes a live dependency, which is the same\n * failure class as the 2026-08-17 quota outage.\n */\nimport type { Db } from \"./client.js\";\nimport type { DigestSnapshot } from \"../alerts/digest-state.js\";\nimport type { NotifyBounceCounts } from \"./submissions.js\";\n\n/** The singleton row's key. A constant rather than a magic string at each call\n * site, so the \"there is exactly one row\" invariant is visible in one place. */\nexport const DIGEST_STATE_ID = \"default\";\n\n/**\n * The prior snapshot, or `{}` when there is none.\n *\n * A missing row and a malformed blob BOTH read as `{}` — the Airtable reader's\n * contract, kept verbatim. The digest runs unattended on a cron, so a bad blob\n * must cost accurate NEW badges for one run, never the whole send.\n */\nexport async function readDigestState(db: Db): Promise<DigestSnapshot> {\n const row = await db\n .selectFrom(\"digest_state\")\n .select(\"snapshot\")\n .where(\"id\", \"=\", DIGEST_STATE_ID)\n .executeTakeFirst();\n if (!row) return {};\n try {\n return JSON.parse(row.snapshot) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot, replacing whatever was there.\n *\n * An upsert on the constant id rather than a get-then-create: the Airtable\n * version needed two round-trips to decide, and here the singleton invariant is\n * enforced by the primary key instead of by the code that happens to run.\n */\nexport async function writeDigestState(\n db: Db,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const row = {\n id: DIGEST_STATE_ID,\n snapshot: JSON.stringify(snap),\n updated_at: updatedAt,\n };\n await db\n .insertInto(\"digest_state\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(row))\n .execute();\n}\n\n/**\n * A SECOND row in the same table, holding the cockpit's \"since a window\"\n * roll-ups. Keyed separately rather than folded into the snapshot: the snapshot\n * is the digest's own diff state and has one writer and one meaning; conflating\n * two unrelated payloads in one blob makes a partial write of either look like a\n * corruption of both.\n */\nexport const COCKPIT_ROLLUP_ID = \"cockpit_rollup\";\n\n/**\n * Fleet-wide counts the cockpit renders, computed once by the nightly digest\n * instead of aggregated on every page load.\n *\n * `computedAt` is not decoration. These figures are up to 24h old by\n * construction, and a stale number rendered as though it were live is worse than\n * no number — so the cockpit shows when it was taken.\n */\nexport type CockpitRollup = {\n spamTotals: { honeypot: number; tooFast: number; markedSpam: number };\n /** site id → unacknowledged bounce counts inside the bounce window, split by\n * whether Resend called any of them Permanent (#783). */\n notifyBounces: Record<string, NotifyBounceCounts>;\n /** The windows these were computed over, so a changed constant is visible\n * rather than silently re-labelling old numbers. */\n windowDays: { screenOuts: number; bounces: number };\n computedAt: string;\n};\n\n/**\n * The stored roll-up, or **null** when there is none.\n *\n * Null rather than zeros, deliberately. Every one of these numbers has a\n * legitimate zero, so a reader given `{honeypot: 0, …}` cannot tell \"nothing was\n * screened out\" from \"the digest has never run\". That is the same distinction\n * `FLEET_SMOKE_UNMEASURED` exists to preserve, and the cockpit renders the strip\n * as ABSENT for null rather than drawing a row of zeros.\n *\n * A malformed blob is also null: same reasoning, and it must never throw on a\n * request path.\n */\n/**\n * Normalize the stored per-site bounce map (#783).\n *\n * The shape changed from `{recA: 2}` to `{recA: {total, permanent}}`, and this\n * blob is written by the NIGHTLY digest — so after a deploy the stored payload\n * is the old shape until the next run. Rejecting it outright would blank the\n * whole strip, `spamTotals` included, for a number that is merely less detailed.\n *\n * An old numeric entry reads as `permanent: 0`, which is the honest translation:\n * that writer recorded no classification, and 0 is exactly \"nothing said the\n * address is dead\". It words the alarm as the mail-filter case for one night,\n * which is the safe direction. Anything else is dropped per SITE rather than\n * poisoning the map with NaN or discarding the roll-up.\n */\nfunction coerceBounces(raw: unknown): Record<string, NotifyBounceCounts> {\n if (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) return {};\n const out: Record<string, NotifyBounceCounts> = {};\n for (const [siteId, v] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof v === \"number\" && Number.isFinite(v)) {\n out[siteId] = { total: v, permanent: 0 };\n continue;\n }\n if (typeof v !== \"object\" || v === null || Array.isArray(v)) continue;\n const o = v as Record<string, unknown>;\n if (\n typeof o.total === \"number\" &&\n Number.isFinite(o.total) &&\n typeof o.permanent === \"number\" &&\n Number.isFinite(o.permanent)\n ) {\n out[siteId] = { total: o.total, permanent: o.permanent };\n }\n }\n return out;\n}\n\nexport async function readCockpitRollup(db: Db): Promise<CockpitRollup | null> {\n const row = await db\n .selectFrom(\"digest_state\")\n .select(\"snapshot\")\n .where(\"id\", \"=\", COCKPIT_ROLLUP_ID)\n .executeTakeFirst();\n if (!row) return null;\n try {\n const parsed = JSON.parse(row.snapshot) as Partial<CockpitRollup>;\n // Shape-check rather than trust: this blob is written by a different process\n // on a different schedule, so an older writer's payload can outlive a type\n // change here. Anything unrecognized reads as \"not measured\".\n if (\n typeof parsed !== \"object\" ||\n parsed === null ||\n typeof parsed.computedAt !== \"string\" ||\n typeof parsed.spamTotals !== \"object\" ||\n parsed.spamTotals === null ||\n typeof parsed.notifyBounces !== \"object\" ||\n parsed.notifyBounces === null\n ) {\n return null;\n }\n return { ...(parsed as CockpitRollup), notifyBounces: coerceBounces(parsed.notifyBounces) };\n } catch {\n return null;\n }\n}\n\n/** Upsert the roll-up, same singleton-by-PK shape as the snapshot. */\nexport async function writeCockpitRollup(db: Db, rollup: CockpitRollup): Promise<void> {\n const row = {\n id: COCKPIT_ROLLUP_ID,\n snapshot: JSON.stringify(rollup),\n updated_at: rollup.computedAt,\n };\n await db\n .insertInto(\"digest_state\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(row))\n .execute();\n}\n"],"mappings":";AA4BO,IAAM,kBAAkB;AAS/B,eAAsB,gBAAgB,IAAiC;AACrE,QAAM,MAAM,MAAM,GACf,WAAW,cAAc,EACzB,OAAO,UAAU,EACjB,MAAM,MAAM,KAAK,eAAe,EAChC,iBAAiB;AACpB,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,MAAI;AACF,WAAO,KAAK,MAAM,IAAI,QAAQ;AAAA,EAChC,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AASA,eAAsB,iBACpB,IACA,MACA,aAAoB,oBAAI,KAAK,GAAE,YAAY,GAC5B;AACf,QAAM,MAAM;AAAA,IACV,IAAI;AAAA,IACJ,UAAU,KAAK,UAAU,IAAI;AAAA,IAC7B,YAAY;AAAA,EACd;AACA,QAAM,GACH,WAAW,cAAc,EACzB,OAAO,GAAG,EACV,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,GAAG,CAAC,EACnD,QAAQ;AACb;AASO,IAAM,oBAAoB;AA+CjC,SAAS,cAAc,KAAkD;AACvE,MAAI,OAAO,QAAQ,YAAY,QAAQ,QAAQ,MAAM,QAAQ,GAAG,EAAG,QAAO,CAAC;AAC3E,QAAM,MAA0C,CAAC;AACjD,aAAW,CAAC,QAAQ,CAAC,KAAK,OAAO,QAAQ,GAA8B,GAAG;AACxE,QAAI,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,GAAG;AAC/C,UAAI,MAAM,IAAI,EAAE,OAAO,GAAG,WAAW,EAAE;AACvC;AAAA,IACF;AACA,QAAI,OAAO,MAAM,YAAY,MAAM,QAAQ,MAAM,QAAQ,CAAC,EAAG;AAC7D,UAAM,IAAI;AACV,QACE,OAAO,EAAE,UAAU,YACnB,OAAO,SAAS,EAAE,KAAK,KACvB,OAAO,EAAE,cAAc,YACvB,OAAO,SAAS,EAAE,SAAS,GAC3B;AACA,UAAI,MAAM,IAAI,EAAE,OAAO,EAAE,OAAO,WAAW,EAAE,UAAU;AAAA,IACzD;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAsB,kBAAkB,IAAuC;AAC7E,QAAM,MAAM,MAAM,GACf,WAAW,cAAc,EACzB,OAAO,UAAU,EACjB,MAAM,MAAM,KAAK,iBAAiB,EAClC,iBAAiB;AACpB,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,IAAI,QAAQ;AAItC,QACE,OAAO,WAAW,YAClB,WAAW,QACX,OAAO,OAAO,eAAe,YAC7B,OAAO,OAAO,eAAe,YAC7B,OAAO,eAAe,QACtB,OAAO,OAAO,kBAAkB,YAChC,OAAO,kBAAkB,MACzB;AACA,aAAO;AAAA,IACT;AACA,WAAO,EAAE,GAAI,QAA0B,eAAe,cAAc,OAAO,aAAa,EAAE;AAAA,EAC5F,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,eAAsB,mBAAmB,IAAQ,QAAsC;AACrF,QAAM,MAAM;AAAA,IACV,IAAI;AAAA,IACJ,UAAU,KAAK,UAAU,MAAM;AAAA,IAC/B,YAAY,OAAO;AAAA,EACrB;AACA,QAAM,GACH,WAAW,cAAc,EACzB,OAAO,GAAG,EACV,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,GAAG,CAAC,EACnD,QAAQ;AACb;","names":[]}
@@ -0,0 +1,70 @@
1
+ // src/cli/commands/ensure-site.ts
2
+ async function defaultAirtable(db) {
3
+ const { openBase, readAirtableConfig } = await import("./client-EPVO3FEH.js");
4
+ let cfg;
5
+ try {
6
+ cfg = readAirtableConfig();
7
+ } catch {
8
+ return null;
9
+ }
10
+ const base = openBase(cfg);
11
+ const { findWebsiteRecordBySlug } = await import("./ensure-site-OO4UPWNQ.js");
12
+ const { updateSiteFields } = await import("./websites-Y6FN2XFD.js");
13
+ const { mirrorSiteInsert } = await import("./fleet-state-RJROYXKO.js");
14
+ return {
15
+ findBySlug: (slug) => findWebsiteRecordBySlug(base, slug),
16
+ // The #645 heal: the importer's own mapping, so an adopted row is exactly what
17
+ // the import would have written.
18
+ adopt: (rec) => mirrorSiteInsert(db, rec, (/* @__PURE__ */ new Date()).toISOString()),
19
+ update: (recordId, patch) => {
20
+ const fields = {};
21
+ if (patch.name !== void 0) fields["Name"] = patch.name;
22
+ if (patch.url !== void 0) fields["url"] = patch.url;
23
+ if (patch.pointOfContact !== void 0) fields["point of contact"] = patch.pointOfContact;
24
+ if (patch.gitRepo !== void 0) fields["Git repo"] = patch.gitRepo;
25
+ return updateSiteFields(base, recordId, fields);
26
+ }
27
+ };
28
+ }
29
+ async function runEnsureSiteCommand(slug, opts, deps = {}) {
30
+ if (!slug) return { output: "Provide a <slug> (e.g. `ensure-site roalson`).", code: 2 };
31
+ try {
32
+ const open = deps.openDb ?? (async () => {
33
+ const { openDb, readDbConfig } = await import("./client-DGTO65WB.js");
34
+ return openDb(readDbConfig());
35
+ });
36
+ const db = await open();
37
+ const { makeSiteStore } = await import("./site-create-ZU3ACBTO.js");
38
+ const { ensureSite } = await import("./ensure-site-JEKV2ODE.js");
39
+ const airtable = deps.airtable ? await deps.airtable() : await defaultAirtable(db);
40
+ const result = await ensureSite(
41
+ {
42
+ slug,
43
+ ...opts.name ? { displayName: opts.name } : {},
44
+ ...opts.url ? { url: opts.url } : {},
45
+ ...opts.contact ? { pointOfContact: opts.contact } : {},
46
+ ...opts.gitRepo ? { gitRepo: opts.gitRepo } : {}
47
+ },
48
+ { store: makeSiteStore(db), airtable }
49
+ );
50
+ const renamed = result.updatedFields.includes("name") ? ` \u2014 name set to "${opts.name}"` : "";
51
+ const blanks = result.updatedFields.filter((f) => f !== "name");
52
+ const filled = blanks.length > 0 ? ` \u2014 filled blank field(s): ${blanks.join(", ")}` : "";
53
+ const skipped = result.skippedMismatches.length > 0 ? ` \u2014 differs from existing, left untouched (edit on the dashboard site page): ${result.skippedMismatches.join(", ")}` : "";
54
+ const healed = result.healedDbRow ? ` \u2014 HEALED: the Turso row was MISSING and has been adopted from Airtable; leads for this slug were being dropped (run \`db replay-deadletters\`)` : "";
55
+ const nameNote = result.status === "created" && !opts.name ? ` \u2014 name set to "${slug}"; re-run with --name before forms/announce go live` : "";
56
+ const batchNote = result.status === "created" ? `
57
+ note: no Airtable record was created (the site id is not an Airtable id). Batch jobs that still enumerate the fleet from Airtable \u2014 nightly audits incl. form-e2e, report drafting, digest \u2014 will not see this site until #646 step 4 moves them to Turso. Form ingest and the dashboard see it now.` : "";
58
+ return {
59
+ output: `[${slug}] ${result.status} (${result.siteId})${healed}${renamed}${filled}${skipped}${nameNote}${batchNote}`,
60
+ code: 0
61
+ };
62
+ } catch (err) {
63
+ const e = err;
64
+ return { output: e.message ?? String(err), code: e.exitCode ?? 1 };
65
+ }
66
+ }
67
+ export {
68
+ runEnsureSiteCommand
69
+ };
70
+ //# sourceMappingURL=ensure-site-2QZHFVQ3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli/commands/ensure-site.ts"],"sourcesContent":["import type { Db } from \"../../db/client.js\";\nimport type { LegacyAirtableSites } from \"../../fleet/ensure-site.js\";\n\nexport type EnsureSiteCommandOptions = {\n name?: string;\n url?: string;\n contact?: string;\n gitRepo?: string;\n cwd?: string;\n};\n\n/** The two stores, injectable so tests never open a real libSQL handle (even with\n * `TURSO_*` exported) or reach Airtable. */\nexport type EnsureSiteCommandDeps = {\n openDb?: () => Promise<Db>;\n /** `null` = no Airtable wired (no creds): no #645 heal lookup, no `rec` shadow. */\n airtable?: () => Promise<LegacyAirtableSites | null>;\n};\n\n/** Wire the legacy Airtable half only when credentials exist. Airtable is a shadow\n * now, so its absence is a supported state — a missing PAT must not stop a site\n * from being created. */\nasync function defaultAirtable(db: Db): Promise<LegacyAirtableSites | null> {\n const { openBase, readAirtableConfig } = await import(\"../../reports/airtable/client.js\");\n let cfg: ReturnType<typeof readAirtableConfig>;\n try {\n cfg = readAirtableConfig();\n } catch {\n return null;\n }\n const base = openBase(cfg);\n const { findWebsiteRecordBySlug } = await import(\"../../reports/airtable/ensure-site.js\");\n const { updateSiteFields } = await import(\"../../reports/airtable/websites.js\");\n const { mirrorSiteInsert } = await import(\"../../db/fleet-state.js\");\n return {\n findBySlug: (slug) => findWebsiteRecordBySlug(base, slug),\n // The #645 heal: the importer's own mapping, so an adopted row is exactly what\n // the import would have written.\n adopt: (rec) => mirrorSiteInsert(db, rec, new Date().toISOString()),\n update: (recordId, patch) => {\n const fields: Record<string, string> = {};\n if (patch.name !== undefined) fields[\"Name\"] = patch.name;\n if (patch.url !== undefined) fields[\"url\"] = patch.url;\n if (patch.pointOfContact !== undefined) fields[\"point of contact\"] = patch.pointOfContact;\n if (patch.gitRepo !== undefined) fields[\"Git repo\"] = patch.gitRepo;\n return updateSiteFields(base, recordId, fields);\n },\n };\n}\n\n/**\n * `ensure-site <slug>` — create/verify a site's fleet row (#646 step 3: in Turso,\n * with a `site_<ULID>` id for a new site). Day-one step of the /new-site bootstrap\n * skill. Fill-blanks-only; safe to re-run; `--name` renames without moving the slug.\n */\nexport async function runEnsureSiteCommand(\n slug: string | undefined,\n opts: EnsureSiteCommandOptions,\n deps: EnsureSiteCommandDeps = {},\n): Promise<{ output: string; code: number }> {\n if (!slug) return { output: \"Provide a <slug> (e.g. `ensure-site roalson`).\", code: 2 };\n try {\n const open =\n deps.openDb ??\n (async () => {\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n return openDb(readDbConfig());\n });\n const db = await open();\n const { makeSiteStore } = await import(\"../../db/site-create.js\");\n const { ensureSite } = await import(\"../../fleet/ensure-site.js\");\n const airtable = deps.airtable ? await deps.airtable() : await defaultAirtable(db);\n const result = await ensureSite(\n {\n slug,\n ...(opts.name ? { displayName: opts.name } : {}),\n ...(opts.url ? { url: opts.url } : {}),\n ...(opts.contact ? { pointOfContact: opts.contact } : {}),\n ...(opts.gitRepo ? { gitRepo: opts.gitRepo } : {}),\n },\n { store: makeSiteStore(db), airtable },\n );\n // #664: a name change on the exists path is a rename the operator asked for.\n const renamed = result.updatedFields.includes(\"name\") ? ` — name set to \"${opts.name}\"` : \"\";\n const blanks = result.updatedFields.filter((f) => f !== \"name\");\n const filled = blanks.length > 0 ? ` — filled blank field(s): ${blanks.join(\", \")}` : \"\";\n const skipped =\n result.skippedMismatches.length > 0\n ? ` — differs from existing, left untouched (edit on the dashboard site page): ${result.skippedMismatches.join(\", \")}`\n : \"\";\n // #645. Loud, and its own clause: a healed row means this site's leads were\n // being answered `unknown-site` and dropped until this run.\n const healed = result.healedDbRow\n ? ` — HEALED: the Turso row was MISSING and has been adopted from Airtable; leads for this slug were being dropped (run \\`db replay-deadletters\\`)`\n : \"\";\n const nameNote =\n result.status === \"created\" && !opts.name\n ? ` — name set to \"${slug}\"; re-run with --name before forms/announce go live`\n : \"\";\n // The honest cost of a Turso-only site until #646 step 4 lands (see\n // src/fleet/ensure-site.ts): say it where the operator is looking.\n const batchNote =\n result.status === \"created\"\n ? `\\n note: no Airtable record was created (the site id is not an Airtable id). Batch jobs that still enumerate the fleet from Airtable — nightly audits incl. form-e2e, report drafting, digest — will not see this site until #646 step 4 moves them to Turso. Form ingest and the dashboard see it now.`\n : \"\";\n return {\n output: `[${slug}] ${result.status} (${result.siteId})${healed}${renamed}${filled}${skipped}${nameNote}${batchNote}`,\n code: 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,105 @@
1
+ import {
2
+ siteSlug
3
+ } from "./chunk-ICSRWW5Q.js";
4
+ import {
5
+ isAirtableRecordId,
6
+ mintSiteId,
7
+ skipsAirtableShadow
8
+ } from "./chunk-RQJFGK3A.js";
9
+
10
+ // src/fleet/ensure-site.ts
11
+ var slugTaken = (err) => typeof err === "object" && err !== null && err.code === "SLUG_TAKEN";
12
+ async function ensureSite(input, deps) {
13
+ const { store } = deps;
14
+ const airtable = deps.airtable ?? null;
15
+ const slug = siteSlug(input.slug);
16
+ if (!slug) throw new Error(`ensure-site: '${input.slug}' does not slugify to a usable slug`);
17
+ if (input.displayName !== void 0 && siteSlug(input.displayName) !== slug) {
18
+ throw new Error(
19
+ `ensure-site: display name '${input.displayName}' slugifies to '${siteSlug(input.displayName)}', not '${slug}' \u2014 a rename never changes the slug`
20
+ );
21
+ }
22
+ let existing = await store.findBySlug(slug);
23
+ let healedDbRow = false;
24
+ if (!existing && airtable) {
25
+ const legacy = await airtable.findBySlug(slug);
26
+ if (legacy) {
27
+ await airtable.adopt(legacy);
28
+ existing = await store.findBySlug(slug);
29
+ if (!existing) {
30
+ throw new Error(
31
+ `ensure-site: adopted Airtable record ${legacy.id} for '${slug}' but Turso still has no row for the slug`
32
+ );
33
+ }
34
+ healedDbRow = true;
35
+ }
36
+ }
37
+ if (!existing) {
38
+ const id = (deps.mintId ?? mintSiteId)();
39
+ try {
40
+ await store.create({
41
+ id,
42
+ slug,
43
+ name: input.displayName ?? slug,
44
+ status: "building",
45
+ url: input.url ?? null,
46
+ pointOfContact: input.pointOfContact ?? null,
47
+ gitRepo: input.gitRepo ?? `reddoorla/${slug}`
48
+ });
49
+ } catch (err) {
50
+ if (!slugTaken(err)) throw err;
51
+ const owner = await store.findBySlug(slug).catch(() => null);
52
+ throw new Error(
53
+ `ensure-site: slug '${slug}' is already taken${owner ? ` by site ${owner.id}` : ""} \u2014 it was created after this run looked; re-run to resume that site`,
54
+ { cause: err }
55
+ );
56
+ }
57
+ skipsAirtableShadow("ensureSite.create", id);
58
+ return {
59
+ status: "created",
60
+ siteId: id,
61
+ updatedFields: [],
62
+ skippedMismatches: [],
63
+ healedDbRow: false,
64
+ airtableShadow: "skipped"
65
+ };
66
+ }
67
+ const patch = {};
68
+ const skippedMismatches = [];
69
+ const consider = (key, provided, current) => {
70
+ if (!provided) return;
71
+ if (current === null || current === "") patch[key] = provided;
72
+ else if (current !== provided) skippedMismatches.push(key);
73
+ };
74
+ consider("url", input.url, existing.url);
75
+ consider("pointOfContact", input.pointOfContact, existing.pointOfContact);
76
+ consider("gitRepo", input.gitRepo, existing.gitRepo);
77
+ if (input.displayName !== void 0 && existing.name !== input.displayName) {
78
+ patch.name = input.displayName;
79
+ }
80
+ const updatedFields = Object.keys(patch);
81
+ let airtableShadow = "none";
82
+ if (updatedFields.length > 0) {
83
+ if (skipsAirtableShadow("ensureSite.update", existing.id)) {
84
+ airtableShadow = "skipped";
85
+ } else if (airtable && isAirtableRecordId(existing.id)) {
86
+ await airtable.update(existing.id, patch);
87
+ airtableShadow = "written";
88
+ }
89
+ if (!await store.updateIdentity(existing.id, patch)) {
90
+ throw new Error(`ensure-site: site ${existing.id} vanished from Turso mid-run`);
91
+ }
92
+ }
93
+ return {
94
+ status: "exists",
95
+ siteId: existing.id,
96
+ updatedFields,
97
+ skippedMismatches,
98
+ healedDbRow,
99
+ airtableShadow
100
+ };
101
+ }
102
+ export {
103
+ ensureSite
104
+ };
105
+ //# sourceMappingURL=ensure-site-JEKV2ODE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/fleet/ensure-site.ts"],"sourcesContent":["/**\n * `ensure-site`, Turso-native (#539 Phase 6 step 3, #646; operator decision\n * 2026-09-17).\n *\n * Find-or-create a site by SLUG so it exists in the fleet from day one. Turso is\n * the source of truth: a new site gets a minted `site_<ULID>` id and its three\n * rows (`sites`, `site_health`, `site_schedule`) in one transaction. The command\n * is re-run to RESUME a partial bootstrap, so everything keys on the slug — a\n * re-run finds the site and never mints a second id.\n *\n * Fill-blanks-only on the exists path: a resumed bootstrap must never clobber an\n * operator's edit. The one exception is `displayName` (#664): `--name` targets the\n * name and nothing else, so a differing value IS the operator's rename. A rename\n * changes `sites.name` only; `sites.slug` is the stable external handle and never\n * moves, which is why a name that would slugify differently is refused outright.\n *\n * ## The Airtable shadow for a NEW site: none\n *\n * A new site is not written to Airtable at all. An Airtable record would carry\n * its own `rec` id that is NOT the site id, and every batch job still enumerating\n * the fleet from Airtable (#646 step 4, not yet done) would then write Turso by\n * that `rec` id — an UPDATE matching no row, which under the freeze THROWS\n * (`mirrored=missed`). A half-visible site that reds the nightly sweeps is worse\n * than one those sweeps cannot see yet. The rollback week is over and a rollback\n * to Airtable has never been rehearsed, so there is no recovery the record would\n * be keeping trustworthy. Every Airtable shadow writer skips a `site_` id with an\n * `AIRTABLE_SHADOW skipped=non-rec-id` line (`src/fleet/site-id.ts`).\n *\n * The cost, named: until step 4 moves batch enumeration to Turso, a site created\n * here is invisible to the Airtable-enumerated jobs (audits incl. form-e2e,\n * report drafting, digest, github-signals, renovate-dispatch…). Form ingest,\n * the dashboard and every Turso reader see it immediately.\n *\n * ## Pre-existing `rec` sites\n *\n * Both id shapes coexist permanently. A `rec` site found in Turso keeps its id,\n * and its fill-blanks / rename is still shadowed to Airtable (Airtable stays the\n * shadow until step 6), Airtable first so a re-run converges both stores.\n *\n * The #645 heal survives in one form: when Turso has no row for the slug but\n * Airtable does, the Airtable record is ADOPTED under its own `rec` id instead of\n * minting a second identity for the same site. That lookup runs only when an\n * Airtable client is wired, and a lookup that FAILS refuses the create — minting\n * while unable to rule out a duplicate identity would be the one unrecoverable\n * outcome here.\n */\nimport { siteSlug } from \"./site-row.js\";\nimport { isAirtableRecordId, mintSiteId, skipsAirtableShadow } from \"./site-id.js\";\nimport type { NewSiteRow, SiteIdentityPatch } from \"../db/fleet-state.js\";\n\nexport type { SiteIdentityPatch } from \"../db/fleet-state.js\";\n\nexport type EnsureSiteInput = {\n /** Canonical slug; normalised through `siteSlug`. The stored `sites.slug`. */\n slug: string;\n /** Human display name. Written on create (default: the slug) and, unlike every\n * other input, UPDATED on an existing site when it differs (#664) — it is used\n * verbatim in client-facing copy (forms auto-reply intro, report subjects).\n * Must slugify to the same slug, or it is refused. */\n displayName?: string;\n url?: string;\n pointOfContact?: string;\n /** owner/repo. Default on create: `reddoorla/<slug>`. */\n gitRepo?: string;\n};\n\n/** The identity columns of an existing site, as `ensure-site` compares them. */\nexport type ExistingSite = {\n id: string;\n name: string;\n url: string | null;\n pointOfContact: string | null;\n gitRepo: string | null;\n};\n\n/** The Turso operations the creator needs. `src/db/site-create.ts` implements it. */\nexport type SiteStore = {\n findBySlug: (slug: string) => Promise<ExistingSite | null>;\n /** Insert all three rows atomically. Rejects with `code: \"SLUG_TAKEN\"` when the\n * `sites.slug` UNIQUE index refuses the row. */\n create: (site: NewSiteRow) => Promise<void>;\n /** Resolves whether a row matched. Never touches the slug (by type). */\n updateIdentity: (siteId: string, patch: SiteIdentityPatch) => Promise<boolean>;\n};\n\n/** The Airtable layer, as the creator still needs it until #646 step 6. Every\n * member is legacy: the lookup + adopt keep the #645 heal, `update` shadows a\n * `rec` site's fill. The CLI wires it only when Airtable credentials exist. */\nexport type LegacyAirtableSites = {\n findBySlug: (slug: string) => Promise<{ id: string; fields: Record<string, unknown> } | null>;\n /** Insert Turso rows for an Airtable record that has none (the #645 heal). */\n adopt: (rec: { id: string; fields: Record<string, unknown> }) => Promise<void>;\n update: (recordId: string, patch: SiteIdentityPatch) => Promise<void>;\n};\n\nexport type EnsureSiteDeps = {\n store: SiteStore;\n airtable?: LegacyAirtableSites | null;\n /** Injectable for tests; defaults to {@link mintSiteId}. */\n mintId?: () => string;\n};\n\nexport type EnsureSiteResult = {\n status: \"created\" | \"exists\";\n siteId: string;\n /** Identity fields written on the exists path (`name` = a `--name` rename). */\n updatedFields: Array<keyof SiteIdentityPatch>;\n /** Inputs that DIFFERED from an existing non-blank value and were left untouched\n * (fill-blanks-only) — surfaced so a resumed bootstrap with a corrected value\n * does not silently discard it. */\n skippedMismatches: Array<keyof SiteIdentityPatch>;\n /** #645: the site existed in Airtable with NO Turso row, and this run adopted it.\n * Its leads were being answered `unknown-site` until now. */\n healedDbRow: boolean;\n /** What happened to the Airtable shadow: `skipped` for a `site_` id (created or\n * updated), `written` when a `rec` site's changes were shadowed, `none` when\n * there was nothing to shadow or no Airtable client was wired. */\n airtableShadow: \"skipped\" | \"written\" | \"none\";\n};\n\nconst slugTaken = (err: unknown): boolean =>\n typeof err === \"object\" && err !== null && (err as { code?: unknown }).code === \"SLUG_TAKEN\";\n\nexport async function ensureSite(\n input: EnsureSiteInput,\n deps: EnsureSiteDeps,\n): Promise<EnsureSiteResult> {\n const { store } = deps;\n const airtable = deps.airtable ?? null;\n const slug = siteSlug(input.slug);\n if (!slug) throw new Error(`ensure-site: '${input.slug}' does not slugify to a usable slug`);\n if (input.displayName !== undefined && siteSlug(input.displayName) !== slug) {\n throw new Error(\n `ensure-site: display name '${input.displayName}' slugifies to '${siteSlug(input.displayName)}', not '${slug}' — a rename never changes the slug`,\n );\n }\n\n let existing = await store.findBySlug(slug);\n let healedDbRow = false;\n\n if (!existing && airtable) {\n // Throws through on failure — see \"Pre-existing rec sites\" above.\n const legacy = await airtable.findBySlug(slug);\n if (legacy) {\n await airtable.adopt(legacy);\n existing = await store.findBySlug(slug);\n if (!existing) {\n throw new Error(\n `ensure-site: adopted Airtable record ${legacy.id} for '${slug}' but Turso still has no row for the slug`,\n );\n }\n healedDbRow = true;\n }\n }\n\n if (!existing) {\n const id = (deps.mintId ?? mintSiteId)();\n try {\n await store.create({\n id,\n slug,\n name: input.displayName ?? slug,\n status: \"building\",\n url: input.url ?? null,\n pointOfContact: input.pointOfContact ?? null,\n gitRepo: input.gitRepo ?? `reddoorla/${slug}`,\n });\n } catch (err) {\n if (!slugTaken(err)) throw err;\n const owner = await store.findBySlug(slug).catch(() => null);\n throw new Error(\n `ensure-site: slug '${slug}' is already taken${owner ? ` by site ${owner.id}` : \"\"} — it was created after this run looked; re-run to resume that site`,\n { cause: err },\n );\n }\n skipsAirtableShadow(\"ensureSite.create\", id);\n return {\n status: \"created\",\n siteId: id,\n updatedFields: [],\n skippedMismatches: [],\n healedDbRow: false,\n airtableShadow: \"skipped\",\n };\n }\n\n const patch: SiteIdentityPatch = {};\n const skippedMismatches: Array<keyof SiteIdentityPatch> = [];\n const consider = (\n key: \"url\" | \"pointOfContact\" | \"gitRepo\",\n provided: string | undefined,\n current: string | null,\n ) => {\n if (!provided) return;\n if (current === null || current === \"\") patch[key] = provided;\n else if (current !== provided) skippedMismatches.push(key);\n };\n consider(\"url\", input.url, existing.url);\n consider(\"pointOfContact\", input.pointOfContact, existing.pointOfContact);\n consider(\"gitRepo\", input.gitRepo, existing.gitRepo);\n if (input.displayName !== undefined && existing.name !== input.displayName) {\n patch.name = input.displayName;\n }\n\n const updatedFields = Object.keys(patch) as Array<keyof SiteIdentityPatch>;\n let airtableShadow: EnsureSiteResult[\"airtableShadow\"] = \"none\";\n if (updatedFields.length > 0) {\n if (skipsAirtableShadow(\"ensureSite.update\", existing.id)) {\n airtableShadow = \"skipped\";\n } else if (airtable && isAirtableRecordId(existing.id)) {\n await airtable.update(existing.id, patch);\n airtableShadow = \"written\";\n }\n if (!(await store.updateIdentity(existing.id, patch))) {\n throw new Error(`ensure-site: site ${existing.id} vanished from Turso mid-run`);\n }\n }\n return {\n status: \"exists\",\n siteId: existing.id,\n updatedFields,\n skippedMismatches,\n healedDbRow,\n airtableShadow,\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,14 +1,12 @@
1
- import {
2
- openBase,
3
- readAirtableConfig
4
- } from "./chunk-HN6I2XWI.js";
5
1
  import {
6
2
  WEBSITES_TABLE,
7
- listWebsites,
8
- siteSlug,
3
+ mapRow,
9
4
  toAirtableStatus
10
- } from "./chunk-3Z6X3BHU.js";
11
- import "./chunk-6VAL7XJT.js";
5
+ } from "./chunk-TC4GANMX.js";
6
+ import {
7
+ siteSlug
8
+ } from "./chunk-ICSRWW5Q.js";
9
+ import "./chunk-RQJFGK3A.js";
12
10
 
13
11
  // src/reports/airtable/ensure-site.ts
14
12
  var COLS = {
@@ -16,6 +14,17 @@ var COLS = {
16
14
  pointOfContact: "point of contact",
17
15
  gitRepo: "Git repo"
18
16
  };
17
+ async function findWebsiteRecordBySlug(base, slug) {
18
+ let found = null;
19
+ await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
20
+ for (const rec of records) {
21
+ if (!found && siteSlug(mapRow(rec).name) === slug)
22
+ found = { id: rec.id, fields: rec.fields };
23
+ }
24
+ fetchNextPage();
25
+ });
26
+ return found;
27
+ }
19
28
  async function ensureSite(base, input, mirror) {
20
29
  const slug = siteSlug(input.slug);
21
30
  if (!slug) throw new Error(`ensure-site: '${input.slug}' does not slugify to a usable slug`);
@@ -23,8 +32,22 @@ async function ensureSite(base, input, mirror) {
23
32
  throw new Error(
24
33
  `ensure-site: display name '${input.displayName}' slugifies to '${siteSlug(input.displayName)}', not '${slug}' \u2014 the row would not be found on re-run`
25
34
  );
26
- const existing = (await listWebsites(base)).find((w) => siteSlug(w.name) === slug);
27
- if (!existing) {
35
+ const raw = [];
36
+ await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
37
+ for (const rec of records) raw.push({ id: rec.id, fields: rec.fields });
38
+ fetchNextPage();
39
+ });
40
+ let existing;
41
+ let existingRaw;
42
+ for (const rec of raw) {
43
+ const row = mapRow(rec);
44
+ if (siteSlug(row.name) === slug) {
45
+ existing = row;
46
+ existingRaw = rec;
47
+ break;
48
+ }
49
+ }
50
+ if (!existing || !existingRaw) {
28
51
  const fields = {
29
52
  Name: input.displayName ?? slug,
30
53
  Status: toAirtableStatus("building"),
@@ -39,9 +62,17 @@ async function ensureSite(base, input, mirror) {
39
62
  status: "created",
40
63
  siteId: rec.id,
41
64
  updatedFields: [],
42
- skippedMismatches: []
65
+ skippedMismatches: [],
66
+ // The create path just inserted the row; there is nothing to heal, and a
67
+ // `hasRow` probe here would be a round-trip whose answer is already known.
68
+ healedDbRow: false
43
69
  };
44
70
  }
71
+ let healedDbRow = false;
72
+ if (mirror?.hasRow && !await mirror.hasRow(existingRaw.id)) {
73
+ await mirror.created(existingRaw);
74
+ healedDbRow = true;
75
+ }
45
76
  const updates = {};
46
77
  const skippedMismatches = [];
47
78
  const blank = (v) => v === null || v === void 0 || v === "";
@@ -53,44 +84,18 @@ async function ensureSite(base, input, mirror) {
53
84
  consider(COLS.url, input.url, existing.url || null);
54
85
  consider(COLS.pointOfContact, input.pointOfContact, existing.pointOfContact);
55
86
  consider(COLS.gitRepo, input.gitRepo, existing.gitRepo);
87
+ if (input.displayName && existing.name !== input.displayName) {
88
+ updates["Name"] = input.displayName;
89
+ }
56
90
  const updatedFields = Object.keys(updates);
57
91
  if (updatedFields.length > 0) {
58
92
  await base(WEBSITES_TABLE).update([{ id: existing.id, fields: updates }]);
59
93
  await mirror?.site(existing.id, updates);
60
94
  }
61
- return { status: "exists", siteId: existing.id, updatedFields, skippedMismatches };
62
- }
63
-
64
- // src/cli/commands/ensure-site.ts
65
- async function runEnsureSiteCommand(slug, opts) {
66
- if (!slug) return { output: "Provide a <slug> (e.g. `ensure-site roalson`).", code: 2 };
67
- try {
68
- const base = openBase(readAirtableConfig());
69
- const { makeSiteMirror } = await import("./site-mirror-WON4FTJ2.js");
70
- const result = await ensureSite(
71
- base,
72
- {
73
- slug,
74
- ...opts.name ? { displayName: opts.name } : {},
75
- ...opts.url ? { url: opts.url } : {},
76
- ...opts.contact ? { pointOfContact: opts.contact } : {},
77
- ...opts.gitRepo ? { gitRepo: opts.gitRepo } : {}
78
- },
79
- await makeSiteMirror()
80
- );
81
- const filled = result.updatedFields.length > 0 ? ` \u2014 filled blank field(s): ${result.updatedFields.join(", ")}` : "";
82
- const skipped = result.skippedMismatches.length > 0 ? ` \u2014 differs from existing, left untouched (edit in Airtable): ${result.skippedMismatches.join(", ")}` : "";
83
- const nameNote = result.status === "created" && !opts.name ? ` \u2014 Name set to "${slug}"; retitle in Airtable before forms/announce go live (or re-create with --name)` : "";
84
- return {
85
- output: `[${slug}] ${result.status} (${result.siteId})${filled}${skipped}${nameNote}`,
86
- code: 0
87
- };
88
- } catch (err) {
89
- const e = err;
90
- return { output: e.message ?? String(err), code: e.exitCode ?? 1 };
91
- }
95
+ return { status: "exists", siteId: existing.id, updatedFields, skippedMismatches, healedDbRow };
92
96
  }
93
97
  export {
94
- runEnsureSiteCommand
98
+ ensureSite,
99
+ findWebsiteRecordBySlug
95
100
  };
96
- //# sourceMappingURL=ensure-site-OAAE5YE2.js.map
101
+ //# sourceMappingURL=ensure-site-OO4UPWNQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/airtable/ensure-site.ts"],"sourcesContent":["import type { FieldSet, Records } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport { WEBSITES_TABLE, mapRow, siteSlug, type WebsiteRow } from \"./websites.js\";\nimport { toAirtableStatus } from \"./site-status.js\";\n\nexport type EnsureSiteInput = {\n /** Canonical slug — matches by siteSlug(Name); the Name on create when no displayName given. */\n slug: string;\n /** Human display name written to Name on create, and — unlike every other\n * input — UPDATED on an existing row when it differs (#664). Name is\n * consumed VERBATIM in client-facing copy (forms auto-reply intro, report\n * subjects), so a bare machine slug there reads as \"Thanks for reaching out\n * to acme-co.\" — and the row that has it is exactly the one created before\n * the name was settled, which only this command re-run can fix. Must\n * slugify to the same slug or the row wouldn't be found on re-run. */\n displayName?: string;\n url?: string;\n pointOfContact?: string;\n /** owner/repo. Default on create: `reddoorla/<slug>`. */\n gitRepo?: string;\n};\n\n/** #539 Phase 5. Structurally typed rather than importing the db layer's\n * `SiteMirror`, so this module stays the leaf it has always been — a real\n * `SiteMirror` satisfies it. Two ops because `ensureSite` has two write paths,\n * and the create one is the reason this exists: every other site mirror is an\n * UPDATE, which does nothing for a row that does not exist yet. */\nexport type EnsureSiteMirror = {\n created: (rec: { id: string; fields: Record<string, unknown> }) => Promise<void>;\n site: (siteId: string, fields: Record<string, unknown>) => Promise<void>;\n /** #645. Does Turso hold a row for this site id? Optional so every pre-#645\n * caller (and every hand-built test mirror) is byte-for-byte unchanged —\n * absent means the heal below never runs. */\n hasRow?: (siteId: string) => Promise<boolean>;\n};\n\nexport type EnsureSiteResult = {\n status: \"created\" | \"exists\";\n siteId: string;\n /** Airtable column names written on the exists path (create path writes all). */\n updatedFields: string[];\n /** Inputs that DIFFERED from an existing non-blank cell and were left\n * untouched (fill-blanks-only) — surfaced so a resumed bootstrap with a\n * corrected value doesn't silently discard it. */\n skippedMismatches: string[];\n /** #645. True when the site existed in Airtable but had NO Turso row and this\n * run inserted one. Surfaced rather than logged-only because it is the\n * difference between \"nothing to do\" and \"this site's leads were being\n * dropped until just now\". */\n healedDbRow: boolean;\n};\n\n/** Column-name map (load-bearing magic strings — see websites.ts's mapRow header). */\nconst COLS = {\n url: \"url\",\n pointOfContact: \"point of contact\",\n gitRepo: \"Git repo\",\n} as const;\n\n/**\n * One select over Websites, returning the RAW stored record whose Name slugifies\n * to `slug` (#646 step 3). The Turso-native creator (`src/fleet/ensure-site.ts`)\n * uses it for the #645 heal only — to ADOPT an Airtable site that has no Turso row\n * under its own `rec` id instead of minting a second identity for it. Raw, not a\n * mapped `WebsiteRow`, because the adopt re-inserts what Airtable STORED.\n */\nexport async function findWebsiteRecordBySlug(\n base: AirtableBase,\n slug: string,\n): Promise<{ id: string; fields: Record<string, unknown> } | null> {\n let found: { id: string; fields: Record<string, unknown> } | null = null;\n await base(WEBSITES_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) {\n if (!found && siteSlug(mapRow(rec).name) === slug)\n found = { id: rec.id, fields: rec.fields };\n }\n fetchNextPage();\n });\n return found;\n}\n\n/**\n * @deprecated since #646 step 3 — the `ensure-site` command no longer calls this.\n * New sites are created in Turso by `src/fleet/ensure-site.ts` with `site_<ULID>`\n * ids and no Airtable record. Kept, not deleted, because removing anything from\n * the Airtable layer is #646 steps 6–8, which need their own go.\n *\n * Find-or-create the Websites row for a slug so a new site exists in the fleet\n * inventory (audits, form-ingest slug resolution, reports) from day one.\n *\n * Fill-blanks-only on the exists path: this command runs from a bootstrap skill\n * that may be re-run to resume — it must never clobber operator-edited cells.\n * The one exception is `displayName` (#664): `--name` targets Name and nothing\n * else, so a differing value IS the operator's edit, and the create message\n * sends them back here to make it. Frequencies are deliberately NOT set\n * (launch flips the lifecycle); Status is only written on create (\"building\" —\n * Airtable's \"in development\" until the stage-2 vocabulary switch flips).\n */\nexport async function ensureSite(\n base: AirtableBase,\n input: EnsureSiteInput,\n mirror?: EnsureSiteMirror,\n): Promise<EnsureSiteResult> {\n const slug = siteSlug(input.slug);\n if (!slug) throw new Error(`ensure-site: '${input.slug}' does not slugify to a usable slug`);\n if (input.displayName && siteSlug(input.displayName) !== slug)\n throw new Error(\n `ensure-site: display name '${input.displayName}' slugifies to '${siteSlug(input.displayName)}', not '${slug}' — the row would not be found on re-run`,\n );\n\n // Paged inline rather than through `listWebsites` so the RAW stored record\n // survives alongside the mapped row: the #645 heal below re-inserts what\n // Airtable STORED (the same contract as the create path's mirror), and a\n // mapped `WebsiteRow` cannot be turned back into a FieldSet. Same single\n // select `listWebsites` issues — no extra Airtable read.\n const raw: Array<{ id: string; fields: Record<string, unknown> }> = [];\n await base(WEBSITES_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) raw.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n let existing: WebsiteRow | undefined;\n let existingRaw: { id: string; fields: Record<string, unknown> } | undefined;\n for (const rec of raw) {\n const row = mapRow(rec);\n if (siteSlug(row.name) === slug) {\n existing = row;\n existingRaw = rec;\n break;\n }\n }\n\n if (!existing || !existingRaw) {\n const fields: FieldSet = {\n Name: input.displayName ?? slug,\n Status: toAirtableStatus(\"building\"),\n [COLS.gitRepo]: input.gitRepo ?? `reddoorla/${slug}`,\n };\n if (input.url) fields[COLS.url] = input.url;\n if (input.pointOfContact) fields[COLS.pointOfContact] = input.pointOfContact;\n const created = (await base(WEBSITES_TABLE).create([{ fields }])) as Records<FieldSet>;\n const rec = created[0]!;\n // The mirror gets what Airtable STORED, never the `fields` we sent: parity\n // diffs Turso against the stored record, so mapping our own payload would\n // diverge the moment Airtable normalises a value.\n await mirror?.created({ id: rec.id, fields: rec.fields as Record<string, unknown> });\n return {\n status: \"created\",\n siteId: rec.id,\n updatedFields: [],\n skippedMismatches: [],\n // The create path just inserted the row; there is nothing to heal, and a\n // `hasRow` probe here would be a round-trip whose answer is already known.\n healedDbRow: false,\n };\n }\n\n // #645. THE gap: before this, `exists` meant \"Airtable has it\" and said nothing\n // about Turso. Post-flip (#643) the form-ingest lookup reads Turso ONLY, so a\n // site with an Airtable row and no Turso row has every lead answered\n // `unknown-site` — silently, permanently, and with no alarm once Phase 6 (#646)\n // deletes `mirror_missed`. `ensure-site` is the command an operator already\n // re-runs to finish a half-created site, so the heal belongs here.\n //\n // FIRST, before the fill-blanks update below: `mirror.site` is an UPDATE, and\n // under the freeze a no-match THROWS (`SITE_MIRROR … no such row in Turso`).\n // Healing second would abort ensure-site on exactly the site it exists to\n // repair. `mirrorSiteInsert` is an upsert, so a lost race just re-writes.\n let healedDbRow = false;\n if (mirror?.hasRow && !(await mirror.hasRow(existingRaw.id))) {\n await mirror.created(existingRaw);\n healedDbRow = true;\n }\n\n const updates: FieldSet = {};\n const skippedMismatches: string[] = [];\n const blank = (v: unknown) => v === null || v === undefined || v === \"\";\n const consider = (column: string, provided: string | undefined, current: unknown) => {\n if (!provided) return;\n if (blank(current)) updates[column] = provided;\n else if (current !== provided) skippedMismatches.push(column);\n };\n consider(COLS.url, input.url, existing.url || null);\n consider(COLS.pointOfContact, input.pointOfContact, existing.pointOfContact);\n consider(COLS.gitRepo, input.gitRepo, existing.gitRepo);\n // #664: Name is NOT fill-blanks. It is never blank (the slug match found it),\n // and `--name` is the only way to retitle a row created before the display\n // name was settled — the slugify guard above already proved the new value\n // resolves to the same row. Goes through the same update + mirror below, so\n // Turso gets it too.\n if (input.displayName && existing.name !== input.displayName) {\n updates[\"Name\"] = input.displayName;\n }\n\n const updatedFields = Object.keys(updates);\n if (updatedFields.length > 0) {\n await base(WEBSITES_TABLE).update([{ id: existing.id, fields: updates }]);\n // The fill-blanks path writes real cells too — a resumed bootstrap that only\n // filled a blank `url` would otherwise leave Turso stale until the sync.\n await mirror?.site(existing.id, updates);\n }\n return { status: \"exists\", siteId: existing.id, updatedFields, skippedMismatches, healedDbRow };\n}\n"],"mappings":"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","names":[]}