@reddoorla/maintenance 0.87.0 → 0.88.2
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/dist/airtable-QG5ICI3Y.js +9 -0
- package/dist/{announce-I2Z4JN3I.js → announce-GKD5YJNB.js} +9 -9
- package/dist/{blux-ZGDSHI3U.js → blux-DN4MI532.js} +5 -5
- package/dist/{bump-deps-CGVELRLK.js → bump-deps-KGH5UYSF.js} +4 -4
- package/dist/{chunk-SDDPUAWH.js → chunk-6I6L3CMX.js} +2 -2
- package/dist/{chunk-ZBDZTJUO.js → chunk-7FFUBF4I.js} +5 -2
- package/dist/chunk-7FFUBF4I.js.map +1 -0
- package/dist/{chunk-HSI6Q7MI.js → chunk-7TMWBXX7.js} +159 -29
- package/dist/chunk-7TMWBXX7.js.map +1 -0
- package/dist/{chunk-DEWJMMG3.js → chunk-A2UNJIKH.js} +15 -7
- package/dist/chunk-A2UNJIKH.js.map +1 -0
- package/dist/{chunk-NCMZMLY7.js → chunk-ANJNTAMM.js} +3 -69
- package/dist/chunk-ANJNTAMM.js.map +1 -0
- package/dist/{chunk-3JQ2FO3D.js → chunk-B23FFWU4.js} +2 -2
- package/dist/{chunk-DJZWYIPP.js → chunk-BGTYPVLT.js} +1 -1
- package/dist/chunk-BGTYPVLT.js.map +1 -0
- package/dist/chunk-BRYXFH77.js +70 -0
- package/dist/chunk-BRYXFH77.js.map +1 -0
- package/dist/{chunk-NDWYJLRF.js → chunk-DGZWFI2G.js} +3 -3
- package/dist/{chunk-HHMMUR4P.js → chunk-GQDDWYU2.js} +45 -8
- package/dist/chunk-GQDDWYU2.js.map +1 -0
- package/dist/{chunk-BBUSGEES.js → chunk-J3WEZGIN.js} +6 -6
- package/dist/{chunk-BNM5POSJ.js → chunk-JRHIFA5R.js} +2 -2
- package/dist/{chunk-TD2NNXLZ.js → chunk-K6SLF6GI.js} +5 -5
- package/dist/{chunk-MT2JINSS.js → chunk-KJGKZLCT.js} +3 -3
- package/dist/{chunk-NXNPDCF3.js → chunk-KMCZA7CH.js} +11 -1
- package/dist/chunk-KMCZA7CH.js.map +1 -0
- package/dist/{chunk-3SF7D4DY.js → chunk-MFHS7IQ2.js} +36 -1
- package/dist/{chunk-3SF7D4DY.js.map → chunk-MFHS7IQ2.js.map} +1 -1
- package/dist/{chunk-SNSTJLQW.js → chunk-NO74DXDP.js} +48 -2
- package/dist/chunk-NO74DXDP.js.map +1 -0
- package/dist/{chunk-C6B4RHXR.js → chunk-O3GT46R2.js} +8 -2
- package/dist/chunk-O3GT46R2.js.map +1 -0
- package/dist/{chunk-C6CLRKPV.js → chunk-PR343K7F.js} +2 -2
- package/dist/{chunk-CROKUHJB.js → chunk-Q47DU255.js} +4 -4
- package/dist/{chunk-EVBCK2KC.js → chunk-RARREDQE.js} +2 -1
- package/dist/{chunk-EVBCK2KC.js.map → chunk-RARREDQE.js.map} +1 -1
- package/dist/chunk-RUPPWAD6.js +7 -0
- package/dist/chunk-RUPPWAD6.js.map +1 -0
- package/dist/{chunk-VSBFM7DS.js → chunk-SJOXWJ5Y.js} +3 -3
- package/dist/{chunk-HEWSRXYV.js → chunk-ULHPALI5.js} +3 -3
- package/dist/{chunk-XUGSZP7Z.js → chunk-W45FZEGE.js} +2 -2
- package/dist/{chunk-JHGHLKFS.js → chunk-WS6NU475.js} +2 -2
- package/dist/{chunk-ZGBEIWSC.js → chunk-XP5WM2LV.js} +5 -5
- package/dist/{chunk-6TWWY7ZU.js → chunk-Z3JXLULD.js} +2 -2
- package/dist/cli/bin.js +27 -27
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +4 -0
- package/dist/cli/commands/audit.js +16 -13
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-JYKPVCJX.js +11 -0
- package/dist/configs/playwright-a11y.js +1 -1
- package/dist/{convert-to-pnpm-QFJOEP5V.js → convert-to-pnpm-NWB5FCQ2.js} +4 -4
- package/dist/{db-UXNIZ2RG.js → db-V6UIS433.js} +121 -26
- package/dist/db-V6UIS433.js.map +1 -0
- package/dist/{digest-FW6QMZZJ.js → digest-2JEAEJK6.js} +53 -29
- package/dist/digest-2JEAEJK6.js.map +1 -0
- package/dist/digest-state-CRZRNGDI.js +25 -0
- package/dist/digest-state-CRZRNGDI.js.map +1 -0
- package/dist/digest-state-F37XUCS5.js +13 -0
- package/dist/{dump-P6EBSIOH.js → dump-EPL7AZFR.js} +38 -13
- package/dist/dump-EPL7AZFR.js.map +1 -0
- package/dist/{email-KJXASVH5.js → email-SL7464YI.js} +16 -6
- package/dist/{email-KJXASVH5.js.map → email-SL7464YI.js.map} +1 -1
- package/dist/{ensure-site-4DCEBAPM.js → ensure-site-FU6527OU.js} +2 -2
- package/dist/{fleet-state-7TJ2GYT2.js → fleet-state-R5LU7IC3.js} +4 -2
- package/dist/{forms-notify-target-2EAWCWBW.js → forms-notify-target-SGDGXXK5.js} +6 -6
- package/dist/{github-signals-E2WPYTBK.js → github-signals-GWGCKEE2.js} +7 -6
- package/dist/{github-signals-E2WPYTBK.js.map → github-signals-GWGCKEE2.js.map} +1 -1
- package/dist/{header-image-6IC53W27.js → header-image-7A7XLVEU.js} +2 -2
- package/dist/{health-endpoint-RFXL2F4A.js → health-endpoint-UVX6R5N5.js} +4 -4
- package/dist/{health-mirror-L5JBEJVA.js → health-mirror-GCDN7BJL.js} +11 -6
- package/dist/health-mirror-GCDN7BJL.js.map +1 -0
- package/dist/index.js +128 -88
- package/dist/index.js.map +1 -1
- package/dist/{init-XRAQZYID.js → init-FGTYNGUI.js} +9 -9
- package/dist/{launch-IX4Y5JJC.js → launch-5H7LPEGP.js} +12 -11
- package/dist/{launch-IX4Y5JJC.js.map → launch-5H7LPEGP.js.map} +1 -1
- package/dist/migrate-YITCXBLS.js +7 -0
- package/dist/{notify-C645PGD5.js → notify-4HP4GZ2H.js} +5 -5
- package/dist/{onboard-O4IAHJMO.js → onboard-BKCYBAVW.js} +4 -4
- package/dist/{orchestrate-YLWQ6KK2.js → orchestrate-JNKCWU5K.js} +9 -9
- package/dist/{parity-56KA2DGR.js → parity-NBYZUBFU.js} +4 -2
- package/dist/pdf-N5QLQ5ZQ.js +38 -0
- package/dist/pdf-N5QLQ5ZQ.js.map +1 -0
- package/dist/{pipeline-3AANZFCR.js → pipeline-WYTXSLHX.js} +3 -3
- package/dist/{preflight-TNORKGCX.js → preflight-IMTYDDD4.js} +10 -10
- package/dist/{prismic-ci-G2AK53VL.js → prismic-ci-MY2U6NN7.js} +4 -4
- package/dist/{prismic-models-O6Z3OK3A.js → prismic-models-NJN5JORF.js} +6 -6
- package/dist/prospect/types.d.ts +14 -0
- package/dist/{prospect-audit-KSXUNQ5J.js → prospect-audit-JMBRVXSG.js} +32 -13
- package/dist/prospect-audit-JMBRVXSG.js.map +1 -0
- package/dist/render-VX72AESY.js +10 -0
- package/dist/{render-from-row-BWSOIYDS.js → render-from-row-DOYJDO2L.js} +5 -5
- package/dist/{replay-C2LXMJZP.js → replay-4S6ENYCH.js} +3 -3
- package/dist/{report-R3E3Y7HR.js → report-TALY5P3L.js} +17 -17
- package/dist/{report-mirror-4MNORYQ4.js → report-mirror-AAK3MXHY.js} +12 -6
- package/dist/report-mirror-AAK3MXHY.js.map +1 -0
- package/dist/schema-75PJECAE.js +18 -0
- package/dist/schema-75PJECAE.js.map +1 -0
- package/dist/{self-updating-UCIWESAX.js → self-updating-NTCCWJYZ.js} +4 -4
- package/dist/{selftest-VGHWPN72.js → selftest-NZTR4SM2.js} +11 -11
- package/dist/{site-mirror-NIREX5ME.js → site-mirror-Q763PLGG.js} +17 -7
- package/dist/site-mirror-Q763PLGG.js.map +1 -0
- package/dist/{smoke-suite-U3UKD3NS.js → smoke-suite-4JFZZYVW.js} +4 -4
- package/dist/{spam-classifier-XLBFCO7G.js → spam-classifier-T5H4VTP6.js} +4 -2
- package/dist/{submissions-IQEJX6FK.js → submissions-TEHPBSZJ.js} +4 -4
- package/dist/{svelte-codemods-GOKXTBHW.js → svelte-codemods-QAPDZC6F.js} +4 -4
- package/dist/{sync-CTUVXMCK.js → sync-GBFV45WX.js} +4 -2
- package/dist/{sync-CTUVXMCK.js.map → sync-GBFV45WX.js.map} +1 -1
- package/dist/{sync-configs-SPTLFJCC.js → sync-configs-34JIA6KZ.js} +4 -4
- package/dist/{upgrade-W6ZR3FJH.js → upgrade-IR2OO3KP.js} +4 -4
- package/dist/{webhook-VOGOW3W6.js → webhook-XM6EV4XT.js} +2 -2
- package/dist/{write-audits-to-airtable-CMKHGJNQ.js → write-audits-to-airtable-OBHGR7DQ.js} +5 -2
- package/dist/write-audits-to-airtable-OBHGR7DQ.js.map +1 -0
- package/package.json +1 -1
- package/dist/airtable-ZBUPQJSC.js +0 -9
- package/dist/chunk-C6B4RHXR.js.map +0 -1
- package/dist/chunk-DEWJMMG3.js.map +0 -1
- package/dist/chunk-DJZWYIPP.js.map +0 -1
- package/dist/chunk-HHMMUR4P.js.map +0 -1
- package/dist/chunk-HSI6Q7MI.js.map +0 -1
- package/dist/chunk-NCMZMLY7.js.map +0 -1
- package/dist/chunk-NXNPDCF3.js.map +0 -1
- package/dist/chunk-SNSTJLQW.js.map +0 -1
- package/dist/chunk-ZBDZTJUO.js.map +0 -1
- package/dist/client-AUE3XUPO.js +0 -11
- package/dist/db-UXNIZ2RG.js.map +0 -1
- package/dist/digest-FW6QMZZJ.js.map +0 -1
- package/dist/dump-P6EBSIOH.js.map +0 -1
- package/dist/health-mirror-L5JBEJVA.js.map +0 -1
- package/dist/migrate-GQH237A6.js +0 -7
- package/dist/prospect-audit-KSXUNQ5J.js.map +0 -1
- package/dist/render-IFOGFB5T.js +0 -10
- package/dist/report-mirror-4MNORYQ4.js.map +0 -1
- package/dist/site-mirror-NIREX5ME.js.map +0 -1
- /package/dist/{airtable-ZBUPQJSC.js.map → airtable-QG5ICI3Y.js.map} +0 -0
- /package/dist/{announce-I2Z4JN3I.js.map → announce-GKD5YJNB.js.map} +0 -0
- /package/dist/{blux-ZGDSHI3U.js.map → blux-DN4MI532.js.map} +0 -0
- /package/dist/{bump-deps-CGVELRLK.js.map → bump-deps-KGH5UYSF.js.map} +0 -0
- /package/dist/{chunk-SDDPUAWH.js.map → chunk-6I6L3CMX.js.map} +0 -0
- /package/dist/{chunk-3JQ2FO3D.js.map → chunk-B23FFWU4.js.map} +0 -0
- /package/dist/{chunk-NDWYJLRF.js.map → chunk-DGZWFI2G.js.map} +0 -0
- /package/dist/{chunk-BBUSGEES.js.map → chunk-J3WEZGIN.js.map} +0 -0
- /package/dist/{chunk-BNM5POSJ.js.map → chunk-JRHIFA5R.js.map} +0 -0
- /package/dist/{chunk-TD2NNXLZ.js.map → chunk-K6SLF6GI.js.map} +0 -0
- /package/dist/{chunk-MT2JINSS.js.map → chunk-KJGKZLCT.js.map} +0 -0
- /package/dist/{chunk-C6CLRKPV.js.map → chunk-PR343K7F.js.map} +0 -0
- /package/dist/{chunk-CROKUHJB.js.map → chunk-Q47DU255.js.map} +0 -0
- /package/dist/{chunk-VSBFM7DS.js.map → chunk-SJOXWJ5Y.js.map} +0 -0
- /package/dist/{chunk-HEWSRXYV.js.map → chunk-ULHPALI5.js.map} +0 -0
- /package/dist/{chunk-XUGSZP7Z.js.map → chunk-W45FZEGE.js.map} +0 -0
- /package/dist/{chunk-JHGHLKFS.js.map → chunk-WS6NU475.js.map} +0 -0
- /package/dist/{chunk-ZGBEIWSC.js.map → chunk-XP5WM2LV.js.map} +0 -0
- /package/dist/{chunk-6TWWY7ZU.js.map → chunk-Z3JXLULD.js.map} +0 -0
- /package/dist/{client-AUE3XUPO.js.map → client-JYKPVCJX.js.map} +0 -0
- /package/dist/{convert-to-pnpm-QFJOEP5V.js.map → convert-to-pnpm-NWB5FCQ2.js.map} +0 -0
- /package/dist/{fleet-state-7TJ2GYT2.js.map → digest-state-F37XUCS5.js.map} +0 -0
- /package/dist/{ensure-site-4DCEBAPM.js.map → ensure-site-FU6527OU.js.map} +0 -0
- /package/dist/{migrate-GQH237A6.js.map → fleet-state-R5LU7IC3.js.map} +0 -0
- /package/dist/{forms-notify-target-2EAWCWBW.js.map → forms-notify-target-SGDGXXK5.js.map} +0 -0
- /package/dist/{header-image-6IC53W27.js.map → header-image-7A7XLVEU.js.map} +0 -0
- /package/dist/{health-endpoint-RFXL2F4A.js.map → health-endpoint-UVX6R5N5.js.map} +0 -0
- /package/dist/{init-XRAQZYID.js.map → init-FGTYNGUI.js.map} +0 -0
- /package/dist/{notify-C645PGD5.js.map → migrate-YITCXBLS.js.map} +0 -0
- /package/dist/{orchestrate-YLWQ6KK2.js.map → notify-4HP4GZ2H.js.map} +0 -0
- /package/dist/{onboard-O4IAHJMO.js.map → onboard-BKCYBAVW.js.map} +0 -0
- /package/dist/{parity-56KA2DGR.js.map → orchestrate-JNKCWU5K.js.map} +0 -0
- /package/dist/{pipeline-3AANZFCR.js.map → parity-NBYZUBFU.js.map} +0 -0
- /package/dist/{render-IFOGFB5T.js.map → pipeline-WYTXSLHX.js.map} +0 -0
- /package/dist/{preflight-TNORKGCX.js.map → preflight-IMTYDDD4.js.map} +0 -0
- /package/dist/{prismic-ci-G2AK53VL.js.map → prismic-ci-MY2U6NN7.js.map} +0 -0
- /package/dist/{prismic-models-O6Z3OK3A.js.map → prismic-models-NJN5JORF.js.map} +0 -0
- /package/dist/{render-from-row-BWSOIYDS.js.map → render-VX72AESY.js.map} +0 -0
- /package/dist/{spam-classifier-XLBFCO7G.js.map → render-from-row-DOYJDO2L.js.map} +0 -0
- /package/dist/{replay-C2LXMJZP.js.map → replay-4S6ENYCH.js.map} +0 -0
- /package/dist/{report-R3E3Y7HR.js.map → report-TALY5P3L.js.map} +0 -0
- /package/dist/{self-updating-UCIWESAX.js.map → self-updating-NTCCWJYZ.js.map} +0 -0
- /package/dist/{selftest-VGHWPN72.js.map → selftest-NZTR4SM2.js.map} +0 -0
- /package/dist/{smoke-suite-U3UKD3NS.js.map → smoke-suite-4JFZZYVW.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-CMKHGJNQ.js.map → spam-classifier-T5H4VTP6.js.map} +0 -0
- /package/dist/{submissions-IQEJX6FK.js.map → submissions-TEHPBSZJ.js.map} +0 -0
- /package/dist/{svelte-codemods-GOKXTBHW.js.map → svelte-codemods-QAPDZC6F.js.map} +0 -0
- /package/dist/{sync-configs-SPTLFJCC.js.map → sync-configs-34JIA6KZ.js.map} +0 -0
- /package/dist/{upgrade-W6ZR3FJH.js.map → upgrade-IR2OO3KP.js.map} +0 -0
- /package/dist/{webhook-VOGOW3W6.js.map → webhook-XM6EV4XT.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/digest-collectors.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport {\n allActionableVulnsTransitive,\n siteSlug,\n ACTIVE_STATUSES,\n isPreLaunch,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { approveBlockers } from \"../reports/preflight.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe.\n * An empty Name slugs to \"\" and `/s/` is a dead link — fall back to the fleet homepage,\n * exactly like the ready-section does. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n const root = baseUrl.replace(/\\/$/, \"\");\n const slug = siteSlug(siteName);\n return slug ? `${root}/s/${slug}` : root;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n * EXCEPT when the persisted advisories prove every critical/high vuln is TRANSITIVE: then\n * nightly dispatches were green no-ops (no direct dep to bump — the fix rides the weekly\n * lockfile window), \"auto-fix failed\" would be a lie, and a stale pre-fix counter must not\n * escalate (Sonder said \"auto-fix failed (5×)\" on exactly this, 2026-08-10). Those sites get\n * an honest transitive-only title, keep count-based severity, and stay out of the digest\n * email (amber cockpit Watch still shows them). Unknown relationship data never mutes.\n *\n * Emission is UNCONDITIONAL — every surface applies its own policy on top:\n * - digest email: includes a vuln only when `autoFixExhausted` (the operator hears\n * about a vuln only after Renovate tried and failed; before that the fleet is\n * still self-patching) — runDigest filters\n * - cockpit: non-exhausted vuln → amber Watch; exhausted → hard break (assignTier),\n * and only an exhausted vuln pierces the pre-launch mute (piercesPreLaunchMute)\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const transitiveOnly = allActionableVulnsTransitive(s.securityAdvisories);\n const exhausted = !transitiveOnly && attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : transitiveOnly\n ? `${metric} critical/high ${noun} — transitive-only, fix rides the weekly lockfile window`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\n/**\n * One attention item per unsent draft whose send is ALREADY known to fail\n * (approveBlockers: recipients / header image / report scores). An APPROVED one\n * is critical — the next 09:23 UTC run will go red on it; a pending one is a\n * warning — approving it just schedules that failure. Keyed `preflight:<reportId>`.\n * PURE; same predicate the approve gate and the dashboard chip use.\n */\nexport function collectPreflightBlocked(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (!r.draftReady || r.sentAt !== null) continue;\n // The approved state rides the KEY so a pending→approved escalation with\n // unchanged blockers re-news as a fresh critical instead of diffing\n // \"standing\" against its old warning self.\n const state = r.approvedToSend ? \"approved\" : \"pending\";\n const site = sitesById.get(r.siteId);\n if (!site) {\n // A dangling/empty Site link is itself a send blocker (sendApprovedReports\n // fails with \"Site row not found\") — surface it; the empty-slug\n // dashboardUrl fallback yields the fleet root, not a broken /s/ link.\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: \"(unlinked site)\",\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — site-not-found`\n : `${r.reportType} draft can't be approved — site-not-found`,\n url: dashboardUrl(baseUrl, \"\"),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: 1,\n });\n continue;\n }\n const fails = approveBlockers(site, r).filter((f) => f.level === \"fail\");\n if (fails.length === 0) continue;\n const first = fails[0]!;\n const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : \"\";\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: site.name,\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — ${first.check}${more}`\n : `${r.reportType} draft can't be approved — ${first.check}${more}`,\n url: dashboardUrl(baseUrl, site.name),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: fails.length,\n });\n }\n return items;\n}\n\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\n/** A function-health sweep older than this can't confirm the CURRENT widget state.\n * The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS) tolerates a\n * weekend of runner flakes without letting a months-old verdict drive an alarm. */\nconst TURNSTILE_WIDGET_STALE_DAYS = 3;\n\n/**\n * One CRITICAL attention item per site with `Require Turnstile` ON whose deployed\n * `/health` reports the Turnstile widget NOT configured (`turnstileWidget === \"fail\"`,\n * fresh per {@link TURNSTILE_WIDGET_STALE_DAYS}). That combination silently buckets\n * 100% of the site's real leads (token-less submissions escalate to spam_auto with\n * notify skipped) and the form-e2e probe cannot see it — testMode bypasses the gate —\n * so the operator's inbox just goes quiet. PURE. Keyed `turnstile:<siteId>`. Because\n * this is an AttentionItem it rides assignTier's items short-circuit, which sits ABOVE\n * the accepted-watch mute loop — an accept key can never silence it. A null verdict or\n * a stale sweep is NOT alarmed here; assignTier raises those as an acceptable\n * \"can't verify\" watch instead. `now` injected, defaults to wall-clock.\n */\nexport function collectTurnstileGuardrailAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (!s.requireTurnstile || s.turnstileWidget !== \"fail\") continue;\n const at = s.functionHealthCheckedAt;\n if (at !== null) {\n const ageMs = now.getTime() - Date.parse(at);\n // Parseable and beyond the window → stale, downgraded to assignTier's watch.\n // Unparseable (NaN) keeps the item — never silently drop a real failure.\n if (Number.isFinite(ageMs) && ageMs > TURNSTILE_WIDGET_STALE_DAYS * MS_PER_DAY) continue;\n }\n items.push({\n key: `turnstile:${s.id}`,\n kind: \"turnstile\",\n siteName: s.name,\n title:\n \"Require Turnstile is ON but /health reports no widget — real leads are being auto-bucketed\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** Bounced lead notifications inside the window before a site alarms. One bounce can\n * be a transient greylist/full-mailbox blip; a repeat says the point-of-contact\n * address itself is bad (the Espada mode: apm@ bounced 4 of 8 with nothing alarming). */\nexport const NOTIFY_BOUNCE_THRESHOLD = 2;\n\n/** Window (days) the bounce count covers. Two weeks: long enough that a low-volume\n * site's second bounce still lands in the same window as its first, short enough\n * that a long-fixed address doesn't alarm forever (bounced rows never self-clear —\n * the alarm ages out as bounced submissions leave the window). */\nexport const NOTIFY_BOUNCE_WINDOW_DAYS = 14;\n\n/**\n * One CRITICAL attention item per site with >= {@link NOTIFY_BOUNCE_THRESHOLD} bounced\n * lead notifications in the last {@link NOTIFY_BOUNCE_WINDOW_DAYS} days — leads are\n * being captured but silently never reach the client, which is worse than no form at\n * all (notifyStatus \"sent\" only means Resend accepted the email; the resend-webhook\n * flips it to \"bounced\" when Resend later reports the failure, 2026-07-16). PURE:\n * takes the pre-fetched per-site counts (`countNotifyBouncedBySite`, keyed by the\n * Websites record id). Keyed `notify-bounce:<siteId>` so the digest and the cockpit\n * share one diff key; `metric` is the bounce count (a rising count diffs WORSE).\n * A site absent from the map (or below threshold) is skipped, as is a count for a\n * site id not in `sites` (orphan → never render a broken link).\n */\nexport function collectNotifyBounceAlerts(\n sites: WebsiteRow[],\n bouncedBySite: ReadonlyMap<string, number>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const n = bouncedBySite.get(s.id) ?? 0;\n if (n < NOTIFY_BOUNCE_THRESHOLD) continue;\n items.push({\n key: `notify-bounce:${s.id}`,\n kind: \"notify-bounce\",\n siteName: s.name,\n title: `${n} lead notifications bounced (${NOTIFY_BOUNCE_WINDOW_DAYS}d) — check the point-of-contact address`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: n,\n });\n }\n return items;\n}\n\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** How current a `fail` / `unknown` must be to still CLAIM the fleet's present\n * state. The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS)\n * tolerates a weekend of runner flakes without letting a months-old finding drive\n * a \"models diverge right now\" alarm. Past it the finding does not vanish — it\n * converts to the staleness item below, which claims nothing about the models. */\nconst PRISMIC_DRIFT_STALE_DAYS = 3;\n\n/** How long a `pass` may go unrefreshed before it is treated as unverified.\n *\n * Deliberately LONGER than the currency window, and the asymmetry is the point.\n * An old `fail` stops being reported as CURRENT drift at 3 days because it may\n * already be fixed. An old `pass` is a green claim nobody has re-established, and\n * escalating that at 3 days would light up the WHOLE fleet after a long weekend of\n * runner flakes — the noise that gets a real alarm muted. A week is the interval\n * over which a silently-dead nightly actually matters. */\nconst PRISMIC_STALE_PASS_DAYS = 7;\n\n/**\n * True when the nightly `prismic-models --fleet airtable` sweep is EXPECTED to\n * cover this site. Mirrors the Airtable inventory's own filter (src/inventory/\n * airtable.ts): live `maintenance` sites (active, not pre-launch) that carry a\n * `url` AND a Name that yields a slug. Deliberately duplicated rather than\n * imported — the inventory builds `Site` objects and needs a workdir; this is a\n * pure predicate over a row — but the two must stay in step: widen the inventory\n * and widen this. That is no longer left to memory: every shape of row is put\n * through BOTH implementations by tests/alerts/prismic-sweep-scope.test.ts, which\n * goes red the moment they diverge.\n *\n * THE SLUG CLAUSE IS NOT COSMETIC, and it was the first drift this pair produced.\n * The inventory drops an empty-slug row (`siteSlug(name) === \"\"`) with a warning,\n * because such a row can neither form a checkout path nor be matched back to its\n * Websites row on write-back — and `writeSweepToAirtable` joins by that same slug,\n * so even a verdict computed for it could never land. Without this clause a live\n * site named in a script with no `[a-z0-9]` (or with an empty Name) was covered\n * here and swept nowhere: the permanent, un-ackable morning email this predicate\n * exists to prevent, produced by the predicate itself.\n *\n * Only the STALENESS escalation consults it. A `fail`/`unknown` is a verdict some\n * run actually established and is reported wherever it came from; the staleness\n * item is an alarm invented FROM AN ABSENCE, and an absence is only wrong where a\n * sweep was owed. A deprecated site that left the inventory would otherwise carry\n * a frozen `pass` into the digest every morning forever — un-ackable (attention\n * items sit above the accepted-watch mute) and unfixable except by hand-clearing\n * an Airtable cell.\n */\nfunction prismicSweepCovers(s: WebsiteRow): boolean {\n return (\n s.status !== null &&\n ACTIVE_STATUSES.has(s.status) &&\n !isPreLaunch(s.status) &&\n s.url.length > 0 &&\n siteSlug(s.name).length > 0\n );\n}\n\n/**\n * One item per site whose nightly Prismic model verdict needs a human, from the\n * `prismic-models --fleet --write-airtable` sweep. PURE (`now` injected).\n *\n * The verdict is THREE-valued plus blank, and each state gets its own key so the\n * digest's snapshot diff (keyed on `key`) can never let one condition stand in for\n * another — a site sliding from drift to \"couldn't check\" re-news as NEW rather\n * than diffing \"standing\" against the drift it replaced (both carry `metric` 1):\n *\n * - `fail`, fresh → `prismic-drift:<siteId>` — repo and Prismic diverge.\n * - `unknown`, fresh → `prismic-unknown:<siteId>` — the check RAN AND COULD NOT\n * ANSWER (unreadable checkout, dead write token, unreachable Prismic). Its own\n * wording, because reporting a dead token as \"models diverge\" sends the\n * operator to fix a model when the job is to fix a secret.\n * - any verdict nobody has re-established → `prismic-stale:<siteId>` — a `pass`\n * older than {@link PRISMIC_STALE_PASS_DAYS} (or undateable), or a `fail` /\n * `unknown` older than {@link PRISMIC_DRIFT_STALE_DAYS}. A stale verdict is\n * never silence: dropping it would make \"nobody could evaluate this\" read\n * exactly like \"this is fine\", which is the failure this whole column exists\n * to close. Gated by {@link prismicSweepCovers}.\n * - blank (never swept, or not a Prismic site) → nothing, at any age. There is\n * no claim to un-verify.\n *\n * A `pass` inside its window is the ONLY silent verdict. At most one item per site\n * — the Airtable cell holds one state at a time.\n *\n * `warning`, not `critical`: on the cockpit ANY item already tiers the site 🔴, so\n * severity buys only (a) piercing the pre-launch mute and (b) sorting first in the\n * needs-you feed. `critical` is reserved for the guardrails that are actively\n * losing leads or breaking a live page (Turnstile, notify-bounce, delivery); a\n * model that drifted — or a check that could not run — is neither, and piercing\n * the pre-launch mute would alarm on exactly the sites the sweep excludes by\n * design.\n */\n/**\n * Is an operator's acceptance of a known divergence still in force?\n *\n * Every uncertain answer is `false` — an absent, blank or unparseable cell leaves\n * the alarm ringing. The asymmetry is the point: a mistyped date costing one noisy\n * item is recoverable, and a mistyped date silently muting a live finding is the\n * exact failure this column was built to close. The boundary belongs to the alarm\n * too: an ack \"until 09:00\" has nothing left to say at 09:00.\n */\nexport function prismicAckIsLive(ackUntil: string | null, now: Date): boolean {\n if (ackUntil === null || ackUntil.trim() === \"\") return false;\n const until = Date.parse(ackUntil);\n if (!Number.isFinite(until)) return false;\n return until > now.getTime();\n}\n\nexport function collectPrismicDriftAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const verdict = s.prismicModels;\n if (verdict === null) continue;\n\n const at = s.prismicModelsCheckedAt;\n // +Infinity when there is no timestamp, NaN when it won't parse — both mean\n // \"the age of this answer is unknowable\", which the two verdict families read\n // in OPPOSITE directions on purpose (see `unverified`).\n const ageMs = at === null ? Number.POSITIVE_INFINITY : now.getTime() - Date.parse(at);\n const ageKnown = Number.isFinite(ageMs);\n const windowDays = verdict === \"pass\" ? PRISMIC_STALE_PASS_DAYS : PRISMIC_DRIFT_STALE_DAYS;\n // An undateable `pass` is unverified — a green claim nobody can date is not a\n // green claim. An undateable `fail`/`unknown` is NOT downgraded: never silently\n // drop a real finding over a parse glitch.\n const unverified = ageKnown ? ageMs > windowDays * MS_PER_DAY : verdict === \"pass\";\n\n if (unverified) {\n if (!prismicSweepCovers(s)) continue;\n items.push({\n key: `prismic-stale:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: `Prismic model check has not run recently — the last verdict (\"${verdict}\") is unverified`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n continue;\n }\n\n if (verdict === \"pass\") continue;\n\n const first = s.prismicModelsDrift\n ?.split(\"\\n\")\n .find((l) => l.trim() !== \"\")\n ?.trim();\n if (verdict === \"unknown\") {\n items.push({\n key: `prismic-unknown:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: first\n ? `Prismic model check could not run — ${first}`\n : \"Prismic model check could not run\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n continue;\n }\n\n // The operator has reviewed THIS divergence and accepted it until a date they\n // set — see `WebsiteRow.prismicAckUntil`. Reached only on `fail`: the `unknown`\n // and staleness branches above have already returned, so an ack can never mute\n // \"the check could not run\" or \"nobody has re-established this\".\n if (prismicAckIsLive(s.prismicAckUntil, now)) continue;\n\n items.push({\n key: `prismic-drift:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: first\n ? `Prismic models diverge from the repo — ${first}`\n : \"Prismic models diverge from the repo\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import {
|
|
30
30
|
a11yRoutes,
|
|
31
31
|
smokeRoutes
|
|
32
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-O3GT46R2.js";
|
|
33
33
|
|
|
34
34
|
// src/audits/deps.ts
|
|
35
35
|
import { readFile } from "fs/promises";
|
|
@@ -1902,4 +1902,4 @@ export {
|
|
|
1902
1902
|
runAudits,
|
|
1903
1903
|
runAuditsAcross
|
|
1904
1904
|
};
|
|
1905
|
-
//# sourceMappingURL=chunk-
|
|
1905
|
+
//# sourceMappingURL=chunk-B23FFWU4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/util/html.ts"],"sourcesContent":["/**\n * Shared HTML/XML escape. One implementation behind the dashboard renderers\n * (`src/dashboard/render.ts`, `fleet-render.ts`), the daily digest\n * (`src/reports/digest.ts`), and the MJML email templates\n * (`src/reports/*-email/template.ts`).\n *\n * The set is the strict-XML set (`& < > \" '`), which is exactly what MJML's\n * `validationLevel: \"strict\"` parser needs and a superset of what plain HTML text\n * interpolation needs — so the SAME function serves both sinks. Site names\n * (e.g. \"Brown & Co\"), URLs, and operator commentary must not break the markup or\n * inject. The MJML templates re-export this as `escapeXml` for their callers.\n */\nexport function escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/**\n * Embed a value inside a `<script>` body as a JavaScript literal.\n *\n * **`escapeHtml` is the wrong tool here and fails in a way that looks like it\n * worked.** A `<script>` element's content is raw text: the HTML parser does not\n * decode entities inside it, so `"` reaching the browser stays the six\n * characters `"` — corrupting the JavaScript rather than escaping it. The\n * house style on these pages is \"escapeHtml everything\", so the first person to\n * interpolate into a script body will reach for it by reflex. This exists so\n * there is a correct thing to reach for instead.\n *\n * `JSON.stringify` produces a valid JS literal (quotes included — do NOT wrap the\n * result in quotes yourself). The extra replacements cover the two sequences JSON\n * does not escape but the HTML tokenizer still acts on inside raw text:\n * - `</` would end the script element early, so `</script>` in a string closes\n * the tag and spills the rest of the value into the document as markup;\n * - `<!--` opens a comment-like state that can swallow the code after it.\n *\n * U+2028/U+2029 are escaped too: they are literal line terminators in JS source\n * and JSON.stringify leaves them raw.\n */\nexport function scriptLiteral(value: unknown): string {\n return JSON.stringify(value ?? null)\n .replace(/</g, \"\\\\u003c\")\n .replace(/>/g, \"\\\\u003e\")\n .replace(/\\u2028/g, \"\\\\u2028\")\n .replace(/\\u2029/g, \"\\\\u2029\");\n}\n\n/** Allow only http(s) URLs in an href context; everything else collapses to \"#\".\n *\n * Returns the PARSED, percent-encoded `u.href` — never the caller's raw string.\n * `new URL()` only validates the scheme; it does not sanitize the rest of the\n * string, so a well-formed https URL carrying a quote-breakout payload\n * (`https://x.example/\"><script>...`) parses fine and, if handed back\n * verbatim, produces a live injection wherever a caller interpolates the\n * result into an href without a further escapeHtml pass. Returning `u.href`\n * closes that gap: the URL parser itself percent-encodes `\"`, `<`, `>`, etc.\n * in the path/query. This also normalizes a bare-origin URL to carry a\n * trailing slash (`https://x.example` → `https://x.example/`) — a caller\n * that additionally wraps the result in escapeHtml (the majority pattern in\n * this repo) sees no behavior change beyond that encoding. */\nexport function safeUrl(raw: string): string {\n try {\n const u = new URL(raw);\n if (u.protocol === \"http:\" || u.protocol === \"https:\") return u.href;\n } catch {\n // fall through\n }\n return \"#\";\n}\n"],"mappings":";AAYO,SAAS,WAAW,GAAmB;AAC5C,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;AA4CO,SAAS,QAAQ,KAAqB;AAC3C,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,QAAI,EAAE,aAAa,WAAW,EAAE,aAAa,SAAU,QAAO,EAAE;AAAA,EAClE,QAAQ;AAAA,EAER;AACA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/alerts/digest-state.ts
|
|
2
|
+
function diffAttention(items, prior, today) {
|
|
3
|
+
const tagged = [];
|
|
4
|
+
const next = {};
|
|
5
|
+
for (const it of items) {
|
|
6
|
+
const was = prior[it.key];
|
|
7
|
+
const exhaustedFlip = it.autoFixExhausted === true && was?.exhausted !== true;
|
|
8
|
+
let status;
|
|
9
|
+
let firstFlaggedAt;
|
|
10
|
+
if (!was) {
|
|
11
|
+
status = "new";
|
|
12
|
+
firstFlaggedAt = today;
|
|
13
|
+
} else if (it.metric > was.metric || exhaustedFlip) {
|
|
14
|
+
status = "worse";
|
|
15
|
+
firstFlaggedAt = was.firstFlaggedAt;
|
|
16
|
+
} else {
|
|
17
|
+
status = "standing";
|
|
18
|
+
firstFlaggedAt = was.firstFlaggedAt;
|
|
19
|
+
}
|
|
20
|
+
tagged.push({ ...it, status });
|
|
21
|
+
next[it.key] = {
|
|
22
|
+
metric: it.metric,
|
|
23
|
+
firstFlaggedAt,
|
|
24
|
+
...it.autoFixExhausted === true ? { exhausted: true } : {}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return { tagged, next };
|
|
28
|
+
}
|
|
29
|
+
var DIGEST_STATE_TABLE = "Digest State";
|
|
30
|
+
async function readDigestState(base) {
|
|
31
|
+
const rows = [];
|
|
32
|
+
await base(DIGEST_STATE_TABLE).select({ maxRecords: 1, pageSize: 1 }).eachPage((records, fetchNextPage) => {
|
|
33
|
+
for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });
|
|
34
|
+
fetchNextPage();
|
|
35
|
+
});
|
|
36
|
+
const first = rows[0];
|
|
37
|
+
if (!first) return {};
|
|
38
|
+
const raw = first.fields["Snapshot"];
|
|
39
|
+
if (typeof raw !== "string") return {};
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function writeDigestState(base, snap, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
47
|
+
const rows = [];
|
|
48
|
+
await base(DIGEST_STATE_TABLE).select({ maxRecords: 1, pageSize: 1 }).eachPage((records, fetchNextPage) => {
|
|
49
|
+
for (const rec of records) rows.push({ id: rec.id });
|
|
50
|
+
fetchNextPage();
|
|
51
|
+
});
|
|
52
|
+
const fields = {
|
|
53
|
+
Snapshot: JSON.stringify(snap),
|
|
54
|
+
"Updated At": updatedAt
|
|
55
|
+
};
|
|
56
|
+
const existing = rows[0];
|
|
57
|
+
if (existing) {
|
|
58
|
+
await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);
|
|
59
|
+
} else {
|
|
60
|
+
await base(DIGEST_STATE_TABLE).create([{ fields }]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
diffAttention,
|
|
66
|
+
DIGEST_STATE_TABLE,
|
|
67
|
+
readDigestState,
|
|
68
|
+
writeDigestState
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=chunk-BRYXFH77.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/digest-state.ts"],"sourcesContent":["// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n * `exhausted` is carried only for vuln keys whose Renovate auto-fix has been\n * exhausted (absent = not exhausted; older snapshots without the field read the\n * same way).\n */\nexport type DigestSnapshot = Record<\n string,\n { metric: number; firstFlaggedAt: string; exhausted?: boolean }\n>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - present and autoFixExhausted flipped on → WORSE (the count didn't move, but\n * \"Renovate gave up\" is an escalation — without this the digest's first mention\n * of a vuln, which is gated on exhaustion, would arrive unbadged)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n const exhaustedFlip = it.autoFixExhausted === true && was?.exhausted !== true;\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric || exhaustedFlip) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = {\n metric: it.metric,\n firstFlaggedAt,\n ...(it.autoFixExhausted === true ? { exhausted: true } : {}),\n };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n"],"mappings":";AA+BO,SAAS,cACd,OACA,OACA,OACmD;AACnD,QAAM,SAA0B,CAAC;AACjC,QAAM,OAAuB,CAAC;AAC9B,aAAW,MAAM,OAAO;AACtB,UAAM,MAAM,MAAM,GAAG,GAAG;AACxB,UAAM,gBAAgB,GAAG,qBAAqB,QAAQ,KAAK,cAAc;AACzE,QAAI;AACJ,QAAI;AACJ,QAAI,CAAC,KAAK;AACR,eAAS;AACT,uBAAiB;AAAA,IACnB,WAAW,GAAG,SAAS,IAAI,UAAU,eAAe;AAClD,eAAS;AACT,uBAAiB,IAAI;AAAA,IACvB,OAAO;AACL,eAAS;AACT,uBAAiB,IAAI;AAAA,IACvB;AACA,WAAO,KAAK,EAAE,GAAG,IAAI,OAAO,CAAC;AAC7B,SAAK,GAAG,GAAG,IAAI;AAAA,MACb,QAAQ,GAAG;AAAA,MACX;AAAA,MACA,GAAI,GAAG,qBAAqB,OAAO,EAAE,WAAW,KAAK,IAAI,CAAC;AAAA,IAC5D;AAAA,EACF;AACA,SAAO,EAAE,QAAQ,KAAK;AACxB;AAGO,IAAM,qBAAqB;AAWlC,eAAsB,gBAAgB,MAA6C;AACjF,QAAM,OAA0D,CAAC;AACjE,QAAM,KAAK,kBAAkB,EAC1B,OAAO,EAAE,YAAY,GAAG,UAAU,EAAE,CAAC,EACrC,SAAS,CAAC,SAAS,kBAAkB;AACpC,eAAW,OAAO,QAAS,MAAK,KAAK,EAAE,IAAI,IAAI,IAAI,QAAQ,IAAI,OAAO,CAAC;AACvE,kBAAc;AAAA,EAChB,CAAC;AACH,QAAM,QAAQ,KAAK,CAAC;AACpB,MAAI,CAAC,MAAO,QAAO,CAAC;AACpB,QAAM,MAAM,MAAM,OAAO,UAAU;AACnC,MAAI,OAAO,QAAQ,SAAU,QAAO,CAAC;AACrC,MAAI;AACF,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AASA,eAAsB,iBACpB,MACA,MACA,aAAoB,oBAAI,KAAK,GAAE,YAAY,GAC5B;AACf,QAAM,OAAyB,CAAC;AAChC,QAAM,KAAK,kBAAkB,EAC1B,OAAO,EAAE,YAAY,GAAG,UAAU,EAAE,CAAC,EACrC,SAAS,CAAC,SAAS,kBAAkB;AACpC,eAAW,OAAO,QAAS,MAAK,KAAK,EAAE,IAAI,IAAI,GAAG,CAAC;AACnD,kBAAc;AAAA,EAChB,CAAC;AACH,QAAM,SAAmB;AAAA,IACvB,UAAU,KAAK,UAAU,IAAI;AAAA,IAC7B,cAAc;AAAA,EAChB;AACA,QAAM,WAAW,KAAK,CAAC;AACvB,MAAI,UAAU;AACZ,UAAM,KAAK,kBAAkB,EAAE,OAAO,CAAC,EAAE,IAAI,SAAS,IAAI,OAAO,CAAC,CAAC;AAAA,EACrE,OAAO;AACL,UAAM,KAAK,kBAAkB,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC;AAAA,EACpD;AACF;","names":[]}
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import {
|
|
18
18
|
renderReportFromRow,
|
|
19
19
|
requireLighthouse
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-XP5WM2LV.js";
|
|
21
21
|
import {
|
|
22
22
|
listWebsites,
|
|
23
23
|
updateLaunched
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from "./chunk-WEKL2HYJ.js";
|
|
28
28
|
import {
|
|
29
29
|
recordFleetEventsBestEffort
|
|
30
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-W45FZEGE.js";
|
|
31
31
|
|
|
32
32
|
// src/reports/send/idempotency.ts
|
|
33
33
|
function isIdempotencyConflict(err) {
|
|
@@ -218,4 +218,4 @@ export {
|
|
|
218
218
|
parseAddresses,
|
|
219
219
|
isProbablyEmail
|
|
220
220
|
};
|
|
221
|
-
//# sourceMappingURL=chunk-
|
|
221
|
+
//# sourceMappingURL=chunk-DGZWFI2G.js.map
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "./chunk-7NYN2P6I.js";
|
|
26
26
|
|
|
27
27
|
// src/db/fleet-state.ts
|
|
28
|
+
import { sql } from "kysely";
|
|
28
29
|
var str = (v) => typeof v === "string" ? v : null;
|
|
29
30
|
var num = (v) => typeof v === "number" ? v : null;
|
|
30
31
|
var bool = (v) => typeof v === "number" ? v !== 0 : null;
|
|
@@ -242,7 +243,7 @@ function checklistFromStored(raw) {
|
|
|
242
243
|
for (const [key, field] of CHECKLIST_FIELD_BY_KEY) out[field] = Boolean(stored[key]);
|
|
243
244
|
return out;
|
|
244
245
|
}
|
|
245
|
-
function reportRowFromDb(r) {
|
|
246
|
+
function reportRowFromDb(r, hasRenderedHtml) {
|
|
246
247
|
const p = r.lighthouse_performance;
|
|
247
248
|
const a = r.lighthouse_accessibility;
|
|
248
249
|
const b = r.lighthouse_best_practices;
|
|
@@ -274,7 +275,7 @@ function reportRowFromDb(r) {
|
|
|
274
275
|
// The body lives IN the row (rendered_html) — the link points at the
|
|
275
276
|
// dashboard's own preview route instead of an EXPIRING Airtable signed URL.
|
|
276
277
|
// Strictly better for the operator: the old link 404'd once the URL aged out.
|
|
277
|
-
renderedHtmlAttachment:
|
|
278
|
+
renderedHtmlAttachment: hasRenderedHtml ? { url: `/api/reports/${r.id}/preview`, filename: `${r.report_id ?? r.id}.html` } : null,
|
|
278
279
|
resendMessageId: r.resend_message_id,
|
|
279
280
|
checklist: checklistFromStored(r.checklist),
|
|
280
281
|
autoEvidence: parseAutoEvidence(r.checklist_auto_evidence),
|
|
@@ -284,13 +285,48 @@ function reportRowFromDb(r) {
|
|
|
284
285
|
overrideAt: r.override_at
|
|
285
286
|
};
|
|
286
287
|
}
|
|
288
|
+
var REPORT_LIST_COLUMNS = [
|
|
289
|
+
"id",
|
|
290
|
+
"site_id",
|
|
291
|
+
"report_id",
|
|
292
|
+
"report_type",
|
|
293
|
+
"period",
|
|
294
|
+
"period_start",
|
|
295
|
+
"period_end",
|
|
296
|
+
"completed_on",
|
|
297
|
+
"lighthouse_performance",
|
|
298
|
+
"lighthouse_accessibility",
|
|
299
|
+
"lighthouse_best_practices",
|
|
300
|
+
"lighthouse_seo",
|
|
301
|
+
"ga_users_current",
|
|
302
|
+
"ga_users_previous",
|
|
303
|
+
"search_found_page1",
|
|
304
|
+
"search_position",
|
|
305
|
+
"last_tested_date",
|
|
306
|
+
"commentary",
|
|
307
|
+
"subject_override",
|
|
308
|
+
"draft_ready",
|
|
309
|
+
"approved_to_send",
|
|
310
|
+
"approved_at",
|
|
311
|
+
"approved_by",
|
|
312
|
+
"send_override",
|
|
313
|
+
"override_reason",
|
|
314
|
+
"override_by",
|
|
315
|
+
"override_at",
|
|
316
|
+
"sent_at",
|
|
317
|
+
"delivery_status",
|
|
318
|
+
"resend_message_id",
|
|
319
|
+
"checklist",
|
|
320
|
+
"checklist_auto_evidence"
|
|
321
|
+
];
|
|
322
|
+
var HAS_RENDERED_HTML = sql`(rendered_html is not null)`.as("has_rendered_html");
|
|
287
323
|
async function listAllReports(db) {
|
|
288
|
-
const rows = await db.selectFrom("reports").
|
|
289
|
-
return rows.map(reportRowFromDb);
|
|
324
|
+
const rows = await db.selectFrom("reports").select(REPORT_LIST_COLUMNS).select(HAS_RENDERED_HTML).orderBy("period_start", "desc").orderBy("id").execute();
|
|
325
|
+
return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));
|
|
290
326
|
}
|
|
291
327
|
async function listReportsForSite(db, siteId) {
|
|
292
|
-
const rows = await db.selectFrom("reports").
|
|
293
|
-
return rows.map(reportRowFromDb);
|
|
328
|
+
const rows = await db.selectFrom("reports").select(REPORT_LIST_COLUMNS).select(HAS_RENDERED_HTML).where("site_id", "=", siteId).orderBy("period_start", "desc").orderBy("id").execute();
|
|
329
|
+
return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));
|
|
294
330
|
}
|
|
295
331
|
async function mirrorReportPatch(db, reportId, patch) {
|
|
296
332
|
if (Object.keys(patch).length === 0) return;
|
|
@@ -307,7 +343,7 @@ async function storeRenderedHtml(db, reportId, html) {
|
|
|
307
343
|
}
|
|
308
344
|
async function getReportById(db, id) {
|
|
309
345
|
const r = await db.selectFrom("reports").selectAll().where("id", "=", id).executeTakeFirst();
|
|
310
|
-
return r ? reportRowFromDb(r) : null;
|
|
346
|
+
return r ? reportRowFromDb(r, r.rendered_html !== null) : null;
|
|
311
347
|
}
|
|
312
348
|
async function getReportHtml(db, id) {
|
|
313
349
|
const r = await db.selectFrom("reports").select(["rendered_html", "report_id"]).where("id", "=", id).executeTakeFirst();
|
|
@@ -324,6 +360,7 @@ export {
|
|
|
324
360
|
getSiteBySlug,
|
|
325
361
|
getSiteById,
|
|
326
362
|
listSites,
|
|
363
|
+
REPORT_LIST_COLUMNS,
|
|
327
364
|
listAllReports,
|
|
328
365
|
listReportsForSite,
|
|
329
366
|
mirrorReportPatch,
|
|
@@ -332,4 +369,4 @@ export {
|
|
|
332
369
|
getReportById,
|
|
333
370
|
getReportHtml
|
|
334
371
|
};
|
|
335
|
-
//# sourceMappingURL=chunk-
|
|
372
|
+
//# sourceMappingURL=chunk-GQDDWYU2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/fleet-state.ts"],"sourcesContent":["/** Phase 2 of the Airtable → Turso migration (#539): the fleet-state READ layer.\n *\n * Returns the exact `WebsiteRow` shape the Airtable module returns, so every\n * repoint is an import-only swap at a composition root — the same trick\n * listSubmissionsForSite used for the hybrid-db cutover. The raw values live\n * in `sites`/`site_health`/`site_schedule` (written by the importer / hourly\n * sync); coercion to `WebsiteRow` happens HERE, reusing the Airtable module's\n * own exported coercers (`toVerdict`, `toFrequency`, `parseNotifyRouting`,\n * `parseSecurityAdvisories`, `trimToNull`) so there is one truth for each.\n *\n * The equivalence instrument (tests/db/fleet-state.test.ts) pins this module\n * to `mapRow` field-by-field: for a fixture record, `mapRow(rec)` must deep-\n * equal the row read back through here after an import. A new WebsiteRow\n * field fails that test until this module carries it.\n *\n * `headerImage` is the ONE deliberate exception (design D5): Airtable stopped\n * being its source — the bytes belong in `sites.header_image*`. Those columns\n * ARE now written: the header-image CLI dual-writes on every generation and the\n * one-shot backfill copied the rest, so as of 2026-08-25 production carries a\n * BLOB for 12 of the 13 maintained sites (the 13th, LA Homelessness Youth, has\n * no header image in Airtable either — its reports are blocked at approve for\n * exactly that). An earlier version of this comment said the columns were empty\n * fleet-wide; that was true when written and is no longer.\n *\n * The `url` is \"\" because the bytes live in the row itself, not behind a signed\n * URL — which is why `url` is NOT a usable handle. A consumer that needs the\n * image calls `loadHeaderImage(db, siteId)` for the bytes; the send path still\n * fetches the Airtable attachment, and moving it over is its own change.\n */\nimport { sql, type Selectable, type Updateable } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { ReportsTable, SiteHealthTable, SiteScheduleTable, SitesTable } from \"./schema.js\";\nimport {\n SITE_FIELDS,\n siteValueFor,\n healthColumnFor,\n scheduleColumnFor,\n mapReportRecord,\n mapWebsiteRecord,\n type RawRecord,\n} from \"./import-airtable.js\";\nimport type { AirtableCellValue } from \"../reports/airtable/websites.js\";\nimport {\n toReportType,\n parseAutoEvidence,\n type ReportRow,\n type DeliveryStatus,\n} from \"../reports/airtable/reports.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { canonicalizeStatus } from \"../reports/airtable/site-status.js\";\nimport {\n parseNotifyRouting,\n parseSecurityAdvisories,\n toFrequency,\n toPrismicModelsVerdict,\n toVerdict,\n trimToNull,\n} from \"../reports/airtable/websites.js\";\n\ntype JoinedRow = Record<string, unknown>;\n\nconst str = (v: unknown): string | null => (typeof v === \"string\" ? v : null);\nconst num = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\n/** Stored 1/0/null (importer's b01n) → boolean | null. */\nconst bool = (v: unknown): boolean | null => (typeof v === \"number\" ? v !== 0 : null);\n\n/** Stored JSON array (importer-normalized) → the trimmed string[] mapRow yields. */\nfunction parseAwc(raw: unknown): string[] {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return [];\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n return parsed\n .filter((x): x is string => typeof x === \"string\")\n .map((s) => s.trim())\n .filter(Boolean);\n } catch {\n return [];\n }\n}\n\nfunction rowFromJoined(r: JoinedRow): WebsiteRow {\n const name = String(r.name ?? \"\");\n const headerFilename = str(r.header_image_filename);\n const headerType = str(r.header_image_type);\n return {\n id: String(r.id),\n name,\n url: str(r.url) ?? \"\",\n // `sites.status` holds the RAW Airtable cell (the importer stores it verbatim\n // so the hourly parity check compares raw-to-raw). Canonicalizing HERE — the\n // twin of mapRow's seam — is what keeps the #558 equivalence instrument green.\n status: canonicalizeStatus(r.status),\n statusRaw: str(r.status),\n pointOfContact: str(r.point_of_contact),\n maintenanceFreq: toFrequency(r.maintenance_freq, `${name} maintenance`),\n testingFreq: toFrequency(r.testing_freq, `${name} testing`),\n maintenanceFreqRaw: str(r.maintenance_freq),\n testingFreqRaw: str(r.testing_freq),\n maintenanceDay: str(r.maintenance_day),\n testingDay: str(r.testing_day),\n ga4PropertyId: str(r.ga4_property_id),\n searchQuery: str(r.search_query),\n searchConsoleProperty: str(r.search_console_property),\n analyticsSoftFailAt: str(r.analytics_soft_fail_at),\n gitRepo: str(r.git_repo),\n reportRecipientsTo: str(r.report_recipients_to),\n reportRecipientsCc: str(r.report_recipients_cc),\n acceptedWatchConditions: parseAwc(r.accepted_watch_conditions),\n headerImage:\n headerFilename !== null && headerType !== null\n ? { url: \"\", filename: headerFilename, type: headerType }\n : null,\n pScore: num(r.p_score),\n rScore: num(r.r_score),\n bpScore: num(r.bp_score),\n seoScore: num(r.seo_score),\n lastLighthouseAuditAt: str(r.lighthouse_at),\n a11yViolations: num(r.a11y_violations),\n depsDrifted: num(r.deps_drifted),\n depsMajorBehind: num(r.deps_major_behind),\n depsOutdated: num(r.deps_outdated),\n depsMajorOutdated: num(r.deps_major_outdated),\n securityVulnsCritical: num(r.vulns_critical),\n securityVulnsHigh: num(r.vulns_high),\n securityVulnsModerate: num(r.vulns_moderate),\n securityVulnsLow: num(r.vulns_low),\n securityAutoFixAttempts: num(r.auto_fix_attempts),\n lastSecurityAuditAt: str(r.security_audit_at),\n securityAdvisories: parseSecurityAdvisories(r.security_advisories),\n certDaysRemaining: num(r.cert_days_remaining),\n domainCheckedAt: str(r.domain_checked_at),\n netlifyId: trimToNull(r.netlify_id),\n deployStatus: str(r.deploy_status),\n lastDeployAt: str(r.last_deploy_at),\n deployLogUrl: str(r.deploy_log_url),\n deployCheckedAt: str(r.deploy_checked_at),\n functionHealth: toVerdict(r.function_health),\n cmsReachable: toVerdict(r.cms_reachable),\n turnstileWidget: toVerdict(r.turnstile_widget),\n functionHealthCheckedAt: str(r.function_health_checked_at),\n crossbrowserOk: bool(r.crossbrowser_ok),\n mobileOk: bool(r.mobile_ok),\n linksOk: bool(r.links_ok),\n brokenLinks: num(r.broken_links),\n browserCheckedAt: str(r.browser_checked_at),\n reachableOk: toVerdict(r.uptime_reachable),\n titleMetaOk: toVerdict(r.titles_meta_ok),\n copyIntro: trimToNull(r.copy_intro),\n copyContact: trimToNull(r.copy_contact),\n copyFooter: trimToNull(r.copy_footer),\n launchedAt: str(r.launched_at),\n newsletterWebhook: trimToNull(r.newsletter_webhook),\n notifyRouting: parseNotifyRouting(r.notify_routing),\n notifyRoutingRaw: trimToNull(r.notify_routing),\n mailchimpApiKey: trimToNull(r.mailchimp_api_key),\n mailchimpAudienceId: trimToNull(r.mailchimp_audience_id),\n requireTurnstile: typeof r.require_turnstile === \"number\" ? r.require_turnstile !== 0 : false,\n renovateFailingCis: num(r.renovate_failing_cis),\n defaultBranchCi: str(r.default_branch_ci),\n lastCommitAt: str(r.last_commit_at),\n githubSignalsAt: str(r.github_signals_at),\n smokeOk: toVerdict(r.smoke_ok),\n lastSmokeAt: str(r.last_smoke_at),\n formE2eOk: toVerdict(r.form_e2e_ok),\n formE2eCheckedAt: str(r.form_e2e_checked_at),\n prismicModels: toPrismicModelsVerdict(r.prismic_models),\n prismicModelsCheckedAt: str(r.prismic_models_checked_at),\n prismicModelsDrift: str(r.prismic_models_drift),\n prismicAckUntil: str(r.prismic_ack_until),\n nextMaintenanceAt: str(r.next_maintenance_at),\n nextTestingAt: str(r.next_testing_at),\n };\n}\n\n/** Every sites column EXCEPT the header-image BLOB. Since the 2026-08-24\n * backfill, header_image holds 0.7–3.5 MB JPEGs — a selectAll would haul\n * megabytes into every site lookup (per LEAD on the ingest path, ×44 on the\n * fleet list) and Turso bills the bytes. WebsiteRow only needs the metadata\n * columns; the bytes have their own dedicated readers when a consumer wants\n * them. Kept in lockstep with SitesTable by the blob-exclusion test. */\nconst SITE_COLUMNS = [\n \"sites.id\",\n \"sites.slug\",\n \"sites.name\",\n \"sites.url\",\n \"sites.status\",\n \"sites.point_of_contact\",\n \"sites.maintenance_freq\",\n \"sites.testing_freq\",\n \"sites.maintenance_day\",\n \"sites.testing_day\",\n \"sites.ga4_property_id\",\n \"sites.search_query\",\n \"sites.search_console_property\",\n \"sites.git_repo\",\n \"sites.netlify_id\",\n \"sites.report_recipients_to\",\n \"sites.report_recipients_cc\",\n \"sites.copy_intro\",\n \"sites.copy_contact\",\n \"sites.copy_footer\",\n \"sites.newsletter_webhook\",\n \"sites.mailchimp_api_key\",\n \"sites.mailchimp_audience_id\",\n \"sites.notify_routing\",\n \"sites.require_turnstile\",\n \"sites.accepted_watch_conditions\",\n \"sites.prismic_ack_until\",\n \"sites.launched_at\",\n \"sites.header_image_filename\",\n \"sites.header_image_type\",\n \"sites.header_image_generated_at\",\n \"sites.legacy\",\n] as const;\n\n/** The three-table join every read composes from. site_health/site_schedule\n * rows are upserted alongside sites by the importer, but LEFT JOIN anyway —\n * a site row must never vanish from a surface because a health row is absent. */\nfunction joined(db: Db) {\n return db\n .selectFrom(\"sites\")\n .leftJoin(\"site_health\", \"site_health.site_id\", \"sites.id\")\n .leftJoin(\"site_schedule\", \"site_schedule.site_id\", \"sites.id\")\n .select(SITE_COLUMNS)\n .selectAll([\"site_health\", \"site_schedule\"]);\n}\n\n/** Write-through for the site-detail editor (Phase 2): after the Airtable\n * write (still the Phase 2 source of truth), mirror the same cell into\n * `sites` so a Turso-reading page renders the edit immediately instead of\n * waiting for the next hourly sync. The column mapping is the IMPORTER's own\n * map — one truth — and the value gets the importer's empty-clears-to-null\n * semantics. Throws on a column the importer doesn't claim (the lockstep\n * test makes that unreachable for the editor's allowlist); the caller treats\n * a mirror failure as non-fatal — the hourly sync converges it.\n *\n * Handles the non-text columns too (`Require Turnstile` is a checkbox,\n * `Accepted Watch Conditions` a multi-select): the coercion is NOT repeated\n * here, it is delegated to the importer's own `siteValueFor`. That delegation\n * is the whole safety property — parity compares raw-to-raw, so a mirror that\n * stored `\"true\"` where the importer stores `1` would red every hourly run. */\nexport async function mirrorSiteField(\n db: Db,\n siteId: string,\n airtableColumn: string,\n value: AirtableCellValue,\n): Promise<void> {\n await mirrorSiteFields(db, siteId, { [airtableColumn]: value });\n}\n\n/** The multi-column form, and where the work actually happens (#539 Phase 5).\n *\n * `updateLaunched` is why it exists: it flips `Status` AND stamps `Launched at`\n * in one Airtable update, and mirroring those as two separate UPDATEs would\n * open a window where Turso says a site is maintained but never launched.\n *\n * Same contract as {@link mirrorHealthFields}, deliberately — it takes the\n * EXACT FieldSet just written to Airtable (the writers return it, so the mirror\n * cannot carry a different payload), and returns whether a `sites` row matched:\n * a site the hourly sync hasn't imported yet updates 0 rows → false, so the\n * caller can count it honestly instead of claiming it mirrored. An empty\n * FieldSet runs no SQL and returns true — nothing to mirror is not a miss. */\nexport async function mirrorSiteFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n): Promise<boolean> {\n const patch: Record<string, string | number | null> = {};\n for (const [airtableColumn, value] of Object.entries(fields)) {\n const col = SITE_FIELDS[airtableColumn];\n if (!col)\n throw new Error(`mirrorSiteFields: importer claims no sites column for '${airtableColumn}'`);\n // Empty text still clears to null (the importer's `s()` does the same); the\n // non-text columns get their own coercion inside siteValueFor.\n patch[col] = siteValueFor(col, value);\n }\n if (Object.keys(patch).length === 0) return true;\n const res = await db\n .updateTable(\"sites\")\n .set(patch as Updateable<SitesTable>)\n .where(\"id\", \"=\", siteId)\n .executeTakeFirst();\n // kysely/libSQL reports numUpdatedRows as a BigInt — compare in BigInt.\n return res.numUpdatedRows > 0n;\n}\n\n/** Mirror a NEWLY CREATED Airtable Websites record into Turso (#539 Phase 5).\n *\n * `ensure-site` CREATES a row, and every other site mirror is an UPDATE, which\n * does nothing at all for a row that does not exist yet — so a site\n * bootstrapped at 09:05 was invisible until the 09:20 sync, and every mirror\n * the rest of the bootstrap fired reported `mirrored=missed` with nothing to\n * update.\n *\n * Maps with the IMPORTER's own `mapWebsiteRecord`, which is also exactly what\n * parity diffs against: that delegation is what makes the mirrored rows\n * parity-clean by construction rather than by a column list someone has to\n * remember to extend.\n *\n * All THREE rows, not just `sites`. Parity reverse-checks `site_health` and\n * `site_schedule` per site and reports a missing one as `(row) ABSENT`, and a\n * later `mirrorHealthFields` would return `missed` forever with no row to hit.\n *\n * Upserts rather than inserts because `ensure-site` is re-run to RESUME a\n * bootstrap. The header_image* columns survive that by construction —\n * `mapWebsiteRecord` does not carry them (Airtable stopped being their source,\n * design D5), so the conflict branch cannot blank a stored plate whose bytes\n * live in no other store. */\nexport async function mirrorSiteInsert(db: Db, rec: RawRecord, computedAt: string): Promise<void> {\n const { site, health, schedule } = mapWebsiteRecord(rec, computedAt);\n await db\n .insertInto(\"sites\")\n .values(site)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(site))\n .execute();\n await db\n .insertInto(\"site_health\")\n .values(health)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(health))\n .execute();\n await db\n .insertInto(\"site_schedule\")\n .values(schedule)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(schedule))\n .execute();\n}\n\n/** Write-through mirror for the nightly health writers (#539 Phase 3). Takes\n * the EXACT FieldSet just written to Airtable (updateAuditFields /\n * updateGitHubSignals return it), so the mirror can never carry a different\n * payload than the Airtable write it shadows. Resolution + coercion come from\n * the importer's healthColumnFor — one truth; an Airtable column no\n * site_health column claims throws (same contract as mirrorSiteField).\n * Partial by design: absent fields stay untouched, matching\n * updateGitHubSignals' deliberate omission of a null lastCommitAt. Returns\n * whether a site_health row matched: a site the hourly sync hasn't imported\n * yet updates 0 rows → false, so the caller can count it honestly\n * (mirror_missed) instead of claiming it mirrored — it still converges on the\n * next sync, like every mirror. An empty FieldSet runs no SQL and returns\n * true: nothing to mirror is not a miss. */\nexport async function mirrorHealthFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n): Promise<boolean> {\n // Per-column value types (number vs text) are guaranteed by healthColumnFor's\n // coercion — the numeric/text split lives there, once — so the patch builds\n // untyped and casts at the .set() boundary (the importer's own idiom).\n const patch: Record<string, string | number | null> = {};\n for (const [field, value] of Object.entries(fields)) {\n const m = healthColumnFor(field);\n if (!m)\n throw new Error(`mirrorHealthFields: importer claims no site_health column for '${field}'`);\n patch[m.col] = m.coerce(value);\n }\n if (Object.keys(patch).length === 0) return true;\n const res = await db\n .updateTable(\"site_health\")\n .set(patch as Updateable<SiteHealthTable>)\n .where(\"site_id\", \"=\", siteId)\n .executeTakeFirst();\n // kysely/libSQL reports numUpdatedRows as a BigInt — compare in BigInt.\n return res.numUpdatedRows > 0n;\n}\n\n/** The site_schedule twin of {@link mirrorHealthFields}, for the nightly\n * next-due write-back. `computedAt` stamps when THIS computation ran — the\n * hourly sync overwrites it with its own import stamp, same as every mirrored\n * value. Empty fields → full no-op (no lone computed_at stamp for a write\n * that carried nothing). Same return contract as mirrorHealthFields: false\n * when the UPDATE matched no site_schedule row (site not yet imported), true\n * otherwise — including the empty-fields no-op. */\nexport async function mirrorScheduleFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n computedAt: string,\n): Promise<boolean> {\n const patch: Record<string, string | null> = {};\n for (const [field, value] of Object.entries(fields)) {\n const m = scheduleColumnFor(field);\n if (!m)\n throw new Error(\n `mirrorScheduleFields: importer claims no site_schedule column for '${field}'`,\n );\n patch[m.col] = m.coerce(value);\n }\n if (Object.keys(patch).length === 0) return true;\n patch.computed_at = computedAt;\n const res = await db\n .updateTable(\"site_schedule\")\n .set(patch as Updateable<SiteScheduleTable>)\n .where(\"site_id\", \"=\", siteId)\n .executeTakeFirst();\n return res.numUpdatedRows > 0n;\n}\n\n/** Same contract as the Airtable getWebsiteBySlug: slug is siteSlug(Name),\n * precomputed into the UNIQUE sites.slug column at import time. */\nexport async function getSiteBySlug(db: Db, slug: string): Promise<WebsiteRow | null> {\n const r = await joined(db).where(\"sites.slug\", \"=\", slug).executeTakeFirst();\n return r ? rowFromJoined(r as JoinedRow) : null;\n}\n\n/** By Airtable rec id (the PK) — approve-report's lookup shape. */\nexport async function getSiteById(db: Db, id: string): Promise<WebsiteRow | null> {\n const r = await joined(db).where(\"sites.id\", \"=\", id).executeTakeFirst();\n return r ? rowFromJoined(r as JoinedRow) : null;\n}\n\n/** Same contract as the Airtable listWebsites: every site, one row each.\n * Name-ordered for determinism (Airtable returned table order; no consumer\n * is order-sensitive — the cockpit groups and sorts itself). */\nexport async function listSites(db: Db): Promise<WebsiteRow[]> {\n const rows = await joined(db).orderBy(\"sites.name\").execute();\n return rows.map((r) => rowFromJoined(r as JoinedRow));\n}\n\n// ————————————————————————— reports —————————————————————————\n\n/** stable checklist key → Airtable column name. The importer stores stable keys\n * (mapReportRecord); `ReportRow.checklist` exposes Airtable column names — one\n * derived map, built from the checklist definitions themselves. */\nconst CHECKLIST_FIELD_BY_KEY: ReadonlyMap<string, string> = new Map(\n [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map((i) => [i.key, i.field]),\n);\n\nfunction checklistFromStored(raw: string | null): Record<string, boolean> {\n let stored: Record<string, unknown> = {};\n if (typeof raw === \"string\" && raw.trim() !== \"\") {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed))\n stored = parsed as Record<string, unknown>;\n } catch {\n // fall through to all-false — a bad blob must not throw the page\n }\n }\n const out: Record<string, boolean> = {};\n for (const [key, field] of CHECKLIST_FIELD_BY_KEY) out[field] = Boolean(stored[key]);\n return out;\n}\n\n/**\n * `hasRenderedHtml` is passed separately rather than read off `r`, because the\n * list read deliberately does not fetch the body (see REPORT_LIST_COLUMNS) and\n * only ever needed its null-ness. Taking it as an argument means a list row and a\n * full row are both honest inputs, and nothing here can start depending on body\n * CONTENT without the type changing to say so.\n */\nfunction reportRowFromDb(\n r: Omit<Selectable<ReportsTable>, \"rendered_html\">,\n hasRenderedHtml: boolean,\n): ReportRow {\n const p = r.lighthouse_performance;\n const a = r.lighthouse_accessibility;\n const b = r.lighthouse_best_practices;\n const seo = r.lighthouse_seo;\n const lighthouse =\n typeof p === \"number\" &&\n typeof a === \"number\" &&\n typeof b === \"number\" &&\n typeof seo === \"number\"\n ? { performance: p, accessibility: a, bestPractices: b, seo }\n : null;\n return {\n id: r.id,\n reportId: r.report_id ?? \"\",\n siteId: r.site_id ?? \"\",\n reportType: toReportType(r.report_type ?? undefined),\n period: r.period,\n periodStart: r.period_start,\n periodEnd: r.period_end,\n completedOn: r.completed_on,\n lighthouse,\n gaUsersCurrent: r.ga_users_current,\n gaUsersPrevious: r.ga_users_previous,\n searchFoundPage1: bool(r.search_found_page1),\n searchPosition: r.search_position,\n lastTestedDate: r.last_tested_date,\n commentary: r.commentary,\n subjectOverride: r.subject_override,\n draftReady: r.draft_ready !== 0,\n approvedToSend: r.approved_to_send !== 0,\n sentAt: r.sent_at,\n approvedAt: r.approved_at,\n approvedBy: r.approved_by,\n deliveryStatus: (r.delivery_status ?? \"pending\") as DeliveryStatus,\n // The body lives IN the row (rendered_html) — the link points at the\n // dashboard's own preview route instead of an EXPIRING Airtable signed URL.\n // Strictly better for the operator: the old link 404'd once the URL aged out.\n renderedHtmlAttachment: hasRenderedHtml\n ? { url: `/api/reports/${r.id}/preview`, filename: `${r.report_id ?? r.id}.html` }\n : null,\n resendMessageId: r.resend_message_id,\n checklist: checklistFromStored(r.checklist),\n autoEvidence: parseAutoEvidence(r.checklist_auto_evidence),\n sendOverride: r.send_override !== 0,\n overrideReason: r.override_reason,\n overrideBy: r.override_by,\n overrideAt: r.override_at,\n };\n}\n\n/** Every reports column EXCEPT the rendered_html body — the exact hazard\n * SITE_COLUMNS guards against on `sites`, on the table where it currently costs\n * more. Live, all 16 reports carry a body: 1.17 MB total, avg 73 KB. A list read\n * wants the body only to know WHETHER there is one, so `has_rendered_html`\n * computes that in SQL and the bytes stay in the database. Kept in lockstep with\n * ReportsTable by the body-exclusion test. */\nexport const REPORT_LIST_COLUMNS = [\n \"id\",\n \"site_id\",\n \"report_id\",\n \"report_type\",\n \"period\",\n \"period_start\",\n \"period_end\",\n \"completed_on\",\n \"lighthouse_performance\",\n \"lighthouse_accessibility\",\n \"lighthouse_best_practices\",\n \"lighthouse_seo\",\n \"ga_users_current\",\n \"ga_users_previous\",\n \"search_found_page1\",\n \"search_position\",\n \"last_tested_date\",\n \"commentary\",\n \"subject_override\",\n \"draft_ready\",\n \"approved_to_send\",\n \"approved_at\",\n \"approved_by\",\n \"send_override\",\n \"override_reason\",\n \"override_by\",\n \"override_at\",\n \"sent_at\",\n \"delivery_status\",\n \"resend_message_id\",\n \"checklist\",\n \"checklist_auto_evidence\",\n] as const;\n\n/** The one thing a list read still needs from the body: whether there IS one.\n * Computed in SQLite so the bytes never cross the wire. */\nconst HAS_RENDERED_HTML = sql<number>`(rendered_html is not null)`.as(\"has_rendered_html\");\n\n/** Same contract as the Airtable listAllReports. Newest-period-first (Airtable\n * returned manual table order; every consumer filters/sorts itself). */\nexport async function listAllReports(db: Db): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .select(REPORT_LIST_COLUMNS)\n .select(HAS_RENDERED_HTML)\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));\n}\n\n/** Same contract as the Airtable listReportsForSite — served by idx_reports_site.\n * Body-free for the same reason as listAllReports: the site-detail page renders\n * a link, never the HTML. */\nexport async function listReportsForSite(db: Db, siteId: string): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .select(REPORT_LIST_COLUMNS)\n .select(HAS_RENDERED_HTML)\n .where(\"site_id\", \"=\", siteId)\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map((r) => reportRowFromDb(r, r.has_rendered_html !== 0));\n}\n\n/** Columns a report writer mirrors after its Airtable write (same pattern as\n * mirrorSiteField): the approve/override flow, the resend-webhook's delivery\n * status, and — since #539 Phase 5 — the drafting path's queue flag and a\n * re-run's refreshed scores.\n *\n * `rendered_html` stays deliberately OUT even though the drafting path now\n * writes it: bodies go through `storeRenderedHtml`, so a request handler\n * holding a patch cannot accidentally write megabytes of HTML. `sent_at` and\n * `resend_message_id` stay out because nothing but the send batch writes them\n * and the hourly sync converges those. */\nexport type ReportMirrorPatch = Partial<\n Pick<\n ReportsTable,\n | \"approved_to_send\"\n | \"approved_at\"\n | \"approved_by\"\n | \"send_override\"\n | \"override_reason\"\n | \"override_by\"\n | \"override_at\"\n | \"delivery_status\"\n // Phase 4 report review: the console edits commentary and re-renders the\n // page immediately after the write, so the mirror has to carry it or the\n // operator sees their own save as a no-op until the next hourly sync.\n | \"commentary\"\n // Phase 5 drafting path. `draft_ready` is the queue flag `queueDraft`\n // writes — for the new draft AND for every row it supersedes, so without it\n // the console shows a site with two queued reports until the next sync.\n | \"draft_ready\"\n // A re-run (announce/launch reuse) refreshes an existing row's scores so the\n // eventually-sent email is not stale; the console reads the same numbers.\n | \"lighthouse_performance\"\n | \"lighthouse_accessibility\"\n | \"lighthouse_best_practices\"\n | \"lighthouse_seo\"\n | \"ga_users_current\"\n | \"ga_users_previous\"\n | \"search_found_page1\"\n | \"search_position\"\n | \"completed_on\"\n >\n>;\n\n/** Mirror an Airtable report write into Turso so the page re-render after an\n * approve/override/bounce shows the new state immediately instead of after\n * the next hourly sync. Callers treat a failure as non-fatal (the sync\n * converges it); an empty patch is a no-op, never invalid SQL. */\nexport async function mirrorReportPatch(\n db: Db,\n reportId: string,\n patch: ReportMirrorPatch,\n): Promise<void> {\n if (Object.keys(patch).length === 0) return;\n await db.updateTable(\"reports\").set(patch).where(\"id\", \"=\", reportId).execute();\n}\n\n/** Mirror a NEWLY CREATED Airtable Reports record into Turso (#539 Phase 5).\n *\n * Every other report mirror is an UPDATE, which silently does nothing for a row\n * that does not exist yet — so a draft created at 09:05 was invisible to the\n * Turso-backed console until the 09:20 sync. Takes the raw record Airtable\n * echoed back from the create, and maps it with the IMPORTER's own\n * `mapReportRecord`: parity diffs Turso against exactly that function, so\n * delegating is what makes the mirrored row parity-clean by construction rather\n * than by a column list someone has to remember to extend.\n *\n * Upsert, not insert, and the conflict branch drops `rendered_html` for the\n * same reason the importer's does: the body is written later by a separate\n * sharp-bearing step, so a re-mirror carrying null would blank a render that\n * had already succeeded. */\nexport async function mirrorReportInsert(db: Db, rec: RawRecord): Promise<void> {\n const row = mapReportRecord(rec, null);\n const { rendered_html: _rh, ...rowSansHtml } = row;\n void _rh;\n await db\n .insertInto(\"reports\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(rowSansHtml))\n .execute();\n}\n\n/**\n * Store a freshly rendered report body, replacing whatever was there.\n *\n * Deliberately NOT part of `ReportMirrorPatch`: that patch is the request path's\n * write-through (approve, override, delivery status), and a rendered body is\n * produced by a batch job with sharp, never by a dashboard POST. Keeping it out\n * means a handler cannot accidentally write megabytes of HTML from a request.\n *\n * It REPLACES unconditionally. The importer's `when-missing` mode skips a report\n * that already has a body — correct for an import, wrong here: a refresh whose\n * entire purpose is showing the newest commentary must overwrite.\n */\nexport async function storeRenderedHtml(db: Db, reportId: string, html: string): Promise<void> {\n await db.updateTable(\"reports\").set({ rendered_html: html }).where(\"id\", \"=\", reportId).execute();\n}\n\n/** By rec id (the PK) — approve-report's read. */\nexport async function getReportById(db: Db, id: string): Promise<ReportRow | null> {\n const r = await db.selectFrom(\"reports\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? reportRowFromDb(r, r.rendered_html !== null) : null;\n}\n\n/** The preview route's read: the stored rendered body, or null when the report\n * has none (imported while its signed URL was expired, or predates rendering). */\nexport async function getReportHtml(\n db: Db,\n id: string,\n): Promise<{ html: string; reportId: string | null } | null> {\n const r = await db\n .selectFrom(\"reports\")\n .select([\"rendered_html\", \"report_id\"])\n .where(\"id\", \"=\", id)\n .executeTakeFirst();\n if (!r || r.rendered_html === null) return null;\n return { html: r.rendered_html, reportId: r.report_id };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -12,20 +12,20 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
uploadAttachment
|
|
14
14
|
} from "./chunk-IY6256GK.js";
|
|
15
|
-
import {
|
|
16
|
-
renderReportHtml,
|
|
17
|
-
resolveCopy
|
|
18
|
-
} from "./chunk-MT2JINSS.js";
|
|
19
15
|
import {
|
|
20
16
|
generateHeaderImage
|
|
21
17
|
} from "./chunk-5UINJNAJ.js";
|
|
18
|
+
import {
|
|
19
|
+
renderReportHtml,
|
|
20
|
+
resolveCopy
|
|
21
|
+
} from "./chunk-KJGKZLCT.js";
|
|
22
22
|
import {
|
|
23
23
|
siteSlug,
|
|
24
24
|
updateAnalyticsHealth
|
|
25
25
|
} from "./chunk-7NYN2P6I.js";
|
|
26
26
|
import {
|
|
27
27
|
isNetlifyAppUrl
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-RARREDQE.js";
|
|
29
29
|
import {
|
|
30
30
|
defaultCredentialsPath
|
|
31
31
|
} from "./chunk-6VAL7XJT.js";
|
|
@@ -741,4 +741,4 @@ export {
|
|
|
741
741
|
fetchGaUsers,
|
|
742
742
|
fetchSearch
|
|
743
743
|
};
|
|
744
|
-
//# sourceMappingURL=chunk-
|
|
744
|
+
//# sourceMappingURL=chunk-J3WEZGIN.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SPAM_THRESHOLD
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-NO74DXDP.js";
|
|
4
4
|
import {
|
|
5
5
|
SUBMISSION_FORM_TYPES
|
|
6
6
|
} from "./chunk-2OP4JKIQ.js";
|
|
@@ -281,4 +281,4 @@ export {
|
|
|
281
281
|
ingestSubmission,
|
|
282
282
|
hostsMatch
|
|
283
283
|
};
|
|
284
|
-
//# sourceMappingURL=chunk-
|
|
284
|
+
//# sourceMappingURL=chunk-JRHIFA5R.js.map
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {
|
|
2
|
+
svelteCodemods
|
|
3
|
+
} from "./chunk-IYSEC543.js";
|
|
1
4
|
import {
|
|
2
5
|
healthEndpoint
|
|
3
6
|
} from "./chunk-BQRFQSGY.js";
|
|
@@ -10,9 +13,6 @@ import {
|
|
|
10
13
|
import {
|
|
11
14
|
convertToPnpm
|
|
12
15
|
} from "./chunk-XIDUYLSR.js";
|
|
13
|
-
import {
|
|
14
|
-
svelteCodemods
|
|
15
|
-
} from "./chunk-IYSEC543.js";
|
|
16
16
|
import {
|
|
17
17
|
syncConfigs
|
|
18
18
|
} from "./chunk-7UAYMAKR.js";
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "./chunk-3G25KIWW.js";
|
|
22
22
|
import {
|
|
23
23
|
runAudits
|
|
24
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-B23FFWU4.js";
|
|
25
25
|
import {
|
|
26
26
|
siteLabel
|
|
27
27
|
} from "./chunk-XXTZBPUY.js";
|
|
@@ -143,4 +143,4 @@ export {
|
|
|
143
143
|
DEFAULT_INIT_STEPS,
|
|
144
144
|
init
|
|
145
145
|
};
|
|
146
|
-
//# sourceMappingURL=chunk-
|
|
146
|
+
//# sourceMappingURL=chunk-K6SLF6GI.js.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
escapeHtml
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-BGTYPVLT.js";
|
|
4
4
|
import {
|
|
5
5
|
isHttpUrl
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-RARREDQE.js";
|
|
7
7
|
|
|
8
8
|
// src/reports/copy.ts
|
|
9
9
|
var DEFAULT_COPY = {
|
|
@@ -560,4 +560,4 @@ export {
|
|
|
560
560
|
rendersCommentary,
|
|
561
561
|
renderReportHtml
|
|
562
562
|
};
|
|
563
|
-
//# sourceMappingURL=chunk-
|
|
563
|
+
//# sourceMappingURL=chunk-KJGKZLCT.js.map
|
|
@@ -2,6 +2,9 @@ import {
|
|
|
2
2
|
hasRealScores,
|
|
3
3
|
lighthouseScoresFromResult
|
|
4
4
|
} from "./chunk-7BZUOZPU.js";
|
|
5
|
+
import {
|
|
6
|
+
TURSO_IS_AUTHORITATIVE
|
|
7
|
+
} from "./chunk-RUPPWAD6.js";
|
|
5
8
|
import {
|
|
6
9
|
detectAuditEvents
|
|
7
10
|
} from "./chunk-5L3FPQIJ.js";
|
|
@@ -278,6 +281,12 @@ async function writeAuditsToAirtable(args) {
|
|
|
278
281
|
}
|
|
279
282
|
return { siteName: target.name, writes, events, siteId: target.id, fields };
|
|
280
283
|
}
|
|
284
|
+
function fleetWriteFailed(result, strict = TURSO_IS_AUTHORITATIVE) {
|
|
285
|
+
if (result.failed.length > 0) return true;
|
|
286
|
+
if (!strict) return false;
|
|
287
|
+
if (result.mirrored === void 0) return true;
|
|
288
|
+
return (result.mirrorFailed ?? 0) > 0 || (result.mirrorMissed ?? 0) > 0;
|
|
289
|
+
}
|
|
281
290
|
function formatFleetWriteSummary(result) {
|
|
282
291
|
const wrote = result.written.length;
|
|
283
292
|
const failed = result.failed.length;
|
|
@@ -330,7 +339,8 @@ async function writeFleetAuditsToAirtable(args) {
|
|
|
330
339
|
|
|
331
340
|
export {
|
|
332
341
|
writeAuditsToAirtable,
|
|
342
|
+
fleetWriteFailed,
|
|
333
343
|
formatFleetWriteSummary,
|
|
334
344
|
writeFleetAuditsToAirtable
|
|
335
345
|
};
|
|
336
|
-
//# sourceMappingURL=chunk-
|
|
346
|
+
//# sourceMappingURL=chunk-KMCZA7CH.js.map
|