@reddoorla/maintenance 0.85.2 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/airtable-ZBUPQJSC.js +9 -0
- package/dist/{announce-GIAYN67S.js → announce-I2Z4JN3I.js} +58 -30
- package/dist/announce-I2Z4JN3I.js.map +1 -0
- package/dist/attachments-NLZIJ5NU.js +9 -0
- package/dist/{bump-deps-SWJB6H5V.js → bump-deps-CGVELRLK.js} +9 -9
- package/dist/{chunk-SDUJIANJ.js → chunk-2NJQLSEU.js} +3 -3
- package/dist/chunk-2NQUCS7O.js +243 -0
- package/dist/chunk-2NQUCS7O.js.map +1 -0
- package/dist/{chunk-ZTPPCHDB.js → chunk-3G25KIWW.js} +4 -4
- package/dist/{chunk-DTTIFYAM.js → chunk-3JQ2FO3D.js} +44 -405
- package/dist/chunk-3JQ2FO3D.js.map +1 -0
- package/dist/chunk-3SF7D4DY.js +359 -0
- package/dist/chunk-3SF7D4DY.js.map +1 -0
- package/dist/{chunk-37S6L6G4.js → chunk-5UINJNAJ.js} +72 -2
- package/dist/chunk-5UINJNAJ.js.map +1 -0
- package/dist/{chunk-THEFTZ7F.js → chunk-62CMMGEO.js} +5 -5
- package/dist/{chunk-FDIFTIXC.js → chunk-6TWWY7ZU.js} +2 -2
- package/dist/{chunk-PBWDDDQH.js → chunk-7BZUOZPU.js} +2 -2
- package/dist/{chunk-577HCGZO.js → chunk-7NYN2P6I.js} +44 -26
- package/dist/chunk-7NYN2P6I.js.map +1 -0
- package/dist/{chunk-UYCAF77E.js → chunk-7RJ2HMMJ.js} +3 -3
- package/dist/{chunk-Q276MZ7E.js → chunk-7UAYMAKR.js} +2 -2
- package/dist/{chunk-MGR2H7F4.js → chunk-BBUSGEES.js} +58 -45
- package/dist/chunk-BBUSGEES.js.map +1 -0
- package/dist/chunk-BNM5POSJ.js +284 -0
- package/dist/chunk-BNM5POSJ.js.map +1 -0
- package/dist/{chunk-R6FPFMOS.js → chunk-BQRFQSGY.js} +3 -3
- package/dist/{chunk-NWB4O5EY.js → chunk-C6CLRKPV.js} +5 -5
- package/dist/chunk-C6CLRKPV.js.map +1 -0
- package/dist/{chunk-7J53SUT6.js → chunk-CROKUHJB.js} +4 -4
- package/dist/chunk-DEWJMMG3.js +274 -0
- package/dist/chunk-DEWJMMG3.js.map +1 -0
- package/dist/{chunk-SEEA6RXF.js → chunk-DJZWYIPP.js} +2 -2
- package/dist/chunk-DJZWYIPP.js.map +1 -0
- package/dist/chunk-EVBCK2KC.js +68 -0
- package/dist/chunk-EVBCK2KC.js.map +1 -0
- package/dist/chunk-F3JWOPTG.js +202 -0
- package/dist/chunk-F3JWOPTG.js.map +1 -0
- package/dist/{chunk-GZNBCHAZ.js → chunk-HEWSRXYV.js} +4 -4
- package/dist/chunk-HHMMUR4P.js +335 -0
- package/dist/chunk-HHMMUR4P.js.map +1 -0
- package/dist/chunk-HSI6Q7MI.js +1238 -0
- package/dist/chunk-HSI6Q7MI.js.map +1 -0
- package/dist/{chunk-MPBXOJ3N.js → chunk-IY6256GK.js} +33 -2
- package/dist/chunk-IY6256GK.js.map +1 -0
- package/dist/{chunk-LS6QPANO.js → chunk-IYSEC543.js} +2 -2
- package/dist/{chunk-DC5H7KEQ.js → chunk-JHGHLKFS.js} +2 -2
- package/dist/{chunk-4DHQCN5H.js → chunk-LBYOLBW7.js} +17 -2
- package/dist/chunk-LBYOLBW7.js.map +1 -0
- package/dist/{chunk-6QRLRT3P.js → chunk-MT2JINSS.js} +16 -265
- package/dist/chunk-MT2JINSS.js.map +1 -0
- package/dist/{chunk-TP5M2NQB.js → chunk-NCMZMLY7.js} +3 -3
- package/dist/{chunk-COZPL62X.js → chunk-NDWYJLRF.js} +24 -139
- package/dist/chunk-NDWYJLRF.js.map +1 -0
- package/dist/{chunk-3YXDSCM6.js → chunk-NXNPDCF3.js} +29 -11
- package/dist/chunk-NXNPDCF3.js.map +1 -0
- package/dist/chunk-NYUYNYQL.js +48 -0
- package/dist/chunk-NYUYNYQL.js.map +1 -0
- package/dist/chunk-P6AORG32.js +415 -0
- package/dist/chunk-P6AORG32.js.map +1 -0
- package/dist/chunk-PTDMVGKL.js +31 -0
- package/dist/chunk-PTDMVGKL.js.map +1 -0
- package/dist/{chunk-D4FVAIEJ.js → chunk-PZR4EBPY.js} +5 -2
- package/dist/chunk-PZR4EBPY.js.map +1 -0
- package/dist/{chunk-LTB6KVJW.js → chunk-SDDPUAWH.js} +13 -11
- package/dist/{chunk-LTB6KVJW.js.map → chunk-SDDPUAWH.js.map} +1 -1
- package/dist/{chunk-WYYQK4D3.js → chunk-TD2NNXLZ.js} +13 -13
- package/dist/{chunk-ZMIPG6TR.js → chunk-TVL5MVUF.js} +4 -4
- package/dist/chunk-UDRHAQT7.js +172 -0
- package/dist/chunk-UDRHAQT7.js.map +1 -0
- package/dist/chunk-VAD43MOR.js +72 -0
- package/dist/chunk-VAD43MOR.js.map +1 -0
- package/dist/chunk-VSBFM7DS.js +229 -0
- package/dist/chunk-VSBFM7DS.js.map +1 -0
- package/dist/{chunk-VRWUL4TR.js → chunk-XHHMM5HX.js} +2 -2
- package/dist/{chunk-RJB3QUSP.js → chunk-XIDUYLSR.js} +3 -3
- package/dist/{chunk-QG6OSL4M.js → chunk-XUGSZP7Z.js} +2 -2
- package/dist/{chunk-S56JQJ4F.js → chunk-Y53WOQIJ.js} +3 -3
- package/dist/{chunk-62VF32IC.js → chunk-YFAXOD7Z.js} +10 -6
- package/dist/chunk-YFAXOD7Z.js.map +1 -0
- package/dist/{chunk-ICXV25N7.js → chunk-Z3KYFUN6.js} +3 -3
- package/dist/chunk-ZBDZTJUO.js +129 -0
- package/dist/chunk-ZBDZTJUO.js.map +1 -0
- package/dist/chunk-ZGBEIWSC.js +146 -0
- package/dist/chunk-ZGBEIWSC.js.map +1 -0
- package/dist/cli/bin.js +51 -28
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +30 -1
- package/dist/cli/commands/audit.js +69 -33
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-AUE3XUPO.js +11 -0
- package/dist/{convert-to-pnpm-AT52NWZB.js → convert-to-pnpm-QFJOEP5V.js} +9 -9
- package/dist/db-UXNIZ2RG.js +217 -0
- package/dist/db-UXNIZ2RG.js.map +1 -0
- package/dist/{digest-UVHYZUYE.js → digest-FW6QMZZJ.js} +26 -22
- package/dist/{digest-UVHYZUYE.js.map → digest-FW6QMZZJ.js.map} +1 -1
- package/dist/dump-P6EBSIOH.js +68 -0
- package/dist/dump-P6EBSIOH.js.map +1 -0
- package/dist/email-KJXASVH5.js +195 -0
- package/dist/email-KJXASVH5.js.map +1 -0
- package/dist/{ensure-site-S25MDDHW.js → ensure-site-4DCEBAPM.js} +25 -16
- package/dist/ensure-site-4DCEBAPM.js.map +1 -0
- package/dist/fleet-events-cF2Om88w.d.ts +24 -0
- package/dist/fleet-state-7TJ2GYT2.js +39 -0
- package/dist/{forms-notify-target-TWN4EH6K.js → forms-notify-target-2EAWCWBW.js} +42 -133
- package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
- package/dist/{github-signals-G4ILZAJ6.js → github-signals-E2WPYTBK.js} +42 -21
- package/dist/github-signals-E2WPYTBK.js.map +1 -0
- package/dist/{header-image-T7XD37JL.js → header-image-6IC53W27.js} +36 -13
- package/dist/header-image-6IC53W27.js.map +1 -0
- package/dist/header-images-SQBCE5OD.js +88 -0
- package/dist/header-images-SQBCE5OD.js.map +1 -0
- package/dist/{health-endpoint-242WG2OJ.js → health-endpoint-RFXL2F4A.js} +9 -9
- package/dist/health-mirror-L5JBEJVA.js +41 -0
- package/dist/health-mirror-L5JBEJVA.js.map +1 -0
- package/dist/import-airtable-OHVY344G.js +37 -0
- package/dist/index.d.ts +324 -21
- package/dist/index.js +215 -76
- package/dist/index.js.map +1 -1
- package/dist/{init-JN26NTWF.js → init-XRAQZYID.js} +21 -18
- package/dist/{init-JN26NTWF.js.map → init-XRAQZYID.js.map} +1 -1
- package/dist/{launch-JA7Z4CIM.js → launch-IX4Y5JJC.js} +65 -30
- package/dist/launch-IX4Y5JJC.js.map +1 -0
- package/dist/lighthouse-VHI77PFB.js +11 -0
- package/dist/{lighthouse-airtable-BIPM26SZ.js → lighthouse-airtable-3GUWHETW.js} +3 -3
- package/dist/mailchimp-XYCTEEJY.js +72 -0
- package/dist/mailchimp-XYCTEEJY.js.map +1 -0
- package/dist/migrate-GQH237A6.js +7 -0
- package/dist/migrate-GQH237A6.js.map +1 -0
- package/dist/notify-C645PGD5.js +22 -0
- package/dist/notify-C645PGD5.js.map +1 -0
- package/dist/{onboard-53CITIM7.js → onboard-O4IAHJMO.js} +9 -9
- package/dist/orchestrate-YLWQ6KK2.js +31 -0
- package/dist/orchestrate-YLWQ6KK2.js.map +1 -0
- package/dist/parity-56KA2DGR.js +12 -0
- package/dist/parity-56KA2DGR.js.map +1 -0
- package/dist/pipeline-3AANZFCR.js +12 -0
- package/dist/pipeline-3AANZFCR.js.map +1 -0
- package/dist/{preflight-3XPDUHFJ.js → preflight-TNORKGCX.js} +17 -13
- package/dist/{preflight-3XPDUHFJ.js.map → preflight-TNORKGCX.js.map} +1 -1
- package/dist/{prismic-ci-UTQ6LRR3.js → prismic-ci-G2AK53VL.js} +12 -12
- package/dist/{prismic-models-PVES6R5V.js → prismic-models-O6Z3OK3A.js} +18 -13
- package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
- package/dist/prospect/types.d.ts +267 -0
- package/dist/prospect/types.js +1 -0
- package/dist/prospect/types.js.map +1 -0
- package/dist/prospect-audit-KSXUNQ5J.js +174 -0
- package/dist/prospect-audit-KSXUNQ5J.js.map +1 -0
- package/dist/prospect-audits-3PMIO73D.js +19 -0
- package/dist/prospect-audits-3PMIO73D.js.map +1 -0
- package/dist/{protection-audit-64PLLBLI.js → protection-audit-SJO53MEQ.js} +3 -3
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/render-IFOGFB5T.js +10 -0
- package/dist/render-IFOGFB5T.js.map +1 -0
- package/dist/render-from-row-BWSOIYDS.js +17 -0
- package/dist/render-from-row-BWSOIYDS.js.map +1 -0
- package/dist/{renovate-dispatch-ICJGSFCE.js → renovate-dispatch-6GQWTRKX.js} +12 -12
- package/dist/{renovate-dispatch-ICJGSFCE.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
- package/dist/replay-C2LXMJZP.js +66 -0
- package/dist/replay-C2LXMJZP.js.map +1 -0
- package/dist/{report-6CKSWJNG.js → report-R3E3Y7HR.js} +100 -32
- package/dist/report-R3E3Y7HR.js.map +1 -0
- package/dist/report-mirror-4MNORYQ4.js +49 -0
- package/dist/report-mirror-4MNORYQ4.js.map +1 -0
- package/dist/reports/header-image/assets/headline-announcement.png +0 -0
- package/dist/reports/header-image/assets/headline-launch.png +0 -0
- package/dist/reports/header-image/assets/headline-maintenance.png +0 -0
- package/dist/reports/header-image/assets/headline-testing.png +0 -0
- package/dist/reports/header-image/assets/plate-clean.png +0 -0
- package/dist/rerender-3FM6DNRH.js +32 -0
- package/dist/rerender-3FM6DNRH.js.map +1 -0
- package/dist/{self-updating-PPQVV6UA.js → self-updating-UCIWESAX.js} +9 -9
- package/dist/{selftest-CQ6NOX52.js → selftest-VGHWPN72.js} +31 -24
- package/dist/selftest-VGHWPN72.js.map +1 -0
- package/dist/site-mirror-NIREX5ME.js +52 -0
- package/dist/site-mirror-NIREX5ME.js.map +1 -0
- package/dist/smoke-B2GQOSIZ.js +20 -0
- package/dist/smoke-B2GQOSIZ.js.map +1 -0
- package/dist/{smoke-suite-TMCSOYRK.js → smoke-suite-U3UKD3NS.js} +9 -9
- package/dist/{submissions-GJ6ITBRH.js → submissions-IQEJX6FK.js} +3 -3
- package/dist/{svelte-codemods-ZOBDHIES.js → svelte-codemods-GOKXTBHW.js} +9 -9
- package/dist/sync-CTUVXMCK.js +44 -0
- package/dist/sync-CTUVXMCK.js.map +1 -0
- package/dist/{sync-configs-CJXUX6MD.js → sync-configs-SPTLFJCC.js} +9 -9
- package/dist/{upgrade-WLX27UPC.js → upgrade-W6ZR3FJH.js} +9 -9
- package/dist/webhook-VOGOW3W6.js +38 -0
- package/dist/webhook-VOGOW3W6.js.map +1 -0
- package/dist/{websites-SADZR4XQ.js → websites-D6WYFAES.js} +6 -2
- package/dist/websites-D6WYFAES.js.map +1 -0
- package/dist/{write-audits-to-airtable-7PH75YWY.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
- package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
- package/package.json +9 -2
- package/dist/airtable-HRKSA7V4.js +0 -9
- package/dist/announce-GIAYN67S.js.map +0 -1
- package/dist/chunk-37S6L6G4.js.map +0 -1
- package/dist/chunk-3YXDSCM6.js.map +0 -1
- package/dist/chunk-4DHQCN5H.js.map +0 -1
- package/dist/chunk-4SOCPK7A.js +0 -108
- package/dist/chunk-4SOCPK7A.js.map +0 -1
- package/dist/chunk-577HCGZO.js.map +0 -1
- package/dist/chunk-62VF32IC.js.map +0 -1
- package/dist/chunk-6QRLRT3P.js.map +0 -1
- package/dist/chunk-COZPL62X.js.map +0 -1
- package/dist/chunk-D4FVAIEJ.js.map +0 -1
- package/dist/chunk-DTTIFYAM.js.map +0 -1
- package/dist/chunk-MGR2H7F4.js.map +0 -1
- package/dist/chunk-MPBXOJ3N.js.map +0 -1
- package/dist/chunk-NWB4O5EY.js.map +0 -1
- package/dist/chunk-SEEA6RXF.js.map +0 -1
- package/dist/chunk-U5SZ4FOP.js +0 -26
- package/dist/chunk-U5SZ4FOP.js.map +0 -1
- package/dist/client-X5WUJWDE.js +0 -11
- package/dist/db-JSIZYT23.js +0 -23
- package/dist/db-JSIZYT23.js.map +0 -1
- package/dist/ensure-site-S25MDDHW.js.map +0 -1
- package/dist/forms-notify-target-TWN4EH6K.js.map +0 -1
- package/dist/github-signals-G4ILZAJ6.js.map +0 -1
- package/dist/header-image-T7XD37JL.js.map +0 -1
- package/dist/launch-JA7Z4CIM.js.map +0 -1
- package/dist/migrate-LJUFGXQZ.js +0 -7
- package/dist/orchestrate-KDNXZQX7.js +0 -27
- package/dist/prismic-models-PVES6R5V.js.map +0 -1
- package/dist/report-6CKSWJNG.js.map +0 -1
- package/dist/reports/header-image/assets/plate.png +0 -0
- package/dist/selftest-CQ6NOX52.js.map +0 -1
- /package/dist/{airtable-HRKSA7V4.js.map → airtable-ZBUPQJSC.js.map} +0 -0
- /package/dist/{client-X5WUJWDE.js.map → attachments-NLZIJ5NU.js.map} +0 -0
- /package/dist/{bump-deps-SWJB6H5V.js.map → bump-deps-CGVELRLK.js.map} +0 -0
- /package/dist/{chunk-SDUJIANJ.js.map → chunk-2NJQLSEU.js.map} +0 -0
- /package/dist/{chunk-ZTPPCHDB.js.map → chunk-3G25KIWW.js.map} +0 -0
- /package/dist/{chunk-THEFTZ7F.js.map → chunk-62CMMGEO.js.map} +0 -0
- /package/dist/{chunk-FDIFTIXC.js.map → chunk-6TWWY7ZU.js.map} +0 -0
- /package/dist/{chunk-PBWDDDQH.js.map → chunk-7BZUOZPU.js.map} +0 -0
- /package/dist/{chunk-UYCAF77E.js.map → chunk-7RJ2HMMJ.js.map} +0 -0
- /package/dist/{chunk-Q276MZ7E.js.map → chunk-7UAYMAKR.js.map} +0 -0
- /package/dist/{chunk-R6FPFMOS.js.map → chunk-BQRFQSGY.js.map} +0 -0
- /package/dist/{chunk-7J53SUT6.js.map → chunk-CROKUHJB.js.map} +0 -0
- /package/dist/{chunk-GZNBCHAZ.js.map → chunk-HEWSRXYV.js.map} +0 -0
- /package/dist/{chunk-LS6QPANO.js.map → chunk-IYSEC543.js.map} +0 -0
- /package/dist/{chunk-DC5H7KEQ.js.map → chunk-JHGHLKFS.js.map} +0 -0
- /package/dist/{chunk-TP5M2NQB.js.map → chunk-NCMZMLY7.js.map} +0 -0
- /package/dist/{chunk-WYYQK4D3.js.map → chunk-TD2NNXLZ.js.map} +0 -0
- /package/dist/{chunk-ZMIPG6TR.js.map → chunk-TVL5MVUF.js.map} +0 -0
- /package/dist/{chunk-VRWUL4TR.js.map → chunk-XHHMM5HX.js.map} +0 -0
- /package/dist/{chunk-RJB3QUSP.js.map → chunk-XIDUYLSR.js.map} +0 -0
- /package/dist/{chunk-QG6OSL4M.js.map → chunk-XUGSZP7Z.js.map} +0 -0
- /package/dist/{chunk-S56JQJ4F.js.map → chunk-Y53WOQIJ.js.map} +0 -0
- /package/dist/{chunk-ICXV25N7.js.map → chunk-Z3KYFUN6.js.map} +0 -0
- /package/dist/{lighthouse-airtable-BIPM26SZ.js.map → client-AUE3XUPO.js.map} +0 -0
- /package/dist/{convert-to-pnpm-AT52NWZB.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
- /package/dist/{migrate-LJUFGXQZ.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
- /package/dist/{health-endpoint-242WG2OJ.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
- /package/dist/{orchestrate-KDNXZQX7.js.map → import-airtable-OHVY344G.js.map} +0 -0
- /package/dist/{websites-SADZR4XQ.js.map → lighthouse-VHI77PFB.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-7PH75YWY.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
- /package/dist/{onboard-53CITIM7.js.map → onboard-O4IAHJMO.js.map} +0 -0
- /package/dist/{prismic-ci-UTQ6LRR3.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
- /package/dist/{protection-audit-64PLLBLI.js.map → protection-audit-SJO53MEQ.js.map} +0 -0
- /package/dist/{self-updating-PPQVV6UA.js.map → self-updating-UCIWESAX.js.map} +0 -0
- /package/dist/{smoke-suite-TMCSOYRK.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
- /package/dist/{submissions-GJ6ITBRH.js.map → submissions-IQEJX6FK.js.map} +0 -0
- /package/dist/{svelte-codemods-ZOBDHIES.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
- /package/dist/{sync-configs-CJXUX6MD.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
- /package/dist/{upgrade-WLX27UPC.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAINTENANCE_CHECKLIST,
|
|
3
|
+
TESTING_CHECKLIST
|
|
4
|
+
} from "./chunk-VAD43MOR.js";
|
|
5
|
+
import {
|
|
6
|
+
siteSlug
|
|
7
|
+
} from "./chunk-7NYN2P6I.js";
|
|
8
|
+
|
|
9
|
+
// src/db/import-airtable.ts
|
|
10
|
+
var EXCLUDED_WEBSITE_FIELDS = /* @__PURE__ */ new Set([
|
|
11
|
+
// Dropped under D4's rule (empty everywhere + unreferenced), verified 2026-08-23.
|
|
12
|
+
"site host username",
|
|
13
|
+
"site host password",
|
|
14
|
+
"launch day",
|
|
15
|
+
"contract link",
|
|
16
|
+
// Plaintext credentials — operator ruling 2026-08-23: these live on ONLY in
|
|
17
|
+
// the frozen Airtable base. A new store does not inherit plaintext creds.
|
|
18
|
+
"DNS username",
|
|
19
|
+
"DNS password",
|
|
20
|
+
"cms username",
|
|
21
|
+
"cms password",
|
|
22
|
+
// Link columns: Turso joins by rec id (D1); the links carry no extra data.
|
|
23
|
+
"Reports",
|
|
24
|
+
"Submissions",
|
|
25
|
+
"Spam Screenouts",
|
|
26
|
+
// Regenerated into sites.header_image as a BLOB by the header-image CLI (D5);
|
|
27
|
+
// the Airtable attachment (an expiring signed URL) is never migrated.
|
|
28
|
+
"Header image"
|
|
29
|
+
]);
|
|
30
|
+
var s = (v) => {
|
|
31
|
+
if (v === void 0 || v === null) return null;
|
|
32
|
+
if (typeof v === "string") return v.trim() === "" ? null : v;
|
|
33
|
+
return String(v);
|
|
34
|
+
};
|
|
35
|
+
var n = (v) => typeof v === "number" ? v : null;
|
|
36
|
+
var b01 = (v) => v === true ? 1 : 0;
|
|
37
|
+
var b01n = (v) => typeof v === "boolean" ? v ? 1 : 0 : null;
|
|
38
|
+
var json = (v) => v === void 0 ? null : JSON.stringify(v);
|
|
39
|
+
var SITE_FIELDS = {
|
|
40
|
+
Name: "name",
|
|
41
|
+
url: "url",
|
|
42
|
+
Status: "status",
|
|
43
|
+
"point of contact": "point_of_contact",
|
|
44
|
+
"maintenence freq": "maintenance_freq",
|
|
45
|
+
// Airtable's misspelling dies at this boundary
|
|
46
|
+
"testing freq": "testing_freq",
|
|
47
|
+
"maintenance day": "maintenance_day",
|
|
48
|
+
"testing day": "testing_day",
|
|
49
|
+
"GA4 property ID": "ga4_property_id",
|
|
50
|
+
"Search query": "search_query",
|
|
51
|
+
"Search Console property": "search_console_property",
|
|
52
|
+
"Git repo": "git_repo",
|
|
53
|
+
"Netlify ID": "netlify_id",
|
|
54
|
+
"Report recipients (To)": "report_recipients_to",
|
|
55
|
+
"Report recipients (CC)": "report_recipients_cc",
|
|
56
|
+
"Copy \u2014 Intro": "copy_intro",
|
|
57
|
+
"Copy \u2014 Contact": "copy_contact",
|
|
58
|
+
"Copy \u2014 Footer": "copy_footer",
|
|
59
|
+
"Newsletter Webhook": "newsletter_webhook",
|
|
60
|
+
"Mailchimp API Key": "mailchimp_api_key",
|
|
61
|
+
"Mailchimp Audience ID": "mailchimp_audience_id",
|
|
62
|
+
"Notify Routing": "notify_routing",
|
|
63
|
+
"Prismic Ack Until": "prismic_ack_until",
|
|
64
|
+
"Launched at": "launched_at",
|
|
65
|
+
// Non-text columns. They live here so `mirrorSiteField` can resolve them like
|
|
66
|
+
// any other editor field; the coercion that makes them non-text is in
|
|
67
|
+
// `siteValueFor`, which BOTH this importer and that mirror go through.
|
|
68
|
+
"Require Turnstile": "require_turnstile",
|
|
69
|
+
"Accepted Watch Conditions": "accepted_watch_conditions"
|
|
70
|
+
};
|
|
71
|
+
function normalizeAwc(raw) {
|
|
72
|
+
if (Array.isArray(raw)) {
|
|
73
|
+
return raw.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
if (typeof raw === "string") {
|
|
76
|
+
return raw.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
function siteValueFor(column, raw) {
|
|
81
|
+
if (column === "require_turnstile") return b01(raw);
|
|
82
|
+
if (column === "accepted_watch_conditions") {
|
|
83
|
+
const awc = normalizeAwc(raw);
|
|
84
|
+
return awc.length > 0 ? JSON.stringify(awc) : null;
|
|
85
|
+
}
|
|
86
|
+
return s(raw);
|
|
87
|
+
}
|
|
88
|
+
var HEALTH_FIELDS = {
|
|
89
|
+
pScore: "p_score",
|
|
90
|
+
rScore: "r_score",
|
|
91
|
+
bpScore: "bp_score",
|
|
92
|
+
seoScore: "seo_score",
|
|
93
|
+
"Last lighthouse audit at": "lighthouse_at",
|
|
94
|
+
"A11y Violations": "a11y_violations",
|
|
95
|
+
"Deps Drifted": "deps_drifted",
|
|
96
|
+
"Deps Major Behind": "deps_major_behind",
|
|
97
|
+
"Deps Outdated": "deps_outdated",
|
|
98
|
+
"Deps Major Outdated": "deps_major_outdated",
|
|
99
|
+
"Security Vulns Critical": "vulns_critical",
|
|
100
|
+
"Security Vulns High": "vulns_high",
|
|
101
|
+
"Security Vulns Moderate": "vulns_moderate",
|
|
102
|
+
"Security Vulns Low": "vulns_low",
|
|
103
|
+
"Last security audit at": "security_audit_at",
|
|
104
|
+
"Security advisories": "security_advisories",
|
|
105
|
+
"Security Auto-Fix Attempts": "auto_fix_attempts",
|
|
106
|
+
"Analytics soft-fail at": "analytics_soft_fail_at",
|
|
107
|
+
"Cert days remaining": "cert_days_remaining",
|
|
108
|
+
"Domain checked at": "domain_checked_at",
|
|
109
|
+
"Deploy status": "deploy_status",
|
|
110
|
+
"Last deploy at": "last_deploy_at",
|
|
111
|
+
"Deploy log URL": "deploy_log_url",
|
|
112
|
+
"Deploy checked at": "deploy_checked_at",
|
|
113
|
+
"Function health": "function_health",
|
|
114
|
+
"CMS Reachable": "cms_reachable",
|
|
115
|
+
"Turnstile widget": "turnstile_widget",
|
|
116
|
+
"Function health checked at": "function_health_checked_at",
|
|
117
|
+
"Broken links": "broken_links",
|
|
118
|
+
"Browser checked at": "browser_checked_at",
|
|
119
|
+
"Uptime Reachable": "uptime_reachable",
|
|
120
|
+
"Titles & Meta OK": "titles_meta_ok",
|
|
121
|
+
"Smoke OK": "smoke_ok",
|
|
122
|
+
"Last Smoke At": "last_smoke_at",
|
|
123
|
+
"Form E2E OK": "form_e2e_ok",
|
|
124
|
+
"Form E2E checked at": "form_e2e_checked_at",
|
|
125
|
+
"Renovate Failing CIs": "renovate_failing_cis",
|
|
126
|
+
"Default Branch CI": "default_branch_ci",
|
|
127
|
+
"Last Commit At": "last_commit_at",
|
|
128
|
+
"GitHub Signals At": "github_signals_at",
|
|
129
|
+
"Prismic Models": "prismic_models",
|
|
130
|
+
"Prismic Models Checked At": "prismic_models_checked_at",
|
|
131
|
+
"Prismic Models Drift": "prismic_models_drift"
|
|
132
|
+
};
|
|
133
|
+
var HEALTH_NUMERIC = /* @__PURE__ */ new Set([
|
|
134
|
+
"p_score",
|
|
135
|
+
"r_score",
|
|
136
|
+
"bp_score",
|
|
137
|
+
"seo_score",
|
|
138
|
+
"a11y_violations",
|
|
139
|
+
"deps_drifted",
|
|
140
|
+
"deps_major_behind",
|
|
141
|
+
"deps_outdated",
|
|
142
|
+
"deps_major_outdated",
|
|
143
|
+
"vulns_critical",
|
|
144
|
+
"vulns_high",
|
|
145
|
+
"vulns_moderate",
|
|
146
|
+
"vulns_low",
|
|
147
|
+
"auto_fix_attempts",
|
|
148
|
+
"cert_days_remaining",
|
|
149
|
+
"broken_links",
|
|
150
|
+
"renovate_failing_cis"
|
|
151
|
+
]);
|
|
152
|
+
var HEALTH_BOOLEAN = {
|
|
153
|
+
"Crossbrowser OK": "crossbrowser_ok",
|
|
154
|
+
"Mobile OK": "mobile_ok",
|
|
155
|
+
"Links OK": "links_ok"
|
|
156
|
+
};
|
|
157
|
+
var SCHEDULE_FIELDS = {
|
|
158
|
+
"Next maintenance at": "next_maintenance_at",
|
|
159
|
+
"Next testing at": "next_testing_at"
|
|
160
|
+
};
|
|
161
|
+
function healthColumnFor(field) {
|
|
162
|
+
const direct = HEALTH_FIELDS[field];
|
|
163
|
+
if (direct) return { col: direct, coerce: HEALTH_NUMERIC.has(direct) ? n : s };
|
|
164
|
+
const bool = HEALTH_BOOLEAN[field];
|
|
165
|
+
if (bool) return { col: bool, coerce: b01n };
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
function scheduleColumnFor(field) {
|
|
169
|
+
const col = SCHEDULE_FIELDS[field];
|
|
170
|
+
return col ? { col, coerce: s } : null;
|
|
171
|
+
}
|
|
172
|
+
function mapWebsiteRecord(rec, computedAt) {
|
|
173
|
+
const f = rec.fields;
|
|
174
|
+
const name = s(f["Name"]);
|
|
175
|
+
if (!name) throw new Error(`Websites ${rec.id}: blank Name \u2014 cannot derive a slug`);
|
|
176
|
+
const slug = siteSlug(name);
|
|
177
|
+
if (!slug) throw new Error(`Websites ${rec.id}: Name "${name}" yields an empty slug`);
|
|
178
|
+
const site = {
|
|
179
|
+
id: rec.id,
|
|
180
|
+
slug,
|
|
181
|
+
name,
|
|
182
|
+
url: null,
|
|
183
|
+
status: null,
|
|
184
|
+
point_of_contact: null,
|
|
185
|
+
maintenance_freq: null,
|
|
186
|
+
testing_freq: null,
|
|
187
|
+
maintenance_day: null,
|
|
188
|
+
testing_day: null,
|
|
189
|
+
ga4_property_id: null,
|
|
190
|
+
search_query: null,
|
|
191
|
+
search_console_property: null,
|
|
192
|
+
git_repo: null,
|
|
193
|
+
netlify_id: null,
|
|
194
|
+
report_recipients_to: null,
|
|
195
|
+
report_recipients_cc: null,
|
|
196
|
+
copy_intro: null,
|
|
197
|
+
copy_contact: null,
|
|
198
|
+
copy_footer: null,
|
|
199
|
+
newsletter_webhook: null,
|
|
200
|
+
mailchimp_api_key: null,
|
|
201
|
+
mailchimp_audience_id: null,
|
|
202
|
+
notify_routing: null,
|
|
203
|
+
require_turnstile: 0,
|
|
204
|
+
accepted_watch_conditions: null,
|
|
205
|
+
prismic_ack_until: null,
|
|
206
|
+
launched_at: null,
|
|
207
|
+
legacy: null
|
|
208
|
+
};
|
|
209
|
+
for (const [field, col] of Object.entries(SITE_FIELDS)) {
|
|
210
|
+
if (col === "name") continue;
|
|
211
|
+
site[col] = siteValueFor(col, f[field]);
|
|
212
|
+
}
|
|
213
|
+
const health = { site_id: rec.id };
|
|
214
|
+
for (const field of [...Object.keys(HEALTH_FIELDS), ...Object.keys(HEALTH_BOOLEAN)]) {
|
|
215
|
+
const m = healthColumnFor(field);
|
|
216
|
+
if (m) health[m.col] = m.coerce(f[field]);
|
|
217
|
+
}
|
|
218
|
+
const schedule = {
|
|
219
|
+
site_id: rec.id,
|
|
220
|
+
next_maintenance_at: s(f["Next maintenance at"]),
|
|
221
|
+
next_testing_at: s(f["Next testing at"]),
|
|
222
|
+
computed_at: computedAt
|
|
223
|
+
};
|
|
224
|
+
const claimed = /* @__PURE__ */ new Set([
|
|
225
|
+
"Name",
|
|
226
|
+
...Object.keys(SITE_FIELDS),
|
|
227
|
+
...Object.keys(HEALTH_FIELDS),
|
|
228
|
+
...Object.keys(HEALTH_BOOLEAN),
|
|
229
|
+
...Object.keys(SCHEDULE_FIELDS)
|
|
230
|
+
]);
|
|
231
|
+
const legacy = {};
|
|
232
|
+
for (const [field, value] of Object.entries(f)) {
|
|
233
|
+
if (claimed.has(field) || EXCLUDED_WEBSITE_FIELDS.has(field)) continue;
|
|
234
|
+
legacy[field] = value;
|
|
235
|
+
}
|
|
236
|
+
site.legacy = Object.keys(legacy).length > 0 ? JSON.stringify(legacy) : null;
|
|
237
|
+
return { site, health, schedule };
|
|
238
|
+
}
|
|
239
|
+
var CHECKLIST_BY_FIELD = [
|
|
240
|
+
...MAINTENANCE_CHECKLIST,
|
|
241
|
+
...TESTING_CHECKLIST
|
|
242
|
+
].map((i) => ({ field: i.field, key: i.key }));
|
|
243
|
+
function mapReportRecord(rec, renderedHtml) {
|
|
244
|
+
const f = rec.fields;
|
|
245
|
+
const linkSites = f["Site"] ?? [];
|
|
246
|
+
const checklist = {};
|
|
247
|
+
for (const { field, key } of CHECKLIST_BY_FIELD) checklist[key] = Boolean(f[field]);
|
|
248
|
+
return {
|
|
249
|
+
id: rec.id,
|
|
250
|
+
site_id: linkSites[0] ?? null,
|
|
251
|
+
report_id: s(f["Report ID"]),
|
|
252
|
+
report_type: s(f["Report type"]),
|
|
253
|
+
period: s(f["Period"]),
|
|
254
|
+
period_start: s(f["Period start"]),
|
|
255
|
+
period_end: s(f["Period end"]),
|
|
256
|
+
completed_on: s(f["Completed on"]),
|
|
257
|
+
lighthouse_performance: n(f["Lighthouse \u2014 Performance"]),
|
|
258
|
+
lighthouse_accessibility: n(f["Lighthouse \u2014 Accessibility"]),
|
|
259
|
+
lighthouse_best_practices: n(f["Lighthouse \u2014 Best Practices"]),
|
|
260
|
+
lighthouse_seo: n(f["Lighthouse \u2014 SEO"]),
|
|
261
|
+
ga_users_current: n(f["GA users (period)"]),
|
|
262
|
+
ga_users_previous: n(f["GA users (prev period)"]),
|
|
263
|
+
search_found_page1: b01n(f["Search found page 1"]),
|
|
264
|
+
search_position: n(f["Search position"]),
|
|
265
|
+
last_tested_date: s(f["Last tested date"]),
|
|
266
|
+
commentary: s(f["Commentary"]),
|
|
267
|
+
subject_override: s(f["Subject override"]),
|
|
268
|
+
draft_ready: b01(f["Draft ready"]),
|
|
269
|
+
approved_to_send: b01(f["Approved to send"]),
|
|
270
|
+
approved_at: s(f["Approved At"]),
|
|
271
|
+
approved_by: s(f["Approved By"]),
|
|
272
|
+
send_override: b01(f["Send override"]),
|
|
273
|
+
override_reason: s(f["Override reason"]),
|
|
274
|
+
override_by: s(f["Override by"]),
|
|
275
|
+
override_at: s(f["Override at"]),
|
|
276
|
+
sent_at: s(f["Sent at"]),
|
|
277
|
+
delivery_status: s(f["Delivery status"]) ?? "pending",
|
|
278
|
+
resend_message_id: s(f["Resend message ID"]),
|
|
279
|
+
checklist: JSON.stringify(checklist),
|
|
280
|
+
// The Airtable cell is a long-text field, so the API hands us a STRING of
|
|
281
|
+
// JSON — store it verbatim. json() here would double-encode it, and
|
|
282
|
+
// parseAutoEvidence on the read side would then parse to a string and
|
|
283
|
+
// yield null (evidence silently lost). The non-string branch only exists
|
|
284
|
+
// for defensive completeness.
|
|
285
|
+
checklist_auto_evidence: typeof f["Checklist auto-evidence"] === "string" ? s(f["Checklist auto-evidence"]) : json(f["Checklist auto-evidence"] ?? void 0),
|
|
286
|
+
rendered_html: renderedHtml
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function renderedHtmlUrl(rec) {
|
|
290
|
+
const atts = rec.fields["Rendered HTML"];
|
|
291
|
+
const url = atts?.[0]?.url;
|
|
292
|
+
return typeof url === "string" && url.length > 0 ? url : null;
|
|
293
|
+
}
|
|
294
|
+
var REAP_FLOOR = 5;
|
|
295
|
+
var REAP_FRACTION = 0.1;
|
|
296
|
+
function reapAllowance(stored) {
|
|
297
|
+
return Math.max(REAP_FLOOR, Math.ceil(stored * REAP_FRACTION));
|
|
298
|
+
}
|
|
299
|
+
function formatReapSummary(reaped) {
|
|
300
|
+
const lines = [];
|
|
301
|
+
for (const id of reaped.sites) {
|
|
302
|
+
lines.push(`\u2702 reaped sites ${id} (+ its health/schedule rows) \u2014 no longer in Airtable`);
|
|
303
|
+
}
|
|
304
|
+
for (const id of reaped.reports) {
|
|
305
|
+
lines.push(`\u2702 reaped reports ${id} \u2014 no longer in Airtable`);
|
|
306
|
+
}
|
|
307
|
+
for (const reason of reaped.refusals) lines.push(`\u26A0 ${reason}`);
|
|
308
|
+
lines.push(
|
|
309
|
+
`FLEET_REAP sites=${reaped.sites.length} reports=${reaped.reports.length} refused=${reaped.refusals.length}`
|
|
310
|
+
);
|
|
311
|
+
return lines;
|
|
312
|
+
}
|
|
313
|
+
function reapRefusal(table, incoming, stored, toReap) {
|
|
314
|
+
if (incoming === 0 && stored > 0) {
|
|
315
|
+
return `${table}: REFUSED to reap \u2014 Airtable returned 0 rows while ${stored} are stored; that is a failed read, not an empty fleet. Nothing deleted.`;
|
|
316
|
+
}
|
|
317
|
+
const allowed = reapAllowance(stored);
|
|
318
|
+
if (toReap > allowed) {
|
|
319
|
+
return `${table}: REFUSED to reap \u2014 ${toReap} rows are missing from Airtable, over the ${allowed}-row allowance for ${stored} stored. Nothing deleted; re-run, and if the deletions are real, remove them in smaller batches.`;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
async function importFleetState(db, io, opts = {}) {
|
|
324
|
+
const computedAt = io.now().toISOString();
|
|
325
|
+
const websites = await io.listWebsiteRecords();
|
|
326
|
+
const mapped = websites.map((r) => mapWebsiteRecord(r, computedAt));
|
|
327
|
+
const slugOwner = /* @__PURE__ */ new Map();
|
|
328
|
+
for (const m of mapped) {
|
|
329
|
+
const prior = slugOwner.get(m.site.slug);
|
|
330
|
+
if (prior)
|
|
331
|
+
throw new Error(`slug collision: ${prior} and ${m.site.id} both map to '${m.site.slug}'`);
|
|
332
|
+
slugOwner.set(m.site.slug, m.site.id);
|
|
333
|
+
}
|
|
334
|
+
for (const { site, health, schedule } of mapped) {
|
|
335
|
+
await db.insertInto("sites").values(site).onConflict((oc) => oc.column("id").doUpdateSet(site)).execute();
|
|
336
|
+
await db.insertInto("site_health").values(health).onConflict((oc) => oc.column("site_id").doUpdateSet(health)).execute();
|
|
337
|
+
await db.insertInto("site_schedule").values(schedule).onConflict((oc) => oc.column("site_id").doUpdateSet(schedule)).execute();
|
|
338
|
+
}
|
|
339
|
+
const reports = await io.listReportRecords();
|
|
340
|
+
const alreadyStored = opts.reportHtml === "when-missing" ? new Set(
|
|
341
|
+
(await db.selectFrom("reports").select("id").where("rendered_html", "is not", null).execute()).map((r) => r.id)
|
|
342
|
+
) : null;
|
|
343
|
+
const misses = [];
|
|
344
|
+
let fetched = 0;
|
|
345
|
+
let skipped = 0;
|
|
346
|
+
for (const rec of reports) {
|
|
347
|
+
const url = renderedHtmlUrl(rec);
|
|
348
|
+
const skip = url !== null && alreadyStored !== null && alreadyStored.has(rec.id);
|
|
349
|
+
if (skip) skipped++;
|
|
350
|
+
const html = url && !skip ? await io.fetchAttachment(url) : null;
|
|
351
|
+
if (url && !skip && html === null) misses.push(rec.id);
|
|
352
|
+
if (html !== null) fetched++;
|
|
353
|
+
const row = mapReportRecord(rec, html);
|
|
354
|
+
const { rendered_html: _rh, ...rowSansHtml } = row;
|
|
355
|
+
void _rh;
|
|
356
|
+
await db.insertInto("reports").values(row).onConflict((oc) => oc.column("id").doUpdateSet(html !== null ? row : rowSansHtml)).execute();
|
|
357
|
+
}
|
|
358
|
+
const reaped = { sites: [], reports: [], refusals: [] };
|
|
359
|
+
const liveSiteIds = new Set(mapped.map((m) => m.site.id));
|
|
360
|
+
const storedSites = (await db.selectFrom("sites").select("id").execute()).map(
|
|
361
|
+
(r) => String(r.id)
|
|
362
|
+
);
|
|
363
|
+
const staleSites = storedSites.filter((id) => !liveSiteIds.has(id)).sort();
|
|
364
|
+
const siteRefusal = reapRefusal("sites", websites.length, storedSites.length, staleSites.length);
|
|
365
|
+
if (siteRefusal) reaped.refusals.push(siteRefusal);
|
|
366
|
+
else if (staleSites.length > 0) {
|
|
367
|
+
await db.deleteFrom("site_health").where("site_id", "in", staleSites).execute();
|
|
368
|
+
await db.deleteFrom("site_schedule").where("site_id", "in", staleSites).execute();
|
|
369
|
+
await db.deleteFrom("sites").where("id", "in", staleSites).execute();
|
|
370
|
+
reaped.sites = staleSites;
|
|
371
|
+
}
|
|
372
|
+
const liveReportIds = new Set(reports.map((r) => r.id));
|
|
373
|
+
const storedReports = (await db.selectFrom("reports").select("id").execute()).map(
|
|
374
|
+
(r) => String(r.id)
|
|
375
|
+
);
|
|
376
|
+
const staleReports = storedReports.filter((id) => !liveReportIds.has(id)).sort();
|
|
377
|
+
const reportRefusal = reapRefusal(
|
|
378
|
+
"reports",
|
|
379
|
+
reports.length,
|
|
380
|
+
storedReports.length,
|
|
381
|
+
staleReports.length
|
|
382
|
+
);
|
|
383
|
+
if (reportRefusal) reaped.refusals.push(reportRefusal);
|
|
384
|
+
else if (staleReports.length > 0) {
|
|
385
|
+
await db.deleteFrom("reports").where("id", "in", staleReports).execute();
|
|
386
|
+
reaped.reports = staleReports;
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
sites: mapped.length,
|
|
390
|
+
reports: reports.length,
|
|
391
|
+
renderedHtmlMisses: misses,
|
|
392
|
+
renderedHtmlFetched: fetched,
|
|
393
|
+
renderedHtmlSkipped: skipped,
|
|
394
|
+
reaped
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export {
|
|
399
|
+
EXCLUDED_WEBSITE_FIELDS,
|
|
400
|
+
SITE_FIELDS,
|
|
401
|
+
siteValueFor,
|
|
402
|
+
HEALTH_FIELDS,
|
|
403
|
+
HEALTH_BOOLEAN,
|
|
404
|
+
SCHEDULE_FIELDS,
|
|
405
|
+
healthColumnFor,
|
|
406
|
+
scheduleColumnFor,
|
|
407
|
+
mapWebsiteRecord,
|
|
408
|
+
mapReportRecord,
|
|
409
|
+
renderedHtmlUrl,
|
|
410
|
+
reapAllowance,
|
|
411
|
+
formatReapSummary,
|
|
412
|
+
reapRefusal,
|
|
413
|
+
importFleetState
|
|
414
|
+
};
|
|
415
|
+
//# sourceMappingURL=chunk-P6AORG32.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/import-airtable.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { SitesTable, SiteHealthTable, SiteScheduleTable, ReportsTable } from \"./schema.js\";\nimport { siteSlug } from \"../reports/airtable/websites.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\n\n/** One raw Airtable record: id + the fields object exactly as the API returns it\n * (empty cells are ABSENT, not null — the API omits them). */\nexport type RawRecord = { id: string; fields: Record<string, unknown> };\n\n/**\n * Phase 1.3 of #539: map raw Airtable records into the 0007 fleet-state tables,\n * and upsert them idempotently.\n *\n * The mapping here is the SINGLE source of truth for Airtable-column →\n * Turso-column: the parity harness (`db parity`) imports these same functions,\n * so \"what the importer writes\" and \"what parity expects\" cannot drift apart.\n *\n * Values are stored RAW (the cell as Airtable returns it, stringified where the\n * column is TEXT). Coercion — `toFrequency`, `toVerdict`, recipient splitting —\n * stays in the READ layer, exactly as `mapRow` does it today, so the store\n * never bakes in a lossy interpretation. The two exceptions are shapes, not\n * meanings: `Accepted Watch Conditions` normalizes to a JSON array (mapRow\n * accepts array or delimited string; the new store keeps one shape), and the\n * report checklist re-keys from Airtable column names to the stable keys in\n * src/reports/checklist.ts.\n */\n\n/** Websites columns that must NEVER reach Turso, and why. Everything else that\n * is populated but unmapped lands in `sites.legacy` (JSON, keyed by original\n * column name) so no data is silently dropped. */\nexport const EXCLUDED_WEBSITE_FIELDS: ReadonlySet<string> = new Set([\n // Dropped under D4's rule (empty everywhere + unreferenced), verified 2026-08-23.\n \"site host username\",\n \"site host password\",\n \"launch day\",\n \"contract link\",\n // Plaintext credentials — operator ruling 2026-08-23: these live on ONLY in\n // the frozen Airtable base. A new store does not inherit plaintext creds.\n \"DNS username\",\n \"DNS password\",\n \"cms username\",\n \"cms password\",\n // Link columns: Turso joins by rec id (D1); the links carry no extra data.\n \"Reports\",\n \"Submissions\",\n \"Spam Screenouts\",\n // Regenerated into sites.header_image as a BLOB by the header-image CLI (D5);\n // the Airtable attachment (an expiring signed URL) is never migrated.\n \"Header image\",\n]);\n\nconst s = (v: unknown): string | null => {\n if (v === undefined || v === null) return null;\n if (typeof v === \"string\") return v.trim() === \"\" ? null : v;\n return String(v);\n};\nconst n = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\nconst b01 = (v: unknown): number => (v === true ? 1 : 0);\nconst b01n = (v: unknown): number | null => (typeof v === \"boolean\" ? (v ? 1 : 0) : null);\nconst json = (v: unknown): string | null => (v === undefined ? null : JSON.stringify(v));\n\n/** Direct field→column map for `sites` (operator-owned config). Exported for\n * the site-details write-through (fleet-state.mirrorSiteField), so the editor\n * and the importer share ONE Airtable-column → sites-column truth. */\nexport const SITE_FIELDS: Record<string, keyof SitesTable> = {\n Name: \"name\",\n url: \"url\",\n Status: \"status\",\n \"point of contact\": \"point_of_contact\",\n \"maintenence freq\": \"maintenance_freq\", // Airtable's misspelling dies at this boundary\n \"testing freq\": \"testing_freq\",\n \"maintenance day\": \"maintenance_day\",\n \"testing day\": \"testing_day\",\n \"GA4 property ID\": \"ga4_property_id\",\n \"Search query\": \"search_query\",\n \"Search Console property\": \"search_console_property\",\n \"Git repo\": \"git_repo\",\n \"Netlify ID\": \"netlify_id\",\n \"Report recipients (To)\": \"report_recipients_to\",\n \"Report recipients (CC)\": \"report_recipients_cc\",\n \"Copy — Intro\": \"copy_intro\",\n \"Copy — Contact\": \"copy_contact\",\n \"Copy — Footer\": \"copy_footer\",\n \"Newsletter Webhook\": \"newsletter_webhook\",\n \"Mailchimp API Key\": \"mailchimp_api_key\",\n \"Mailchimp Audience ID\": \"mailchimp_audience_id\",\n \"Notify Routing\": \"notify_routing\",\n \"Prismic Ack Until\": \"prismic_ack_until\",\n \"Launched at\": \"launched_at\",\n // Non-text columns. They live here so `mirrorSiteField` can resolve them like\n // any other editor field; the coercion that makes them non-text is in\n // `siteValueFor`, which BOTH this importer and that mirror go through.\n \"Require Turnstile\": \"require_turnstile\",\n \"Accepted Watch Conditions\": \"accepted_watch_conditions\",\n};\n\n/** Normalize an `Accepted Watch Conditions` cell (Airtable array, or a delimited\n * string) to the trimmed list both stores agree on. */\nfunction normalizeAwc(raw: unknown): string[] {\n if (Array.isArray(raw)) {\n return raw\n .filter((x): x is string => typeof x === \"string\")\n .map((x) => x.trim())\n .filter(Boolean);\n }\n if (typeof raw === \"string\") {\n return raw\n .split(/[\\n,]/)\n .map((x) => x.trim())\n .filter(Boolean);\n }\n return [];\n}\n\n/**\n * Resolve one Airtable Websites cell to the value its `sites` column stores.\n *\n * The ONE shared path between `mapWebsiteRecord` (full-row import) and\n * `fleet-state.mirrorSiteField` (the editor's write-through) — same contract and\n * the same reason as `healthColumnFor`: a coercion that exists twice will\n * eventually disagree, and here a disagreement is not subtle. Parity compares\n * raw-to-raw, so a mirror storing `\"true\"` where the importer stores `1` reds\n * every hourly run until the next import quietly papers over it.\n */\nexport function siteValueFor(column: keyof SitesTable, raw: unknown): string | number | null {\n if (column === \"require_turnstile\") return b01(raw);\n if (column === \"accepted_watch_conditions\") {\n const awc = normalizeAwc(raw);\n return awc.length > 0 ? JSON.stringify(awc) : null;\n }\n return s(raw);\n}\n\n/** Direct field→column map for `site_health` (nightly-cron-owned). Exported for\n * the Phase 3 writer mirrors (fleet-state.mirrorHealthFields) and their\n * lockstep tests, so the nightly writers and the importer share ONE\n * Airtable-column → site_health-column truth. */\nexport const HEALTH_FIELDS: Record<string, keyof SiteHealthTable> = {\n pScore: \"p_score\",\n rScore: \"r_score\",\n bpScore: \"bp_score\",\n seoScore: \"seo_score\",\n \"Last lighthouse audit at\": \"lighthouse_at\",\n \"A11y Violations\": \"a11y_violations\",\n \"Deps Drifted\": \"deps_drifted\",\n \"Deps Major Behind\": \"deps_major_behind\",\n \"Deps Outdated\": \"deps_outdated\",\n \"Deps Major Outdated\": \"deps_major_outdated\",\n \"Security Vulns Critical\": \"vulns_critical\",\n \"Security Vulns High\": \"vulns_high\",\n \"Security Vulns Moderate\": \"vulns_moderate\",\n \"Security Vulns Low\": \"vulns_low\",\n \"Last security audit at\": \"security_audit_at\",\n \"Security advisories\": \"security_advisories\",\n \"Security Auto-Fix Attempts\": \"auto_fix_attempts\",\n \"Analytics soft-fail at\": \"analytics_soft_fail_at\",\n \"Cert days remaining\": \"cert_days_remaining\",\n \"Domain checked at\": \"domain_checked_at\",\n \"Deploy status\": \"deploy_status\",\n \"Last deploy at\": \"last_deploy_at\",\n \"Deploy log URL\": \"deploy_log_url\",\n \"Deploy checked at\": \"deploy_checked_at\",\n \"Function health\": \"function_health\",\n \"CMS Reachable\": \"cms_reachable\",\n \"Turnstile widget\": \"turnstile_widget\",\n \"Function health checked at\": \"function_health_checked_at\",\n \"Broken links\": \"broken_links\",\n \"Browser checked at\": \"browser_checked_at\",\n \"Uptime Reachable\": \"uptime_reachable\",\n \"Titles & Meta OK\": \"titles_meta_ok\",\n \"Smoke OK\": \"smoke_ok\",\n \"Last Smoke At\": \"last_smoke_at\",\n \"Form E2E OK\": \"form_e2e_ok\",\n \"Form E2E checked at\": \"form_e2e_checked_at\",\n \"Renovate Failing CIs\": \"renovate_failing_cis\",\n \"Default Branch CI\": \"default_branch_ci\",\n \"Last Commit At\": \"last_commit_at\",\n \"GitHub Signals At\": \"github_signals_at\",\n \"Prismic Models\": \"prismic_models\",\n \"Prismic Models Checked At\": \"prismic_models_checked_at\",\n \"Prismic Models Drift\": \"prismic_models_drift\",\n};\n\n/** Numeric health columns (everything else in HEALTH_FIELDS stores as text). */\nconst HEALTH_NUMERIC: ReadonlySet<keyof SiteHealthTable> = new Set([\n \"p_score\",\n \"r_score\",\n \"bp_score\",\n \"seo_score\",\n \"a11y_violations\",\n \"deps_drifted\",\n \"deps_major_behind\",\n \"deps_outdated\",\n \"deps_major_outdated\",\n \"vulns_critical\",\n \"vulns_high\",\n \"vulns_moderate\",\n \"vulns_low\",\n \"auto_fix_attempts\",\n \"cert_days_remaining\",\n \"broken_links\",\n \"renovate_failing_cis\",\n]);\n\n/** Boolean-checkbox health columns (Airtable true/absent → 1/0/null). */\nexport const HEALTH_BOOLEAN: Record<string, keyof SiteHealthTable> = {\n \"Crossbrowser OK\": \"crossbrowser_ok\",\n \"Mobile OK\": \"mobile_ok\",\n \"Links OK\": \"links_ok\",\n};\n\n/** Fields consumed by the schedule table. */\nexport const SCHEDULE_FIELDS: Record<string, keyof SiteScheduleTable> = {\n \"Next maintenance at\": \"next_maintenance_at\",\n \"Next testing at\": \"next_testing_at\",\n};\n\n/** Resolve one Airtable health field to its site_health column AND the exact\n * coercion the importer applies to it. The ONE shared path between\n * mapWebsiteRecord (full-row import) and fleet-state.mirrorHealthFields\n * (partial write-through): a coercion that exists twice will eventually\n * disagree, and the hourly import silently papers over the loser. Returns\n * null for a field no health column claims. */\nexport function healthColumnFor(\n field: string,\n): { col: keyof SiteHealthTable; coerce: (v: unknown) => string | number | null } | null {\n const direct = HEALTH_FIELDS[field];\n if (direct) return { col: direct, coerce: HEALTH_NUMERIC.has(direct) ? n : s };\n const bool = HEALTH_BOOLEAN[field];\n if (bool) return { col: bool, coerce: b01n };\n return null;\n}\n\n/** The schedule twin of {@link healthColumnFor} (both columns store as text). */\nexport function scheduleColumnFor(\n field: string,\n): { col: keyof SiteScheduleTable; coerce: (v: unknown) => string | null } | null {\n const col = SCHEDULE_FIELDS[field];\n return col ? { col, coerce: s } : null;\n}\n\nexport type MappedWebsite = {\n site: Omit<\n SitesTable,\n \"header_image\" | \"header_image_filename\" | \"header_image_type\" | \"header_image_generated_at\"\n >;\n health: SiteHealthTable;\n schedule: SiteScheduleTable;\n};\n\n/** Map one raw Websites record into its three-table shape. Pure. */\nexport function mapWebsiteRecord(rec: RawRecord, computedAt: string): MappedWebsite {\n const f = rec.fields;\n const name = s(f[\"Name\"]);\n if (!name) throw new Error(`Websites ${rec.id}: blank Name — cannot derive a slug`);\n const slug = siteSlug(name);\n if (!slug) throw new Error(`Websites ${rec.id}: Name \"${name}\" yields an empty slug`);\n\n const site: MappedWebsite[\"site\"] = {\n id: rec.id,\n slug,\n name,\n url: null,\n status: null,\n point_of_contact: null,\n maintenance_freq: null,\n testing_freq: null,\n maintenance_day: null,\n testing_day: null,\n ga4_property_id: null,\n search_query: null,\n search_console_property: null,\n git_repo: null,\n netlify_id: null,\n report_recipients_to: null,\n report_recipients_cc: null,\n copy_intro: null,\n copy_contact: null,\n copy_footer: null,\n newsletter_webhook: null,\n mailchimp_api_key: null,\n mailchimp_audience_id: null,\n notify_routing: null,\n require_turnstile: 0,\n accepted_watch_conditions: null,\n prismic_ack_until: null,\n launched_at: null,\n legacy: null,\n };\n // Every mapped column goes through siteValueFor, so the non-text ones\n // (require_turnstile, accepted_watch_conditions) get the SAME coercion the\n // editor's write-through mirror applies. The literals above are placeholders\n // this loop overwrites; they exist only to satisfy the row type.\n for (const [field, col] of Object.entries(SITE_FIELDS)) {\n if (col === \"name\") continue; // handled above (validated)\n (site as unknown as Record<string, unknown>)[col] = siteValueFor(col, f[field]);\n }\n\n const health = { site_id: rec.id } as SiteHealthTable;\n // Through healthColumnFor — the SAME resolution+coercion the Phase 3 writer\n // mirrors use, so import and write-through cannot drift apart.\n for (const field of [...Object.keys(HEALTH_FIELDS), ...Object.keys(HEALTH_BOOLEAN)]) {\n const m = healthColumnFor(field);\n if (m) (health as unknown as Record<string, unknown>)[m.col] = m.coerce(f[field]);\n }\n\n const schedule: SiteScheduleTable = {\n site_id: rec.id,\n next_maintenance_at: s(f[\"Next maintenance at\"]),\n next_testing_at: s(f[\"Next testing at\"]),\n computed_at: computedAt,\n };\n\n // Everything populated that no table claims and no exclusion bans → legacy.\n const claimed = new Set<string>([\n \"Name\",\n ...Object.keys(SITE_FIELDS),\n ...Object.keys(HEALTH_FIELDS),\n ...Object.keys(HEALTH_BOOLEAN),\n ...Object.keys(SCHEDULE_FIELDS),\n ]);\n const legacy: Record<string, unknown> = {};\n for (const [field, value] of Object.entries(f)) {\n if (claimed.has(field) || EXCLUDED_WEBSITE_FIELDS.has(field)) continue;\n legacy[field] = value;\n }\n site.legacy = Object.keys(legacy).length > 0 ? JSON.stringify(legacy) : null;\n\n return { site, health, schedule };\n}\n\n/** Stable-key checklist mapping (Airtable column name → key). */\nconst CHECKLIST_BY_FIELD: ReadonlyArray<{ field: string; key: string }> = [\n ...MAINTENANCE_CHECKLIST,\n ...TESTING_CHECKLIST,\n].map((i) => ({ field: i.field, key: i.key }));\n\n/** Map one raw Reports record. `renderedHtml` is fetched by the caller (the\n * attachment URL is an expiring signed URL — see the design's Attachments\n * section); pass null when the record has no attachment or the fetch failed. */\nexport function mapReportRecord(rec: RawRecord, renderedHtml: string | null): ReportsTable {\n const f = rec.fields;\n const linkSites = (f[\"Site\"] as string[] | undefined) ?? [];\n const checklist: Record<string, boolean> = {};\n for (const { field, key } of CHECKLIST_BY_FIELD) checklist[key] = Boolean(f[field]);\n\n return {\n id: rec.id,\n site_id: linkSites[0] ?? null,\n report_id: s(f[\"Report ID\"]),\n report_type: s(f[\"Report type\"]),\n period: s(f[\"Period\"]),\n period_start: s(f[\"Period start\"]),\n period_end: s(f[\"Period end\"]),\n completed_on: s(f[\"Completed on\"]),\n lighthouse_performance: n(f[\"Lighthouse — Performance\"]),\n lighthouse_accessibility: n(f[\"Lighthouse — Accessibility\"]),\n lighthouse_best_practices: n(f[\"Lighthouse — Best Practices\"]),\n lighthouse_seo: n(f[\"Lighthouse — SEO\"]),\n ga_users_current: n(f[\"GA users (period)\"]),\n ga_users_previous: n(f[\"GA users (prev period)\"]),\n search_found_page1: b01n(f[\"Search found page 1\"]),\n search_position: n(f[\"Search position\"]),\n last_tested_date: s(f[\"Last tested date\"]),\n commentary: s(f[\"Commentary\"]),\n subject_override: s(f[\"Subject override\"]),\n draft_ready: b01(f[\"Draft ready\"]),\n approved_to_send: b01(f[\"Approved to send\"]),\n approved_at: s(f[\"Approved At\"]),\n approved_by: s(f[\"Approved By\"]),\n send_override: b01(f[\"Send override\"]),\n override_reason: s(f[\"Override reason\"]),\n override_by: s(f[\"Override by\"]),\n override_at: s(f[\"Override at\"]),\n sent_at: s(f[\"Sent at\"]),\n delivery_status: s(f[\"Delivery status\"]) ?? \"pending\",\n resend_message_id: s(f[\"Resend message ID\"]),\n checklist: JSON.stringify(checklist),\n // The Airtable cell is a long-text field, so the API hands us a STRING of\n // JSON — store it verbatim. json() here would double-encode it, and\n // parseAutoEvidence on the read side would then parse to a string and\n // yield null (evidence silently lost). The non-string branch only exists\n // for defensive completeness.\n checklist_auto_evidence:\n typeof f[\"Checklist auto-evidence\"] === \"string\"\n ? s(f[\"Checklist auto-evidence\"])\n : json(f[\"Checklist auto-evidence\"] ?? undefined),\n rendered_html: renderedHtml,\n };\n}\n\n/** First Rendered HTML attachment's signed URL, or null. Exposed so the CLI can\n * fetch it and tests can assert which URL would be fetched. */\nexport function renderedHtmlUrl(rec: RawRecord): string | null {\n const atts = rec.fields[\"Rendered HTML\"] as Array<{ url?: string }> | undefined;\n const url = atts?.[0]?.url;\n return typeof url === \"string\" && url.length > 0 ? url : null;\n}\n\n/** What the import removed because Airtable no longer has the record — and what\n * it declined to remove. `refusals` is non-empty only when the incoming read\n * looked untrustworthy; in that case NOTHING was deleted for that table. */\nexport type ReapSummary = {\n sites: string[];\n reports: string[];\n refusals: string[];\n};\n\n/** Reaping is the only destructive thing the importer does, and it acts on the\n * absence of evidence — so it is only ever as trustworthy as the read that\n * motivated it. A short read (a truncated page, a partial outage) is\n * indistinguishable from mass deletion by looking at the rows alone, so the\n * allowance is deliberately shaped like an operator tidying up rather than\n * like a bad page: at most 10% of what is stored, with a floor of 5 so a small\n * table stays workable. Over that, refuse the whole table and let parity red\n * the run — a wedged-red sync is recoverable, an emptied Turso is not. */\nconst REAP_FLOOR = 5;\nconst REAP_FRACTION = 0.1;\n\nexport function reapAllowance(stored: number): number {\n return Math.max(REAP_FLOOR, Math.ceil(stored * REAP_FRACTION));\n}\n\n/** Human lines plus the FLEET_REAP machine line, emitted on EVERY pass (nothing\n * reaped included) — an absent line must read as \"the reap never ran\", never as\n * \"it ran clean\", the same contract FLEET_PARITY and FLEET_SYNC hold. Shared by\n * `db sync` and the one-shot `db import-airtable`: both can delete, so a second\n * hand-rolled copy is how one of them ends up doing it quietly. */\nexport function formatReapSummary(reaped: ReapSummary): string[] {\n const lines: string[] = [];\n for (const id of reaped.sites) {\n lines.push(`✂ reaped sites ${id} (+ its health/schedule rows) — no longer in Airtable`);\n }\n for (const id of reaped.reports) {\n lines.push(`✂ reaped reports ${id} — no longer in Airtable`);\n }\n for (const reason of reaped.refusals) lines.push(`⚠ ${reason}`);\n lines.push(\n `FLEET_REAP sites=${reaped.sites.length} reports=${reaped.reports.length} ` +\n `refused=${reaped.refusals.length}`,\n );\n return lines;\n}\n\n/** Null when the reap may proceed, else the human reason it was refused. */\nexport function reapRefusal(\n table: string,\n incoming: number,\n stored: number,\n toReap: number,\n): string | null {\n if (incoming === 0 && stored > 0) {\n return `${table}: REFUSED to reap — Airtable returned 0 rows while ${stored} are stored; that is a failed read, not an empty fleet. Nothing deleted.`;\n }\n const allowed = reapAllowance(stored);\n if (toReap > allowed) {\n return `${table}: REFUSED to reap — ${toReap} rows are missing from Airtable, over the ${allowed}-row allowance for ${stored} stored. Nothing deleted; re-run, and if the deletions are real, remove them in smaller batches.`;\n }\n return null;\n}\n\nexport type ImportSummary = {\n sites: number;\n reports: number;\n /** Rows removed (or refused) because their Airtable record is gone. */\n reaped: ReapSummary;\n /** Reports whose Rendered HTML attachment could not be fetched (URL expired /\n * network) — imported with rendered_html null, named so the run is honest. */\n renderedHtmlMisses: string[];\n /** Attachment fetches attempted and succeeded this run. */\n renderedHtmlFetched: number;\n /** Fetches skipped because the stored row already carries rendered_html\n * (only in reportHtml: \"when-missing\" mode — the hourly sync's mode). */\n renderedHtmlSkipped: number;\n};\n\nexport type ImportOptions = {\n /** \"always\" (default): fetch every report's Rendered HTML attachment — the\n * one-shot full import's mode. \"when-missing\": fetch only for reports whose\n * stored row has no rendered_html yet — the hourly sync's mode, so 24 runs a\n * day don't re-download every attachment every hour. Safe for Phase 2:\n * nothing reads reports.rendered_html from Turso until the Phase 4 report\n * review lands; revisit the staleness story there (a re-rendered report's\n * stored HTML goes stale under \"when-missing\"). Parity is unaffected —\n * rendered_html is a parity SKIP_COLUMN. */\n reportHtml?: \"always\" | \"when-missing\";\n};\n\nexport type ImportIo = {\n listWebsiteRecords: () => Promise<RawRecord[]>;\n listReportRecords: () => Promise<RawRecord[]>;\n /** Fetch an attachment body; null on failure (the miss is reported, not fatal). */\n fetchAttachment: (url: string) => Promise<string | null>;\n now: () => Date;\n};\n\n/**\n * Import both tables, idempotently: upsert by rec id, so a re-run converges\n * instead of duplicating. On conflict the sites upsert deliberately does NOT\n * touch the header_image* columns — Airtable stopped being their source (D5),\n * so a re-import must never wipe a regenerated image. rendered_html is only\n * overwritten when the fetch succeeded, for the same reason: a re-run with an\n * expired signed URL must not erase a body captured while it was valid.\n */\nexport async function importFleetState(\n db: Db,\n io: ImportIo,\n opts: ImportOptions = {},\n): Promise<ImportSummary> {\n const computedAt = io.now().toISOString();\n const websites = await io.listWebsiteRecords();\n\n const mapped = websites.map((r) => mapWebsiteRecord(r, computedAt));\n const slugOwner = new Map<string, string>();\n for (const m of mapped) {\n const prior = slugOwner.get(m.site.slug);\n if (prior)\n throw new Error(`slug collision: ${prior} and ${m.site.id} both map to '${m.site.slug}'`);\n slugOwner.set(m.site.slug, m.site.id);\n }\n\n for (const { site, health, schedule } of mapped) {\n await db\n .insertInto(\"sites\")\n .values(site)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(site))\n .execute();\n await db\n .insertInto(\"site_health\")\n .values(health)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(health))\n .execute();\n await db\n .insertInto(\"site_schedule\")\n .values(schedule)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(schedule))\n .execute();\n }\n\n const reports = await io.listReportRecords();\n // \"when-missing\": one pre-read of which report rows already hold a body, so\n // the loop can skip their attachment fetches. A skipped fetch keeps the\n // stored body (the upsert falls into the rowSansHtml branch) and is counted,\n // not treated as a miss.\n const alreadyStored =\n opts.reportHtml === \"when-missing\"\n ? new Set(\n (\n await db\n .selectFrom(\"reports\")\n .select(\"id\")\n .where(\"rendered_html\", \"is not\", null)\n .execute()\n ).map((r) => r.id),\n )\n : null;\n const misses: string[] = [];\n let fetched = 0;\n let skipped = 0;\n for (const rec of reports) {\n const url = renderedHtmlUrl(rec);\n const skip = url !== null && alreadyStored !== null && alreadyStored.has(rec.id);\n if (skip) skipped++;\n const html = url && !skip ? await io.fetchAttachment(url) : null;\n if (url && !skip && html === null) misses.push(rec.id);\n if (html !== null) fetched++;\n const row = mapReportRecord(rec, html);\n const { rendered_html: _rh, ...rowSansHtml } = row;\n void _rh;\n await db\n .insertInto(\"reports\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(html !== null ? row : rowSansHtml))\n .execute();\n }\n\n const reaped: ReapSummary = { sites: [], reports: [], refusals: [] };\n\n // Sites first. No foreign keys are declared, so the health and schedule rows\n // must be removed explicitly — parity only reverse-checks `sites`, so an\n // orphan left here would linger forever, unnoticed and still readable.\n const liveSiteIds = new Set(mapped.map((m) => m.site.id));\n const storedSites = (await db.selectFrom(\"sites\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleSites = storedSites.filter((id) => !liveSiteIds.has(id)).sort();\n const siteRefusal = reapRefusal(\"sites\", websites.length, storedSites.length, staleSites.length);\n if (siteRefusal) reaped.refusals.push(siteRefusal);\n else if (staleSites.length > 0) {\n await db.deleteFrom(\"site_health\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"site_schedule\").where(\"site_id\", \"in\", staleSites).execute();\n await db.deleteFrom(\"sites\").where(\"id\", \"in\", staleSites).execute();\n reaped.sites = staleSites;\n }\n\n const liveReportIds = new Set(reports.map((r) => r.id));\n const storedReports = (await db.selectFrom(\"reports\").select(\"id\").execute()).map((r) =>\n String(r.id),\n );\n const staleReports = storedReports.filter((id) => !liveReportIds.has(id)).sort();\n const reportRefusal = reapRefusal(\n \"reports\",\n reports.length,\n storedReports.length,\n staleReports.length,\n );\n if (reportRefusal) reaped.refusals.push(reportRefusal);\n else if (staleReports.length > 0) {\n await db.deleteFrom(\"reports\").where(\"id\", \"in\", staleReports).execute();\n reaped.reports = staleReports;\n }\n\n return {\n sites: mapped.length,\n reports: reports.length,\n renderedHtmlMisses: misses,\n renderedHtmlFetched: fetched,\n renderedHtmlSkipped: skipped,\n reaped,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/util/free-port.ts
|
|
2
|
+
import { createServer } from "net";
|
|
3
|
+
async function findFreePort() {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
server.unref();
|
|
7
|
+
server.on("error", reject);
|
|
8
|
+
server.listen(0, "127.0.0.1", () => {
|
|
9
|
+
const addr = server.address();
|
|
10
|
+
if (typeof addr === "object" && addr) {
|
|
11
|
+
const port = addr.port;
|
|
12
|
+
server.close(() => resolve(port));
|
|
13
|
+
} else {
|
|
14
|
+
server.close();
|
|
15
|
+
reject(new Error("findFreePort: could not determine assigned port from socket"));
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function withFreePort(url, port) {
|
|
21
|
+
const u = new URL(url);
|
|
22
|
+
u.hostname = "localhost";
|
|
23
|
+
u.port = String(port);
|
|
24
|
+
return u.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
findFreePort,
|
|
29
|
+
withFreePort
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=chunk-PTDMVGKL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/util/free-port.ts"],"sourcesContent":["import { createServer } from \"node:net\";\n\n/**\n * Bind an ephemeral TCP port, capture it, release it, and return it. Used\n * by the lighthouse and a11y audits to pick a port the audit's own dev\n * server will then bind via `--strictPort`.\n *\n * Why: vite's default behavior on a busy port is to bump to the next free\n * one (5173 → 5174 → …). When zombie vite processes (or any squatter) are\n * already on 5173, the audit's spawned vite lands on a higher port, but\n * the audit tooling (lhci, playwright) still probes 5173 — hits the\n * zombie — gets stale 404s — fails with \"no manifest written\" / \"no\n * results written (exit 1)\". Reproduced on caltex 2026-05-28 with 10\n * orphaned vite processes accumulated across this repo, the reports repo,\n * and caltex itself. Allocating a free port up front + `--strictPort`\n * makes the audit immune to port collisions.\n *\n * TOCTOU note: the small window between close() and the spawned vite\n * binding is theoretically racy, but in practice we run one audit at a\n * time and the OS keeps the port free for re-use. If vite still fails to\n * bind under `--strictPort`, the audit fails loudly — that's the correct\n * outcome (vs. silently auditing the wrong server).\n */\nexport async function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = createServer();\n server.unref();\n server.on(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const addr = server.address();\n if (typeof addr === \"object\" && addr) {\n const port = addr.port;\n server.close(() => resolve(port));\n } else {\n server.close();\n reject(new Error(\"findFreePort: could not determine assigned port from socket\"));\n }\n });\n });\n}\n\n/**\n * Swap the port (and force `localhost` host) on a URL so it points at the\n * audit's freshly-allocated dev server. Preserves the path + any query.\n * Used to rewrite the lighthouse `url` so lhci probes the correct port.\n */\nexport function withFreePort(url: string, port: number): string {\n const u = new URL(url);\n u.hostname = \"localhost\";\n u.port = String(port);\n return u.toString();\n}\n"],"mappings":";AAAA,SAAS,oBAAoB;AAuB7B,eAAsB,eAAgC;AACpD,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,SAAS,aAAa;AAC5B,WAAO,MAAM;AACb,WAAO,GAAG,SAAS,MAAM;AACzB,WAAO,OAAO,GAAG,aAAa,MAAM;AAClC,YAAM,OAAO,OAAO,QAAQ;AAC5B,UAAI,OAAO,SAAS,YAAY,MAAM;AACpC,cAAM,OAAO,KAAK;AAClB,eAAO,MAAM,MAAM,QAAQ,IAAI,CAAC;AAAA,MAClC,OAAO;AACL,eAAO,MAAM;AACb,eAAO,IAAI,MAAM,6DAA6D,CAAC;AAAA,MACjF;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH;AAOO,SAAS,aAAa,KAAa,MAAsB;AAC9D,QAAM,IAAI,IAAI,IAAI,GAAG;AACrB,IAAE,WAAW;AACb,IAAE,OAAO,OAAO,IAAI;AACpB,SAAO,EAAE,SAAS;AACpB;","names":[]}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// src/reports/due.ts
|
|
2
|
-
var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
|
|
2
|
+
var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
|
|
3
|
+
"maintained",
|
|
4
|
+
"hosted-only"
|
|
5
|
+
]);
|
|
3
6
|
var MONTHS = {
|
|
4
7
|
Monthly: 1,
|
|
5
8
|
Quarterly: 3,
|
|
@@ -65,4 +68,4 @@ export {
|
|
|
65
68
|
findDueReports,
|
|
66
69
|
reportPeriodKey
|
|
67
70
|
};
|
|
68
|
-
//# sourceMappingURL=chunk-
|
|
71
|
+
//# sourceMappingURL=chunk-PZR4EBPY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/due.ts"],"sourcesContent":["import type { WebsiteRow, Frequency, Status } from \"./airtable/websites.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport type { ReportType } from \"./types.js\";\n\n/** Statuses where recurring Maintenance/Testing reports are appropriate. Only\n * LIVE sites: \"maintained\" and \"hosted-only\". Pre-launch\n * stages (\"building\" / \"launching\") are excluded — a not-yet-live site\n * must not be drafted a recurring Maintenance/Testing report (that's what was\n * emailing/alarming pre-launch sites); it starts its report cadence when a Launch\n * report flips its Status to \"maintained\". Launch reports themselves are a\n * separate manual flow (recipes/launch.ts), never scheduled here. \"archived\" /\n * \"external\" are dropped too. Sites with status=null pass through\n * (partial data; better to surface than silently skip). */\nexport const ELIGIBLE_STATUSES: ReadonlySet<Status> = new Set<Status>([\n \"maintained\",\n \"hosted-only\",\n]);\n\nexport type DueItem = {\n site: WebsiteRow;\n reportType: ReportType;\n /** Inclusive: the day the next report became due. */\n dueDate: Date;\n /** ISO date of the last `Sent at` for this (site, type), or null if there's never been one. */\n lastSent: string | null;\n};\n\nconst MONTHS: Record<Exclude<Frequency, \"None\">, number> = {\n Monthly: 1,\n Quarterly: 3,\n Yearly: 12,\n};\n\n/**\n * Add `n` calendar months in UTC, clamped to the last day of the target month.\n * Jan 31 + 1 month = Feb 28 (not Mar 3, which is what naive setMonth produces).\n * All-UTC accessors mean the result is timezone-independent.\n */\nfunction addMonths(d: Date, n: number): Date {\n const out = new Date(d);\n const day = out.getUTCDate();\n out.setUTCDate(1);\n out.setUTCMonth(out.getUTCMonth() + n);\n const lastDayOfTargetMonth = new Date(\n Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0),\n ).getUTCDate();\n out.setUTCDate(Math.min(day, lastDayOfTargetMonth));\n return out;\n}\n\n/** Truncate to UTC midnight. Avoids local-TZ skew when comparing Airtable date-only fields. */\nfunction startOfDay(d: Date): Date {\n const out = new Date(d);\n out.setUTCHours(0, 0, 0, 0);\n return out;\n}\n\nfunction lastSentForType(reports: ReportRow[], siteId: string, type: ReportType): string | null {\n const candidates = reports\n .filter((r) => r.siteId === siteId && r.reportType === type && r.sentAt !== null)\n .map((r) => r.sentAt!)\n .sort();\n return candidates[candidates.length - 1] ?? null;\n}\n\n/**\n * The next-due date for one (site, type): the date the next report of that type is\n * scheduled to draft, whether or not it's due yet. `null` when there's no schedule —\n * an ineligible status or a \"None\" frequency. Unrecognized/blank raw Airtable values\n * never reach here: mapRow's `toFrequency` trims, warns LOUDLY, and coerces them to\n * \"None\" at the read boundary.\n *\n * baseDate = the last `Sent at` for this (site, type), else the site's\n * `maintenance day`/`testing day` anchor. With no baseDate at all the next report is\n * due now (returns `today` at UTC midnight). Otherwise baseDate + frequency.\n *\n * Shared with {@link findDueReports} so the scheduler and any schedule display can't\n * drift on what \"next\" means.\n */\nexport function nextDueDate(\n site: WebsiteRow,\n reports: ReportRow[],\n type: ReportType,\n today: Date,\n): Date | null {\n if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) return null;\n const freq = type === \"Maintenance\" ? site.maintenanceFreq : site.testingFreq;\n if (freq === \"None\") return null;\n const lastSent = lastSentForType(reports, site.id, type);\n const fallback = type === \"Maintenance\" ? site.maintenanceDay : site.testingDay;\n const baseIso = lastSent ?? fallback;\n if (!baseIso) return startOfDay(today);\n return addMonths(new Date(baseIso), MONTHS[freq]);\n}\n\n/**\n * Computes which (site, type) pairs are due as of `today`.\n *\n * Algorithm per (site, type):\n * 1. If freq === \"None\", skip.\n * 2. baseDate = max(last Sent at for this type, site's `maintenance/testing day` fallback).\n * 3. If no baseDate exists at all, the site is due now.\n * 4. dueDate = baseDate + frequency months.\n * 5. Due iff startOfDay(today) >= startOfDay(dueDate).\n */\nexport function findDueReports(\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n): DueItem[] {\n const out: DueItem[] = [];\n const todayStart = startOfDay(today);\n\n for (const site of websites) {\n // Skip explicitly-non-active statuses (\"archived\", \"external\").\n // Null status is treated as active for backwards compat with rows that pre-date\n // the Status convention.\n if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;\n\n for (const type of [\"Maintenance\", \"Testing\"] as const) {\n const freq = type === \"Maintenance\" ? site.maintenanceFreq : site.testingFreq;\n // Intentional silent skip — \"None\" (also the coerced default for blank cells)\n // means \"no schedule\", not a mistake. A trailing-space or typo'd raw Airtable\n // value never reaches here: mapRow's `toFrequency` trims (so \"Quarterly \"\n // schedules) and warns LOUDLY on anything still unrecognized before coercing\n // it to \"None\" — the guard lives at the read boundary, not in this loop.\n if (freq === \"None\") continue;\n\n const lastSent = lastSentForType(reports, site.id, type);\n // Same computation as the schedule write-back uses. The guards above already\n // proved a schedule exists, so dueDate is non-null; a no-anchor site returns\n // `today`, so the comparison below pushes it as due-now (the prior behavior).\n const dueDate = nextDueDate(site, reports, type, today);\n if (dueDate !== null && todayStart.getTime() >= startOfDay(dueDate).getTime()) {\n out.push({ site, reportType: type, dueDate, lastSent });\n }\n }\n }\n\n return out;\n}\n\n/**\n * The UTC `YYYY-MM` of a `dueDate` from {@link findDueReports} — the per-recurrence\n * idempotency key for drafting. Monthly recurrences land in distinct months; quarterly\n * and yearly land in distinct due-months too, so this uniquely names one draft per cycle.\n * UTC accessors keep it timezone-independent, consistent with the rest of this module.\n */\nexport function reportPeriodKey(dueDate: Date): string {\n if (Number.isNaN(dueDate.getTime())) throw new TypeError(\"reportPeriodKey: invalid Date\");\n const year = dueDate.getUTCFullYear();\n const month = String(dueDate.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}-${month}`;\n}\n"],"mappings":";AAaO,IAAM,oBAAyC,oBAAI,IAAY;AAAA,EACpE;AAAA,EACA;AACF,CAAC;AAWD,IAAM,SAAqD;AAAA,EACzD,SAAS;AAAA,EACT,WAAW;AAAA,EACX,QAAQ;AACV;AAOA,SAAS,UAAU,GAAS,GAAiB;AAC3C,QAAM,MAAM,IAAI,KAAK,CAAC;AACtB,QAAM,MAAM,IAAI,WAAW;AAC3B,MAAI,WAAW,CAAC;AAChB,MAAI,YAAY,IAAI,YAAY,IAAI,CAAC;AACrC,QAAM,uBAAuB,IAAI;AAAA,IAC/B,KAAK,IAAI,IAAI,eAAe,GAAG,IAAI,YAAY,IAAI,GAAG,CAAC;AAAA,EACzD,EAAE,WAAW;AACb,MAAI,WAAW,KAAK,IAAI,KAAK,oBAAoB,CAAC;AAClD,SAAO;AACT;AAGA,SAAS,WAAW,GAAe;AACjC,QAAM,MAAM,IAAI,KAAK,CAAC;AACtB,MAAI,YAAY,GAAG,GAAG,GAAG,CAAC;AAC1B,SAAO;AACT;AAEA,SAAS,gBAAgB,SAAsB,QAAgB,MAAiC;AAC9F,QAAM,aAAa,QAChB,OAAO,CAAC,MAAM,EAAE,WAAW,UAAU,EAAE,eAAe,QAAQ,EAAE,WAAW,IAAI,EAC/E,IAAI,CAAC,MAAM,EAAE,MAAO,EACpB,KAAK;AACR,SAAO,WAAW,WAAW,SAAS,CAAC,KAAK;AAC9C;AAgBO,SAAS,YACd,MACA,SACA,MACA,OACa;AACb,MAAI,KAAK,WAAW,QAAQ,CAAC,kBAAkB,IAAI,KAAK,MAAM,EAAG,QAAO;AACxE,QAAM,OAAO,SAAS,gBAAgB,KAAK,kBAAkB,KAAK;AAClE,MAAI,SAAS,OAAQ,QAAO;AAC5B,QAAM,WAAW,gBAAgB,SAAS,KAAK,IAAI,IAAI;AACvD,QAAM,WAAW,SAAS,gBAAgB,KAAK,iBAAiB,KAAK;AACrE,QAAM,UAAU,YAAY;AAC5B,MAAI,CAAC,QAAS,QAAO,WAAW,KAAK;AACrC,SAAO,UAAU,IAAI,KAAK,OAAO,GAAG,OAAO,IAAI,CAAC;AAClD;AAYO,SAAS,eACd,UACA,SACA,OACW;AACX,QAAM,MAAiB,CAAC;AACxB,QAAM,aAAa,WAAW,KAAK;AAEnC,aAAW,QAAQ,UAAU;AAI3B,QAAI,KAAK,WAAW,QAAQ,CAAC,kBAAkB,IAAI,KAAK,MAAM,EAAG;AAEjE,eAAW,QAAQ,CAAC,eAAe,SAAS,GAAY;AACtD,YAAM,OAAO,SAAS,gBAAgB,KAAK,kBAAkB,KAAK;AAMlE,UAAI,SAAS,OAAQ;AAErB,YAAM,WAAW,gBAAgB,SAAS,KAAK,IAAI,IAAI;AAIvD,YAAM,UAAU,YAAY,MAAM,SAAS,MAAM,KAAK;AACtD,UAAI,YAAY,QAAQ,WAAW,QAAQ,KAAK,WAAW,OAAO,EAAE,QAAQ,GAAG;AAC7E,YAAI,KAAK,EAAE,MAAM,YAAY,MAAM,SAAS,SAAS,CAAC;AAAA,MACxD;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,gBAAgB,SAAuB;AACrD,MAAI,OAAO,MAAM,QAAQ,QAAQ,CAAC,EAAG,OAAM,IAAI,UAAU,+BAA+B;AACxF,QAAM,OAAO,QAAQ,eAAe;AACpC,QAAM,QAAQ,OAAO,QAAQ,YAAY,IAAI,CAAC,EAAE,SAAS,GAAG,GAAG;AAC/D,SAAO,GAAG,IAAI,IAAI,KAAK;AACzB;","names":[]}
|
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ELIGIBLE_STATUSES,
|
|
3
3
|
reportPeriodKey
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-PZR4EBPY.js";
|
|
5
5
|
import {
|
|
6
6
|
isProbablyEmail,
|
|
7
7
|
parseAddresses
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-NDWYJLRF.js";
|
|
9
9
|
import {
|
|
10
|
-
gatingHealth,
|
|
11
|
-
isSendOverridden,
|
|
12
10
|
listAllReports
|
|
13
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-F3JWOPTG.js";
|
|
14
12
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "./chunk-
|
|
13
|
+
gatingHealth,
|
|
14
|
+
isSendOverridden
|
|
15
|
+
} from "./chunk-VAD43MOR.js";
|
|
18
16
|
import {
|
|
19
17
|
openBase,
|
|
20
18
|
readAirtableConfig
|
|
21
19
|
} from "./chunk-HN6I2XWI.js";
|
|
20
|
+
import {
|
|
21
|
+
listWebsites,
|
|
22
|
+
siteSlug
|
|
23
|
+
} from "./chunk-7NYN2P6I.js";
|
|
22
24
|
|
|
23
25
|
// src/reports/preflight.ts
|
|
24
26
|
var OPERATOR_DOMAINS = ["reddoorla.com", "tuckerlemos.com"];
|
|
@@ -158,7 +160,7 @@ function preflightSite(site, reports, type, now) {
|
|
|
158
160
|
});
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
|
-
if (type === "Announcement" && site.status !== "
|
|
163
|
+
if (type === "Announcement" && site.status !== "maintained") {
|
|
162
164
|
findings.push({
|
|
163
165
|
level: "info",
|
|
164
166
|
check: "status-not-maintenance",
|
|
@@ -242,7 +244,7 @@ async function preflight(deps) {
|
|
|
242
244
|
const type = deps?.type ?? "Announcement";
|
|
243
245
|
const now = deps?.now ?? /* @__PURE__ */ new Date();
|
|
244
246
|
const websites = await listWebsites(base);
|
|
245
|
-
const selected = deps?.site ? websites.filter((w) => siteSlug(w.name) === siteSlug(deps.site)) : type === "Announcement" ? websites.filter((w) => w.status === "
|
|
247
|
+
const selected = deps?.site ? websites.filter((w) => siteSlug(w.name) === siteSlug(deps.site)) : type === "Announcement" ? websites.filter((w) => w.status === "maintained") : websites.filter((w) => w.status === null || ELIGIBLE_STATUSES.has(w.status));
|
|
246
248
|
const allReports = selected.length > 0 ? await listAllReports(base) : [];
|
|
247
249
|
const results = selected.map(
|
|
248
250
|
(site) => preflightSite(
|
|
@@ -339,4 +341,4 @@ export {
|
|
|
339
341
|
preflight,
|
|
340
342
|
approveBlockers
|
|
341
343
|
};
|
|
342
|
-
//# sourceMappingURL=chunk-
|
|
344
|
+
//# sourceMappingURL=chunk-SDDPUAWH.js.map
|