@reddoorla/maintenance 0.87.0 → 0.88.3
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-EATHOHKK.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-NXNPDCF3.js → chunk-6UCVWYDA.js} +11 -1
- package/dist/chunk-6UCVWYDA.js.map +1 -0
- package/dist/{chunk-ZBDZTJUO.js → chunk-7FFUBF4I.js} +5 -2
- package/dist/chunk-7FFUBF4I.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-HSI6Q7MI.js → chunk-CEW5PEHZ.js} +168 -31
- package/dist/chunk-CEW5PEHZ.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-MT2JINSS.js → chunk-KJGKZLCT.js} +3 -3
- 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-NCMZMLY7.js → chunk-WPL5GERE.js} +5 -69
- package/dist/chunk-WPL5GERE.js.map +1 -0
- package/dist/{chunk-JHGHLKFS.js → chunk-WS6NU475.js} +2 -2
- package/dist/{chunk-DEWJMMG3.js → chunk-XKDCR4UE.js} +19 -8
- package/dist/chunk-XKDCR4UE.js.map +1 -0
- package/dist/{chunk-ZGBEIWSC.js → chunk-XP5WM2LV.js} +5 -5
- package/dist/{chunk-TD2NNXLZ.js → chunk-XSOINOPY.js} +8 -8
- package/dist/{chunk-6TWWY7ZU.js → chunk-Z3JXLULD.js} +2 -2
- package/dist/cli/bin.js +31 -28
- 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-DXUUAMXT.js} +121 -26
- package/dist/db-DXUUAMXT.js.map +1 -0
- package/dist/{digest-FW6QMZZJ.js → digest-NBLECFM7.js} +110 -29
- package/dist/digest-NBLECFM7.js.map +1 -0
- package/dist/digest-collectors-NM7WUWQP.js +50 -0
- package/dist/digest-state-3KHZF7GD.js +50 -0
- package/dist/digest-state-3KHZF7GD.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-FMMEUPX6.js} +16 -6
- package/dist/{email-KJXASVH5.js.map → email-FMMEUPX6.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-DB25UWY4.js} +7 -6
- package/dist/{github-signals-E2WPYTBK.js.map → github-signals-DB25UWY4.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.d.ts +3 -0
- package/dist/index.js +136 -95
- package/dist/index.js.map +1 -1
- package/dist/{init-XRAQZYID.js → init-32AWPS7A.js} +9 -9
- package/dist/{launch-IX4Y5JJC.js → launch-52WUE4TZ.js} +12 -11
- package/dist/{launch-IX4Y5JJC.js.map → launch-52WUE4TZ.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-YIBIQ7XK.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-IJWZW2EO.js} +6 -6
- package/dist/prospect/types.d.ts +33 -0
- package/dist/{prospect-audit-KSXUNQ5J.js → prospect-audit-WJQI7V5V.js} +32 -13
- package/dist/prospect-audit-WJQI7V5V.js.map +1 -0
- package/dist/render-CVKE7IWC.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-2LM2SX67.js} +20 -20
- 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-DIZ5JD2X.js} +12 -12
- 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/spam-classifier-T5H4VTP6.js.map +1 -0
- 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-OPBJXGS4.js} +5 -2
- package/dist/write-audits-to-airtable-OPBJXGS4.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-EATHOHKK.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-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-TD2NNXLZ.js.map → chunk-XSOINOPY.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-collectors-NM7WUWQP.js.map} +0 -0
- /package/dist/{migrate-GQH237A6.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/{notify-C645PGD5.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-32AWPS7A.js.map} +0 -0
- /package/dist/{orchestrate-YLWQ6KK2.js.map → migrate-YITCXBLS.js.map} +0 -0
- /package/dist/{parity-56KA2DGR.js.map → notify-4HP4GZ2H.js.map} +0 -0
- /package/dist/{onboard-O4IAHJMO.js.map → onboard-BKCYBAVW.js.map} +0 -0
- /package/dist/{pipeline-3AANZFCR.js.map → orchestrate-JNKCWU5K.js.map} +0 -0
- /package/dist/{render-IFOGFB5T.js.map → parity-NBYZUBFU.js.map} +0 -0
- /package/dist/{render-from-row-BWSOIYDS.js.map → pipeline-YIBIQ7XK.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-IJWZW2EO.js.map} +0 -0
- /package/dist/{spam-classifier-XLBFCO7G.js.map → render-CVKE7IWC.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-CMKHGJNQ.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-2LM2SX67.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-DIZ5JD2X.js.map} +0 -0
- /package/dist/{smoke-suite-U3UKD3NS.js.map → smoke-suite-4JFZZYVW.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,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NOTIFY_BOUNCE_THRESHOLD,
|
|
3
|
+
NOTIFY_BOUNCE_WINDOW_DAYS,
|
|
4
|
+
collectAnalyticsFailures,
|
|
5
|
+
collectCiAlerts,
|
|
6
|
+
collectDeliveryFailures,
|
|
7
|
+
collectLighthouseAlerts,
|
|
8
|
+
collectNotifyBounceAlerts,
|
|
9
|
+
collectPreflightBlocked,
|
|
10
|
+
collectPrismicDriftAlerts,
|
|
11
|
+
collectRenovateAlerts,
|
|
12
|
+
collectTurnstileGuardrailAlerts,
|
|
13
|
+
collectVulnAlerts,
|
|
14
|
+
prismicAckIsLive
|
|
15
|
+
} from "./chunk-WPL5GERE.js";
|
|
16
|
+
import "./chunk-6I6L3CMX.js";
|
|
17
|
+
import "./chunk-PZR4EBPY.js";
|
|
18
|
+
import "./chunk-DGZWFI2G.js";
|
|
19
|
+
import "./chunk-F3JWOPTG.js";
|
|
20
|
+
import "./chunk-VAD43MOR.js";
|
|
21
|
+
import "./chunk-IY6256GK.js";
|
|
22
|
+
import "./chunk-HN6I2XWI.js";
|
|
23
|
+
import "./chunk-XP5WM2LV.js";
|
|
24
|
+
import "./chunk-GECESMVS.js";
|
|
25
|
+
import "./chunk-5UINJNAJ.js";
|
|
26
|
+
import "./chunk-KJGKZLCT.js";
|
|
27
|
+
import "./chunk-BGTYPVLT.js";
|
|
28
|
+
import "./chunk-7NYN2P6I.js";
|
|
29
|
+
import "./chunk-WEKL2HYJ.js";
|
|
30
|
+
import "./chunk-W45FZEGE.js";
|
|
31
|
+
import "./chunk-RARREDQE.js";
|
|
32
|
+
import "./chunk-WS6NU475.js";
|
|
33
|
+
import "./chunk-MFHS7IQ2.js";
|
|
34
|
+
import "./chunk-6VAL7XJT.js";
|
|
35
|
+
export {
|
|
36
|
+
NOTIFY_BOUNCE_THRESHOLD,
|
|
37
|
+
NOTIFY_BOUNCE_WINDOW_DAYS,
|
|
38
|
+
collectAnalyticsFailures,
|
|
39
|
+
collectCiAlerts,
|
|
40
|
+
collectDeliveryFailures,
|
|
41
|
+
collectLighthouseAlerts,
|
|
42
|
+
collectNotifyBounceAlerts,
|
|
43
|
+
collectPreflightBlocked,
|
|
44
|
+
collectPrismicDriftAlerts,
|
|
45
|
+
collectRenovateAlerts,
|
|
46
|
+
collectTurnstileGuardrailAlerts,
|
|
47
|
+
collectVulnAlerts,
|
|
48
|
+
prismicAckIsLive
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=digest-collectors-NM7WUWQP.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/db/digest-state.ts
|
|
2
|
+
var DIGEST_STATE_ID = "default";
|
|
3
|
+
async function readDigestState(db) {
|
|
4
|
+
const row = await db.selectFrom("digest_state").select("snapshot").where("id", "=", DIGEST_STATE_ID).executeTakeFirst();
|
|
5
|
+
if (!row) return {};
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(row.snapshot);
|
|
8
|
+
} catch {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function writeDigestState(db, snap, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
13
|
+
const row = {
|
|
14
|
+
id: DIGEST_STATE_ID,
|
|
15
|
+
snapshot: JSON.stringify(snap),
|
|
16
|
+
updated_at: updatedAt
|
|
17
|
+
};
|
|
18
|
+
await db.insertInto("digest_state").values(row).onConflict((oc) => oc.column("id").doUpdateSet(row)).execute();
|
|
19
|
+
}
|
|
20
|
+
var COCKPIT_ROLLUP_ID = "cockpit_rollup";
|
|
21
|
+
async function readCockpitRollup(db) {
|
|
22
|
+
const row = await db.selectFrom("digest_state").select("snapshot").where("id", "=", COCKPIT_ROLLUP_ID).executeTakeFirst();
|
|
23
|
+
if (!row) return null;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(row.snapshot);
|
|
26
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.computedAt !== "string" || typeof parsed.spamTotals !== "object" || parsed.spamTotals === null || typeof parsed.notifyBounces !== "object" || parsed.notifyBounces === null) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function writeCockpitRollup(db, rollup) {
|
|
35
|
+
const row = {
|
|
36
|
+
id: COCKPIT_ROLLUP_ID,
|
|
37
|
+
snapshot: JSON.stringify(rollup),
|
|
38
|
+
updated_at: rollup.computedAt
|
|
39
|
+
};
|
|
40
|
+
await db.insertInto("digest_state").values(row).onConflict((oc) => oc.column("id").doUpdateSet(row)).execute();
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
COCKPIT_ROLLUP_ID,
|
|
44
|
+
DIGEST_STATE_ID,
|
|
45
|
+
readCockpitRollup,
|
|
46
|
+
readDigestState,
|
|
47
|
+
writeCockpitRollup,
|
|
48
|
+
writeDigestState
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=digest-state-3KHZF7GD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/digest-state.ts"],"sourcesContent":["/** #609 (#539 Phase 5): the digest's prior-run snapshot, on Turso.\n *\n * Unlike every other Phase 5 slice this is a MIGRATION, not a mirror — there\n * was no Turso table to dual-write into, so these REPLACE the Airtable pair in\n * `src/alerts/digest-state.ts` rather than shadowing them. Nothing writes the\n * Airtable \"Digest State\" table after this, so parity does not cover the new\n * one: it has no counterpart to compare against.\n *\n * The pure half — `diffAttention`, and the `DigestSnapshot` type it is written\n * against — stays exactly where it is. Only the IO moved.\n *\n * Stored as ONE row holding the whole snapshot as JSON. Both readers\n * (`runDigest`'s diff and the fleet homepage's NEW badges) need the entire map,\n * so a keyed table would buy nothing on reads and its \"give me everything\"\n * query would be a raw scan needing a justified entry in the EXPLAIN gate's\n * allowlist. One row by primary key needs neither.\n *\n * Worth stating plainly: the homepage's read of this used to be an AIRTABLE\n * call on a request path — a Phase 2 leftover, since digest state was never in\n * that phase's scope. Moving it removes a live dependency, which is the same\n * failure class as the 2026-08-17 quota outage.\n */\nimport type { Db } from \"./client.js\";\nimport type { DigestSnapshot } from \"../alerts/digest-state.js\";\n\n/** The singleton row's key. A constant rather than a magic string at each call\n * site, so the \"there is exactly one row\" invariant is visible in one place. */\nexport const DIGEST_STATE_ID = \"default\";\n\n/**\n * The prior snapshot, or `{}` when there is none.\n *\n * A missing row and a malformed blob BOTH read as `{}` — the Airtable reader's\n * contract, kept verbatim. The digest runs unattended on a cron, so a bad blob\n * must cost accurate NEW badges for one run, never the whole send.\n */\nexport async function readDigestState(db: Db): Promise<DigestSnapshot> {\n const row = await db\n .selectFrom(\"digest_state\")\n .select(\"snapshot\")\n .where(\"id\", \"=\", DIGEST_STATE_ID)\n .executeTakeFirst();\n if (!row) return {};\n try {\n return JSON.parse(row.snapshot) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot, replacing whatever was there.\n *\n * An upsert on the constant id rather than a get-then-create: the Airtable\n * version needed two round-trips to decide, and here the singleton invariant is\n * enforced by the primary key instead of by the code that happens to run.\n */\nexport async function writeDigestState(\n db: Db,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const row = {\n id: DIGEST_STATE_ID,\n snapshot: JSON.stringify(snap),\n updated_at: updatedAt,\n };\n await db\n .insertInto(\"digest_state\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(row))\n .execute();\n}\n\n/**\n * A SECOND row in the same table, holding the cockpit's \"since a window\"\n * roll-ups. Keyed separately rather than folded into the snapshot: the snapshot\n * is the digest's own diff state and has one writer and one meaning; conflating\n * two unrelated payloads in one blob makes a partial write of either look like a\n * corruption of both.\n */\nexport const COCKPIT_ROLLUP_ID = \"cockpit_rollup\";\n\n/**\n * Fleet-wide counts the cockpit renders, computed once by the nightly digest\n * instead of aggregated on every page load.\n *\n * `computedAt` is not decoration. These figures are up to 24h old by\n * construction, and a stale number rendered as though it were live is worse than\n * no number — so the cockpit shows when it was taken.\n */\nexport type CockpitRollup = {\n spamTotals: { honeypot: number; tooFast: number; markedSpam: number };\n /** site id → notify=bounced count inside the bounce window. */\n notifyBounces: Record<string, number>;\n /** The windows these were computed over, so a changed constant is visible\n * rather than silently re-labelling old numbers. */\n windowDays: { screenOuts: number; bounces: number };\n computedAt: string;\n};\n\n/**\n * The stored roll-up, or **null** when there is none.\n *\n * Null rather than zeros, deliberately. Every one of these numbers has a\n * legitimate zero, so a reader given `{honeypot: 0, …}` cannot tell \"nothing was\n * screened out\" from \"the digest has never run\". That is the same distinction\n * `FLEET_SMOKE_UNMEASURED` exists to preserve, and the cockpit renders the strip\n * as ABSENT for null rather than drawing a row of zeros.\n *\n * A malformed blob is also null: same reasoning, and it must never throw on a\n * request path.\n */\nexport async function readCockpitRollup(db: Db): Promise<CockpitRollup | null> {\n const row = await db\n .selectFrom(\"digest_state\")\n .select(\"snapshot\")\n .where(\"id\", \"=\", COCKPIT_ROLLUP_ID)\n .executeTakeFirst();\n if (!row) return null;\n try {\n const parsed = JSON.parse(row.snapshot) as Partial<CockpitRollup>;\n // Shape-check rather than trust: this blob is written by a different process\n // on a different schedule, so an older writer's payload can outlive a type\n // change here. Anything unrecognized reads as \"not measured\".\n if (\n typeof parsed !== \"object\" ||\n parsed === null ||\n typeof parsed.computedAt !== \"string\" ||\n typeof parsed.spamTotals !== \"object\" ||\n parsed.spamTotals === null ||\n typeof parsed.notifyBounces !== \"object\" ||\n parsed.notifyBounces === null\n ) {\n return null;\n }\n return parsed as CockpitRollup;\n } catch {\n return null;\n }\n}\n\n/** Upsert the roll-up, same singleton-by-PK shape as the snapshot. */\nexport async function writeCockpitRollup(db: Db, rollup: CockpitRollup): Promise<void> {\n const row = {\n id: COCKPIT_ROLLUP_ID,\n snapshot: JSON.stringify(rollup),\n updated_at: rollup.computedAt,\n };\n await db\n .insertInto(\"digest_state\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(row))\n .execute();\n}\n"],"mappings":";AA2BO,IAAM,kBAAkB;AAS/B,eAAsB,gBAAgB,IAAiC;AACrE,QAAM,MAAM,MAAM,GACf,WAAW,cAAc,EACzB,OAAO,UAAU,EACjB,MAAM,MAAM,KAAK,eAAe,EAChC,iBAAiB;AACpB,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,MAAI;AACF,WAAO,KAAK,MAAM,IAAI,QAAQ;AAAA,EAChC,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AASA,eAAsB,iBACpB,IACA,MACA,aAAoB,oBAAI,KAAK,GAAE,YAAY,GAC5B;AACf,QAAM,MAAM;AAAA,IACV,IAAI;AAAA,IACJ,UAAU,KAAK,UAAU,IAAI;AAAA,IAC7B,YAAY;AAAA,EACd;AACA,QAAM,GACH,WAAW,cAAc,EACzB,OAAO,GAAG,EACV,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,GAAG,CAAC,EACnD,QAAQ;AACb;AASO,IAAM,oBAAoB;AAgCjC,eAAsB,kBAAkB,IAAuC;AAC7E,QAAM,MAAM,MAAM,GACf,WAAW,cAAc,EACzB,OAAO,UAAU,EACjB,MAAM,MAAM,KAAK,iBAAiB,EAClC,iBAAiB;AACpB,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,IAAI,QAAQ;AAItC,QACE,OAAO,WAAW,YAClB,WAAW,QACX,OAAO,OAAO,eAAe,YAC7B,OAAO,OAAO,eAAe,YAC7B,OAAO,eAAe,QACtB,OAAO,OAAO,kBAAkB,YAChC,OAAO,kBAAkB,MACzB;AACA,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,eAAsB,mBAAmB,IAAQ,QAAsC;AACrF,QAAM,MAAM;AAAA,IACV,IAAI;AAAA,IACJ,UAAU,KAAK,UAAU,MAAM;AAAA,IAC/B,YAAY,OAAO;AAAA,EACrB;AACA,QAAM,GACH,WAAW,cAAc,EACzB,OAAO,GAAG,EACV,WAAW,CAAC,OAAO,GAAG,OAAO,IAAI,EAAE,YAAY,GAAG,CAAC,EACnD,QAAQ;AACb;","names":[]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DIGEST_STATE_TABLE,
|
|
3
|
+
diffAttention,
|
|
4
|
+
readDigestState,
|
|
5
|
+
writeDigestState
|
|
6
|
+
} from "./chunk-BRYXFH77.js";
|
|
7
|
+
export {
|
|
8
|
+
DIGEST_STATE_TABLE,
|
|
9
|
+
diffAttention,
|
|
10
|
+
readDigestState,
|
|
11
|
+
writeDigestState
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=digest-state-F37XUCS5.js.map
|
|
@@ -13,10 +13,27 @@ function sqlLiteral(v) {
|
|
|
13
13
|
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
14
14
|
return `X'${hex}'`;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
const str = String(v);
|
|
17
|
+
if (str.includes("\0")) {
|
|
18
|
+
const bytes = new TextEncoder().encode(str);
|
|
19
|
+
let hex = "";
|
|
20
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
21
|
+
return `CAST(X'${hex}' AS TEXT)`;
|
|
22
|
+
}
|
|
23
|
+
return `'${str.replaceAll("'", "''")}'`;
|
|
17
24
|
}
|
|
18
|
-
async function dumpDatabase(db) {
|
|
19
|
-
const
|
|
25
|
+
async function dumpDatabase(db, generatedAt) {
|
|
26
|
+
const manifest = {
|
|
27
|
+
tables: await tableCounts(db),
|
|
28
|
+
blobBytes: await headerImageBytes(db),
|
|
29
|
+
generatedAt
|
|
30
|
+
};
|
|
31
|
+
const out = [
|
|
32
|
+
// First line, so a truncated dump still carries what it CLAIMED to hold.
|
|
33
|
+
`${MANIFEST_PREFIX}${JSON.stringify(manifest)}`,
|
|
34
|
+
"PRAGMA foreign_keys=OFF;",
|
|
35
|
+
"BEGIN TRANSACTION;"
|
|
36
|
+
];
|
|
20
37
|
const schema = await db.execute(
|
|
21
38
|
"SELECT name, type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name"
|
|
22
39
|
);
|
|
@@ -37,15 +54,21 @@ async function dumpDatabase(db) {
|
|
|
37
54
|
out.push("COMMIT;");
|
|
38
55
|
return out.join("\n") + "\n";
|
|
39
56
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
var MANIFEST_PREFIX = "-- REDDOOR_DUMP_MANIFEST ";
|
|
58
|
+
async function headerImageBytes(db) {
|
|
59
|
+
const r = await db.execute(
|
|
60
|
+
"SELECT COALESCE(SUM(LENGTH(header_image)), 0) AS n FROM sites WHERE header_image IS NOT NULL"
|
|
61
|
+
);
|
|
62
|
+
return Number(r.rows[0]?.n ?? 0);
|
|
63
|
+
}
|
|
64
|
+
function parseDumpManifest(sql) {
|
|
65
|
+
const line = sql.split("\n").find((l) => l.startsWith(MANIFEST_PREFIX));
|
|
66
|
+
if (!line) return null;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(line.slice(MANIFEST_PREFIX.length));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
47
71
|
}
|
|
48
|
-
return counts;
|
|
49
72
|
}
|
|
50
73
|
async function tableCounts(db) {
|
|
51
74
|
const schema = await db.execute(
|
|
@@ -60,9 +83,11 @@ async function tableCounts(db) {
|
|
|
60
83
|
return counts;
|
|
61
84
|
}
|
|
62
85
|
export {
|
|
63
|
-
|
|
86
|
+
MANIFEST_PREFIX,
|
|
64
87
|
dumpDatabase,
|
|
88
|
+
headerImageBytes,
|
|
89
|
+
parseDumpManifest,
|
|
65
90
|
sqlLiteral,
|
|
66
91
|
tableCounts
|
|
67
92
|
};
|
|
68
|
-
//# sourceMappingURL=dump-
|
|
93
|
+
//# sourceMappingURL=dump-EPL7AZFR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/dump.ts"],"sourcesContent":["/**\n * Phase 1.5 of #539: a platform-auth-free SQL dump of the whole database.\n *\n * `turso db dump` needs a PLATFORM login (browser OAuth) — a nightly workflow\n * has only the DATABASE-level url+token it already holds for every other job.\n * So the dump speaks plain SQL through the same client: schema straight from\n * `sqlite_master`, then every row as an INSERT. The output loads into stock\n * `sqlite3` (libSQL IS SQLite), which is exactly how the rehearsed restore\n * proves it — and how a real disaster would replay it into a fresh database.\n *\n * Determinism: tables and rows are emitted in stable order (name, then rowid)\n * so two dumps of an unchanged database are byte-identical — a diffable backup.\n */\n\n/** Minimal execute surface: the @libsql/client `execute` we need. Injectable so\n * tests run against :memory: without the real network client. */\nexport type SqlExecutor = {\n execute: (sql: string) => Promise<{\n columns: string[];\n rows: Array<Record<string, unknown>>;\n }>;\n};\n\nconst IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\nfunction quoteIdent(name: string): string {\n return IDENT.test(name) ? name : `\"${name.replaceAll('\"', '\"\"')}\"`;\n}\n\nexport function sqlLiteral(v: unknown): string {\n if (v === null || v === undefined) return \"NULL\";\n if (typeof v === \"number\") return Number.isFinite(v) ? String(v) : \"NULL\";\n if (typeof v === \"bigint\") return v.toString();\n if (v instanceof Uint8Array || v instanceof ArrayBuffer) {\n const bytes = v instanceof ArrayBuffer ? new Uint8Array(v) : v;\n let hex = \"\";\n for (const b of bytes) hex += b.toString(16).padStart(2, \"0\");\n return `X'${hex}'`;\n }\n const str = String(v);\n // A NUL cannot ride a single-quoted SQL literal. This used to strip them,\n // justified as \"they cannot legitimately appear in this schema's TEXT\n // columns\" — but `submissions` free text is attacker-supplied and SQLite\n // stores NUL inside TEXT quite happily, so the backup would have silently\n // differed from the origin with no signal. Emit the whole value as a hex blob\n // cast back to text instead: lossless, and it round-trips through the\n // rehearsal like any other literal.\n if (str.includes(\"\\u0000\")) {\n const bytes = new TextEncoder().encode(str);\n let hex = \"\";\n for (const b of bytes) hex += b.toString(16).padStart(2, \"0\");\n return `CAST(X'${hex}' AS TEXT)`;\n }\n // Strings: standard SQL escaping — double the single quotes.\n return `'${str.replaceAll(\"'\", \"''\")}'`;\n}\n\n/**\n * Dump schema + data as executable SQL. Skips SQLite's internal tables\n * (`sqlite_*`); includes indexes. Wrapped in a transaction so a partial load\n * fails atomically instead of leaving a half-restored scratch that could be\n * mistaken for a good one.\n */\nexport async function dumpDatabase(db: SqlExecutor, generatedAt: string): Promise<string> {\n // Read the ORIGIN's own numbers BEFORE serialising anything. This is the\n // whole point: it is the only measurement that does not come from the dump,\n // so it is the only one that can notice the dump is short.\n const manifest: DumpManifest = {\n tables: await tableCounts(db),\n blobBytes: await headerImageBytes(db),\n generatedAt,\n };\n const out: string[] = [\n // First line, so a truncated dump still carries what it CLAIMED to hold.\n `${MANIFEST_PREFIX}${JSON.stringify(manifest)}`,\n \"PRAGMA foreign_keys=OFF;\",\n \"BEGIN TRANSACTION;\",\n ];\n\n const schema = await db.execute(\n \"SELECT name, type, sql FROM sqlite_master \" +\n \"WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' \" +\n \"ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name\",\n );\n\n const tables: string[] = [];\n for (const row of schema.rows) {\n out.push(`${String(row.sql).trim().replace(/;?$/, \"\")};`);\n if (row.type === \"table\") tables.push(String(row.name));\n }\n\n for (const table of tables) {\n const data = await db.execute(`SELECT * FROM ${quoteIdent(table)} ORDER BY rowid`);\n if (data.rows.length === 0) continue;\n const cols = data.columns.map(quoteIdent).join(\", \");\n for (const row of data.rows) {\n const values = data.columns.map((c) => sqlLiteral(row[c])).join(\", \");\n out.push(`INSERT INTO ${quoteIdent(table)} (${cols}) VALUES (${values});`);\n }\n }\n\n out.push(\"COMMIT;\");\n return out.join(\"\\n\") + \"\\n\";\n}\n\n/** Marker for the origin manifest line, first line of every dump. */\nexport const MANIFEST_PREFIX = \"-- REDDOOR_DUMP_MANIFEST \";\n\n/** What the ORIGIN database held at dump time.\n *\n * This exists because the restore rehearsal used to compare the dump against\n * ITSELF: expected counts were parsed out of the dump text, and actual counts\n * came from loading that same text. Both sides moved together, so a dump that\n * collected 5 of 44 sites verified clean. A manifest read from the LIVE\n * database before any rows are serialised is the only thing that can catch\n * under-dumping.\n *\n * `blobBytes` is the cheap content check. Row counts alone pass a dump in\n * which every `header_image` came back NULL — and those bytes exist in no\n * other store once Airtable is frozen. */\nexport type DumpManifest = {\n tables: Record<string, number>;\n blobBytes: number;\n generatedAt: string;\n};\n\n/** Total stored header-image bytes, the one content signal cheap enough to\n * check on every nightly run. */\nexport async function headerImageBytes(db: SqlExecutor): Promise<number> {\n const r = await db.execute(\n \"SELECT COALESCE(SUM(LENGTH(header_image)), 0) AS n FROM sites WHERE header_image IS NOT NULL\",\n );\n return Number(r.rows[0]?.n ?? 0);\n}\n\n/** Parse the manifest line from a dump, or null when the dump predates it. */\nexport function parseDumpManifest(sql: string): DumpManifest | null {\n const line = sql.split(\"\\n\").find((l) => l.startsWith(MANIFEST_PREFIX));\n if (!line) return null;\n try {\n return JSON.parse(line.slice(MANIFEST_PREFIX.length)) as DumpManifest;\n } catch {\n return null;\n }\n}\n\n/** Per-table row counts — the cheap integrity check the restore rehearsal\n * compares across original vs restored. */\nexport async function tableCounts(db: SqlExecutor): Promise<Record<string, number>> {\n const schema = await db.execute(\n \"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name\",\n );\n const counts: Record<string, number> = {};\n for (const row of schema.rows) {\n const name = String(row.name);\n const r = await db.execute(`SELECT COUNT(*) AS c FROM ${quoteIdent(name)}`);\n counts[name] = Number(r.rows[0]?.c ?? 0);\n }\n return counts;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
renderProspectReport
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-XKDCR4UE.js";
|
|
4
|
+
import "./chunk-CEW5PEHZ.js";
|
|
5
5
|
import {
|
|
6
6
|
escapeHtml,
|
|
7
7
|
safeUrl
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-BGTYPVLT.js";
|
|
9
9
|
import {
|
|
10
10
|
defaultResendClient
|
|
11
11
|
} from "./chunk-WEKL2HYJ.js";
|
|
12
12
|
import {
|
|
13
13
|
hostnameOf
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-RARREDQE.js";
|
|
15
15
|
|
|
16
16
|
// src/prospect/email.ts
|
|
17
17
|
var FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
|
|
@@ -181,7 +181,17 @@ async function sendAuditEmail(result, opts) {
|
|
|
181
181
|
filename,
|
|
182
182
|
content: Buffer.from(attachmentHtml, "utf-8").toString("base64"),
|
|
183
183
|
contentType: "text/html"
|
|
184
|
-
}
|
|
184
|
+
},
|
|
185
|
+
// The PDF goes second so the HTML sheet stays the first attachment it has
|
|
186
|
+
// always been, and appears only when one was actually rendered — an empty
|
|
187
|
+
// or broken attachment is worse than none.
|
|
188
|
+
...opts.pdf ? [
|
|
189
|
+
{
|
|
190
|
+
filename: filename.replace(/\.html$/, ".pdf"),
|
|
191
|
+
content: opts.pdf.toString("base64"),
|
|
192
|
+
contentType: "application/pdf"
|
|
193
|
+
}
|
|
194
|
+
] : []
|
|
185
195
|
],
|
|
186
196
|
idempotencyKey
|
|
187
197
|
});
|
|
@@ -192,4 +202,4 @@ export {
|
|
|
192
202
|
parseProspectAuditRecipients,
|
|
193
203
|
sendAuditEmail
|
|
194
204
|
};
|
|
195
|
-
//# sourceMappingURL=email-
|
|
205
|
+
//# sourceMappingURL=email-FMMEUPX6.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/prospect/email.ts"],"sourcesContent":["import { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { hostnameOf } from \"../util/url.js\";\nimport { renderProspectReport } from \"./render.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport type { AnalyzeResult, Fix, ProspectAuditResult, StageResult } from \"./types.js\";\n\n/**\n * Mirrors `reports/send/orchestrate.ts`'s `FROM_ADDRESS` (its line 18) — copied,\n * not imported, exactly like `digest.ts` and `recipes/selftest-email.ts` already\n * do. orchestrate.ts's own `FROM_ADDRESS` isn't exported, and importing it would\n * drag its whole Airtable/MJML dependency chain into this module's graph, which\n * stays lazy-loaded on purpose (see bin.ts's central-dep-blocker comment — this\n * module reaches `resend`, a devDependency, and must only ever be reached via a\n * dynamic `import()` from the CLI).\n */\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n\nconst IMPACT_ORDER: Record<Fix[\"impact\"], number> = { high: 0, medium: 1, low: 2 };\n\n/** \"top few fixes\" per the design — enough to be useful on an internal sheet\n * without turning it into the full client report. */\nconst TOP_FIX_COUNT = 3;\n\nexport type AuditEmailContent = {\n subject: string;\n html: string;\n /** The FULL client-facing report (render.ts's `renderProspectReport`, pure\n * and deterministic like this function), so `sendAuditEmail` can attach it\n * without a second render call on the default path. */\n attachmentHtml: string;\n};\n\n/** The searchable business name when the pipeline resolved one, else the\n * hostname — mirrors render.ts's identical fallback (types.ts's businessName\n * doc: \"Null when no name was ever resolved... itself a finding, not an\n * error\"). Used for both the subject line and the sheet's own heading. */\nfunction resolveName(result: ProspectAuditResult): string {\n return result.businessName && result.businessName.trim()\n ? result.businessName\n : hostnameOf(result.url);\n}\n\n/**\n * Mail subject headers are plain text, never HTML-rendered by a client — no\n * `escapeHtml` needed there (that would wrongly show literal \"<\" etc. in\n * the subject line). A raw newline/control character IS worth collapsing:\n * a business name that embeds one (scraped page title, model output) would\n * otherwise visibly break the subject, and a stray CR/LF in a header value\n * is the classic email-header-injection vector — defense-in-depth even\n * though the Resend API's JSON transport already neutralizes it here.\n */\nfunction subjectSafe(s: string): string {\n return s.replace(/\\s+/g, \" \").trim();\n}\n\n/** Local copy of render.ts's date formatter — small enough that importing it\n * (render.ts doesn't export it) isn't worth the coupling. */\nfunction formatIsoDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleDateString(\"en-US\", {\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n timeZone: \"UTC\",\n });\n}\n\ntype NotMeasuredLine = { label: string; reason: string };\n\n/**\n * Everything on the sheet that reads \"not measured\", and why — in plain\n * words. Unlike render.ts's client-facing report (which collapses every\n * stage failure to STAGE_FAILED_MESSAGE so a stranger never sees operator\n * vocabulary), this email goes to Tucker/Tim/Erik: the real diagnostic\n * (a fetch error, an LLM timeout) is more useful to them than a sanitized\n * phrase, so stage `error` strings are surfaced verbatim (still escaped —\n * they can embed arbitrary text from a fetch response).\n *\n * The four-Scores branches mirror checks.ts's `computeScores` exactly: which\n * stage feeds which score, and the cases where that stage's OWN data still\n * nulls the score even though the stage itself succeeded (crawlerAccessMeasured\n * false, avgMissing null, empty buyerQuestions, a null visibilityScore). If\n * computeScores' conditions change, this needs the matching update.\n */\nfunction notMeasuredLines(result: ProspectAuditResult): NotMeasuredLine[] {\n const lines: NotMeasuredLine[] = [];\n\n if (!result.checks.ok) {\n lines.push({\n label: \"Findability\",\n reason: `the checks stage failed — ${result.checks.error}`,\n });\n lines.push({\n label: \"Readability\",\n reason: `the checks stage failed — ${result.checks.error}`,\n });\n } else {\n const c = result.checks.data;\n if (result.scores.findability === null) {\n lines.push({\n label: \"Findability\",\n reason: !c.crawlerAccessMeasured\n ? \"robots.txt could not be fetched, so crawler access wasn't measured\"\n : \"every crawled page failed to produce readable content\",\n });\n }\n if (result.scores.readability === null) {\n lines.push({\n label: \"Readability\",\n reason: \"no page produced a comparable raw/rendered pair to measure JavaScript dependence\",\n });\n }\n }\n\n if (!result.lighthouse.ok) {\n lines.push({ label: \"Lighthouse\", reason: result.lighthouse.error });\n }\n\n if (!result.analyze.ok) {\n lines.push({ label: \"Answers\", reason: result.analyze.error });\n } else if (result.scores.answers === null) {\n lines.push({ label: \"Answers\", reason: \"the model found no buyer questions to check\" });\n }\n\n if (!result.probes.ok) {\n lines.push({ label: \"AI Visibility\", reason: result.probes.error });\n } else if (result.scores.aiVisibility === null) {\n lines.push({ label: \"AI Visibility\", reason: \"no category (buyer-question) query ran\" });\n }\n\n return lines;\n}\n\nconst SCORE_FIELDS: { key: keyof ProspectAuditResult[\"scores\"]; label: string }[] = [\n { key: \"findability\", label: \"Findability\" },\n { key: \"readability\", label: \"Readability\" },\n { key: \"answers\", label: \"Answers\" },\n { key: \"aiVisibility\", label: \"AI Visibility\" },\n];\n\nfunction scoresTableHtml(scores: ProspectAuditResult[\"scores\"]): string {\n const cells = SCORE_FIELDS.map(({ key, label }) => {\n const value = scores[key];\n const display = value === null ? \"Not measured\" : String(value);\n const valueStyle =\n value === null ? \"color:#8a857e;font-size:14px;\" : \"color:#d71920;font-size:28px;\";\n return `<td style=\"padding:12px 16px;border:1px solid #e6e1da;text-align:center;\">\n <div style=\"font-weight:600;${valueStyle}\">${escapeHtml(display)}</div>\n <div style=\"font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#57544f;margin-top:4px;\">${escapeHtml(label)}</div>\n </td>`;\n }).join(\"\");\n return `<table role=\"presentation\" style=\"border-collapse:collapse;margin:16px 0;\"><tr>${cells}</tr></table>`;\n}\n\nfunction notMeasuredHtml(lines: NotMeasuredLine[]): string {\n if (lines.length === 0) return \"\";\n const items = lines\n .map((l) => `<li><strong>${escapeHtml(l.label)}</strong> — ${escapeHtml(l.reason)}</li>`)\n .join(\"\");\n return `<h2 style=\"font-size:16px;margin:24px 0 8px;\">Not measured</h2><ul style=\"margin:0;padding-left:20px;\">${items}</ul>`;\n}\n\n/** analyze.error and fix titles/why are untrusted (LLM output, or text lifted\n * from the prospect's own site) — every interpolation below is escaped. */\nfunction fixesHtml(analyze: StageResult<AnalyzeResult>): string {\n if (!analyze.ok) {\n return `<p style=\"color:#8a857e;margin:8px 0 0;\">Fixes not available — ${escapeHtml(analyze.error)}</p>`;\n }\n const top = [...analyze.data.fixes]\n .sort((a, b) => IMPACT_ORDER[a.impact] - IMPACT_ORDER[b.impact])\n .slice(0, TOP_FIX_COUNT);\n if (top.length === 0) {\n return `<p style=\"margin:8px 0 0;\">Nothing urgent — the essentials are already in place.</p>`;\n }\n const items = top\n .map(\n (f) =>\n `<li><strong>${escapeHtml(f.title)}</strong> — ${escapeHtml(f.why)} <span style=\"color:#8a857e;\">(${escapeHtml(f.impact)} impact, ${escapeHtml(f.effort)} effort)</span></li>`,\n )\n .join(\"\");\n return `<ol style=\"margin:8px 0 0;padding-left:20px;\">${items}</ol>`;\n}\n\nfunction linkHtml(link: string | null): string {\n if (!link) {\n return `<p style=\"margin:24px 0 0;color:#8a857e;\">No shareable link — the audit could not be saved to the database. The full report is attached to this email instead.</p>`;\n }\n const safe = safeUrl(link);\n return `<p style=\"margin:24px 0 0;\"><strong>Full report:</strong> <a href=\"${escapeHtml(safe)}\">${escapeHtml(link)}</a></p>`;\n}\n\n/** This sheet is for us, and it says so. It carries the real reasons a stage did\n * not run — useful internally, wrong in front of a prospect — so it has to be\n * obvious that the shareable artefact is the attachment and the link, not this\n * body. Without the label, the natural thing to do with a useful email is\n * forward it. */\nconst INTERNAL_NOTE = `<p style=\"margin:24px 0 0;padding:12px 14px;background:#f4f2ef;border-left:3px solid #d71920;color:#57544f;font-size:14px;\">\n Internal sheet — it names the reasons a section could not be measured. The\n version to send a prospect is the linked report, also attached to this email.\n</p>`;\n\n/**\n * Build the internal sheet — a SHORT summary for Tucker/Tim/Erik, distinct\n * from the client-facing report render.ts builds. Pure: no IO, so it's safe\n * to call from a test with no network. `attachmentHtml` is the full report\n * (render.ts's `renderProspectReport`, itself pure), included here so\n * `sendAuditEmail`'s default path doesn't need a second render call.\n *\n * Every value that can trace back to the prospect's site or the model —\n * the business name, fixes, stage error text, the narrative-adjacent bits —\n * is escaped with `escapeHtml`/`safeUrl`, the same honesty-and-safety rule\n * render.ts follows for the client-facing report.\n */\nexport function buildAuditEmail(\n result: ProspectAuditResult,\n opts: { link: string | null },\n): AuditEmailContent {\n const name = resolveName(result);\n const subject = `Prospect audit — ${subjectSafe(name)}`;\n\n const html = `<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /></head>\n<body style=\"margin:0;padding:24px;background:#faf8f5;color:#1a1a1a;font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;\">\n <h1 style=\"font-size:22px;margin:0 0 4px;\">Prospect audit — ${escapeHtml(name)}</h1>\n <p style=\"margin:0 0 16px;color:#57544f;\">\n <a href=\"${escapeHtml(safeUrl(result.url))}\">${escapeHtml(result.url)}</a> · audited ${escapeHtml(formatIsoDate(result.generatedAt))}\n </p>\n ${scoresTableHtml(result.scores)}\n ${notMeasuredHtml(notMeasuredLines(result))}\n <h2 style=\"font-size:16px;margin:24px 0 8px;\">Top fixes</h2>\n ${fixesHtml(result.analyze)}\n ${linkHtml(opts.link)}\n ${INTERNAL_NOTE}\n</body>\n</html>`;\n\n return { subject, html, attachmentHtml: renderProspectReport(result) };\n}\n\n/**\n * Parse `PROSPECT_AUDIT_RECIPIENTS` (comma-separated) into a clean address\n * list: trimmed, empties dropped, case-insensitively deduped — mirrors\n * `reports/send/orchestrate.ts`'s `parseAddresses` for the same reasons (an\n * operator-typed env var, same typo surface). No address is ever hardcoded\n * anywhere in this module; an unset or empty var yields `[]`, which\n * `sendAuditEmail` treats as \"skip, and say why\" rather than an error.\n */\nexport function parseProspectAuditRecipients(raw: string | undefined | null): string[] {\n if (!raw) return [];\n const seen = new Set<string>();\n const list: string[] = [];\n for (const part of raw.split(\",\")) {\n const trimmed = part.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list;\n}\n\n/** Filesystem-safe attachment-filename fragment. Tiny local copy of the CLI's\n * private `slugify` (prospect-audit.ts) — not worth exporting/sharing one\n * three-line predicate across a module boundary. */\nfunction slugify(s: string): string {\n const slug = s\n .toLowerCase()\n .replace(/[^a-z0-9.-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return slug || \"prospect\";\n}\n\nexport type SendAuditEmailOptions = {\n link: string | null;\n recipients: string[];\n /**\n * The persisted `prospect_audits` row id (from `createProspectAudit`), when\n * persistence succeeded. Mirrors orchestrate.ts's\n * `idempotencyKey: \\`report:${report.id}\\`` pattern, so re-sending for the\n * SAME already-persisted audit dedupes at Resend instead of double-mailing.\n * Null when persistence failed — the sheet can still be sent (it's built to\n * survive independently of Turso), but then there is no stable row id, so\n * the key falls back to `url`+`generatedAt`: that still protects a\n * same-process double-call, though — unlike a DB id — it can't survive an\n * entire pipeline rerun, which mints genuinely new content anyway.\n */\n auditId?: string | null;\n /** Defaults to `defaultResendClient()`. */\n client?: ResendClient;\n /** Test seam for the ATTACHMENT content only (production omits it and gets\n * the real `renderProspectReport` output `buildAuditEmail` already\n * computed) — lets a test of this function's sending mechanics (recipients,\n * idempotency key, attachment shape) skip re-deriving the full report. */\n renderReport?: (result: ProspectAuditResult) => string;\n};\n\nexport type SendAuditEmailResult =\n { sent: true; messageId: string; recipients: string[] } | { sent: false; reason: string };\n\n/**\n * Send the audit sheet with the full rendered report attached as an `.html`\n * file (base64), so the sheet survives independently of the link and of\n * Turso. Empty `recipients` is a legitimate, non-exceptional outcome (no\n * `PROSPECT_AUDIT_RECIPIENTS` configured) — returns `{sent:false}` rather\n * than throwing, so a caller can report it as a warning; a genuine send\n * failure (a throwing `client.send`) still propagates as a real exception.\n */\nexport async function sendAuditEmail(\n result: ProspectAuditResult,\n opts: SendAuditEmailOptions,\n): Promise<SendAuditEmailResult> {\n if (opts.recipients.length === 0) {\n return {\n sent: false,\n reason: \"no recipients configured (PROSPECT_AUDIT_RECIPIENTS is unset or empty)\",\n };\n }\n\n const built = buildAuditEmail(result, { link: opts.link });\n const attachmentHtml = opts.renderReport ? opts.renderReport(result) : built.attachmentHtml;\n const client = opts.client ?? defaultResendClient();\n const idempotencyKey = `prospect-audit:${opts.auditId ?? `${result.url}#${result.generatedAt}`}`;\n const filename = `prospect-audit-${slugify(hostnameOf(result.url))}.html`;\n\n const { messageId } = await client.send({\n from: FROM_ADDRESS,\n to: opts.recipients,\n subject: built.subject,\n html: built.html,\n attachments: [\n {\n filename,\n content: Buffer.from(attachmentHtml, \"utf-8\").toString(\"base64\"),\n contentType: \"text/html\",\n },\n ],\n idempotencyKey,\n });\n\n return { sent: true, messageId, recipients: opts.recipients };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/prospect/email.ts"],"sourcesContent":["import { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { hostnameOf } from \"../util/url.js\";\nimport { renderProspectReport } from \"./render.js\";\nimport { defaultResendClient, type ResendClient } from \"../reports/send/resend.js\";\nimport type { AnalyzeResult, Fix, ProspectAuditResult, StageResult } from \"./types.js\";\n\n/**\n * Mirrors `reports/send/orchestrate.ts`'s `FROM_ADDRESS` (its line 18) — copied,\n * not imported, exactly like `digest.ts` and `recipes/selftest-email.ts` already\n * do. orchestrate.ts's own `FROM_ADDRESS` isn't exported, and importing it would\n * drag its whole Airtable/MJML dependency chain into this module's graph, which\n * stays lazy-loaded on purpose (see bin.ts's central-dep-blocker comment — this\n * module reaches `resend`, a devDependency, and must only ever be reached via a\n * dynamic `import()` from the CLI).\n */\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n\nconst IMPACT_ORDER: Record<Fix[\"impact\"], number> = { high: 0, medium: 1, low: 2 };\n\n/** \"top few fixes\" per the design — enough to be useful on an internal sheet\n * without turning it into the full client report. */\nconst TOP_FIX_COUNT = 3;\n\nexport type AuditEmailContent = {\n subject: string;\n html: string;\n /** The FULL client-facing report (render.ts's `renderProspectReport`, pure\n * and deterministic like this function), so `sendAuditEmail` can attach it\n * without a second render call on the default path. */\n attachmentHtml: string;\n};\n\n/** The searchable business name when the pipeline resolved one, else the\n * hostname — mirrors render.ts's identical fallback (types.ts's businessName\n * doc: \"Null when no name was ever resolved... itself a finding, not an\n * error\"). Used for both the subject line and the sheet's own heading. */\nfunction resolveName(result: ProspectAuditResult): string {\n return result.businessName && result.businessName.trim()\n ? result.businessName\n : hostnameOf(result.url);\n}\n\n/**\n * Mail subject headers are plain text, never HTML-rendered by a client — no\n * `escapeHtml` needed there (that would wrongly show literal \"<\" etc. in\n * the subject line). A raw newline/control character IS worth collapsing:\n * a business name that embeds one (scraped page title, model output) would\n * otherwise visibly break the subject, and a stray CR/LF in a header value\n * is the classic email-header-injection vector — defense-in-depth even\n * though the Resend API's JSON transport already neutralizes it here.\n */\nfunction subjectSafe(s: string): string {\n return s.replace(/\\s+/g, \" \").trim();\n}\n\n/** Local copy of render.ts's date formatter — small enough that importing it\n * (render.ts doesn't export it) isn't worth the coupling. */\nfunction formatIsoDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleDateString(\"en-US\", {\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n timeZone: \"UTC\",\n });\n}\n\ntype NotMeasuredLine = { label: string; reason: string };\n\n/**\n * Everything on the sheet that reads \"not measured\", and why — in plain\n * words. Unlike render.ts's client-facing report (which collapses every\n * stage failure to STAGE_FAILED_MESSAGE so a stranger never sees operator\n * vocabulary), this email goes to Tucker/Tim/Erik: the real diagnostic\n * (a fetch error, an LLM timeout) is more useful to them than a sanitized\n * phrase, so stage `error` strings are surfaced verbatim (still escaped —\n * they can embed arbitrary text from a fetch response).\n *\n * The four-Scores branches mirror checks.ts's `computeScores` exactly: which\n * stage feeds which score, and the cases where that stage's OWN data still\n * nulls the score even though the stage itself succeeded (crawlerAccessMeasured\n * false, avgMissing null, empty buyerQuestions, a null visibilityScore). If\n * computeScores' conditions change, this needs the matching update.\n */\nfunction notMeasuredLines(result: ProspectAuditResult): NotMeasuredLine[] {\n const lines: NotMeasuredLine[] = [];\n\n if (!result.checks.ok) {\n lines.push({\n label: \"Findability\",\n reason: `the checks stage failed — ${result.checks.error}`,\n });\n lines.push({\n label: \"Readability\",\n reason: `the checks stage failed — ${result.checks.error}`,\n });\n } else {\n const c = result.checks.data;\n if (result.scores.findability === null) {\n lines.push({\n label: \"Findability\",\n reason: !c.crawlerAccessMeasured\n ? \"robots.txt could not be fetched, so crawler access wasn't measured\"\n : \"every crawled page failed to produce readable content\",\n });\n }\n if (result.scores.readability === null) {\n lines.push({\n label: \"Readability\",\n reason: \"no page produced a comparable raw/rendered pair to measure JavaScript dependence\",\n });\n }\n }\n\n if (!result.lighthouse.ok) {\n lines.push({ label: \"Lighthouse\", reason: result.lighthouse.error });\n }\n\n if (!result.analyze.ok) {\n lines.push({ label: \"Answers\", reason: result.analyze.error });\n } else if (result.scores.answers === null) {\n lines.push({ label: \"Answers\", reason: \"the model found no buyer questions to check\" });\n }\n\n if (!result.probes.ok) {\n lines.push({ label: \"AI Visibility\", reason: result.probes.error });\n } else if (result.scores.aiVisibility === null) {\n lines.push({ label: \"AI Visibility\", reason: \"no category (buyer-question) query ran\" });\n }\n\n return lines;\n}\n\nconst SCORE_FIELDS: { key: keyof ProspectAuditResult[\"scores\"]; label: string }[] = [\n { key: \"findability\", label: \"Findability\" },\n { key: \"readability\", label: \"Readability\" },\n { key: \"answers\", label: \"Answers\" },\n { key: \"aiVisibility\", label: \"AI Visibility\" },\n];\n\nfunction scoresTableHtml(scores: ProspectAuditResult[\"scores\"]): string {\n const cells = SCORE_FIELDS.map(({ key, label }) => {\n const value = scores[key];\n const display = value === null ? \"Not measured\" : String(value);\n const valueStyle =\n value === null ? \"color:#8a857e;font-size:14px;\" : \"color:#d71920;font-size:28px;\";\n return `<td style=\"padding:12px 16px;border:1px solid #e6e1da;text-align:center;\">\n <div style=\"font-weight:600;${valueStyle}\">${escapeHtml(display)}</div>\n <div style=\"font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#57544f;margin-top:4px;\">${escapeHtml(label)}</div>\n </td>`;\n }).join(\"\");\n return `<table role=\"presentation\" style=\"border-collapse:collapse;margin:16px 0;\"><tr>${cells}</tr></table>`;\n}\n\nfunction notMeasuredHtml(lines: NotMeasuredLine[]): string {\n if (lines.length === 0) return \"\";\n const items = lines\n .map((l) => `<li><strong>${escapeHtml(l.label)}</strong> — ${escapeHtml(l.reason)}</li>`)\n .join(\"\");\n return `<h2 style=\"font-size:16px;margin:24px 0 8px;\">Not measured</h2><ul style=\"margin:0;padding-left:20px;\">${items}</ul>`;\n}\n\n/** analyze.error and fix titles/why are untrusted (LLM output, or text lifted\n * from the prospect's own site) — every interpolation below is escaped. */\nfunction fixesHtml(analyze: StageResult<AnalyzeResult>): string {\n if (!analyze.ok) {\n return `<p style=\"color:#8a857e;margin:8px 0 0;\">Fixes not available — ${escapeHtml(analyze.error)}</p>`;\n }\n const top = [...analyze.data.fixes]\n .sort((a, b) => IMPACT_ORDER[a.impact] - IMPACT_ORDER[b.impact])\n .slice(0, TOP_FIX_COUNT);\n if (top.length === 0) {\n return `<p style=\"margin:8px 0 0;\">Nothing urgent — the essentials are already in place.</p>`;\n }\n const items = top\n .map(\n (f) =>\n `<li><strong>${escapeHtml(f.title)}</strong> — ${escapeHtml(f.why)} <span style=\"color:#8a857e;\">(${escapeHtml(f.impact)} impact, ${escapeHtml(f.effort)} effort)</span></li>`,\n )\n .join(\"\");\n return `<ol style=\"margin:8px 0 0;padding-left:20px;\">${items}</ol>`;\n}\n\nfunction linkHtml(link: string | null): string {\n if (!link) {\n return `<p style=\"margin:24px 0 0;color:#8a857e;\">No shareable link — the audit could not be saved to the database. The full report is attached to this email instead.</p>`;\n }\n const safe = safeUrl(link);\n return `<p style=\"margin:24px 0 0;\"><strong>Full report:</strong> <a href=\"${escapeHtml(safe)}\">${escapeHtml(link)}</a></p>`;\n}\n\n/** This sheet is for us, and it says so. It carries the real reasons a stage did\n * not run — useful internally, wrong in front of a prospect — so it has to be\n * obvious that the shareable artefact is the attachment and the link, not this\n * body. Without the label, the natural thing to do with a useful email is\n * forward it. */\nconst INTERNAL_NOTE = `<p style=\"margin:24px 0 0;padding:12px 14px;background:#f4f2ef;border-left:3px solid #d71920;color:#57544f;font-size:14px;\">\n Internal sheet — it names the reasons a section could not be measured. The\n version to send a prospect is the linked report, also attached to this email.\n</p>`;\n\n/**\n * Build the internal sheet — a SHORT summary for Tucker/Tim/Erik, distinct\n * from the client-facing report render.ts builds. Pure: no IO, so it's safe\n * to call from a test with no network. `attachmentHtml` is the full report\n * (render.ts's `renderProspectReport`, itself pure), included here so\n * `sendAuditEmail`'s default path doesn't need a second render call.\n *\n * Every value that can trace back to the prospect's site or the model —\n * the business name, fixes, stage error text, the narrative-adjacent bits —\n * is escaped with `escapeHtml`/`safeUrl`, the same honesty-and-safety rule\n * render.ts follows for the client-facing report.\n */\nexport function buildAuditEmail(\n result: ProspectAuditResult,\n opts: { link: string | null },\n): AuditEmailContent {\n const name = resolveName(result);\n const subject = `Prospect audit — ${subjectSafe(name)}`;\n\n const html = `<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /></head>\n<body style=\"margin:0;padding:24px;background:#faf8f5;color:#1a1a1a;font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;\">\n <h1 style=\"font-size:22px;margin:0 0 4px;\">Prospect audit — ${escapeHtml(name)}</h1>\n <p style=\"margin:0 0 16px;color:#57544f;\">\n <a href=\"${escapeHtml(safeUrl(result.url))}\">${escapeHtml(result.url)}</a> · audited ${escapeHtml(formatIsoDate(result.generatedAt))}\n </p>\n ${scoresTableHtml(result.scores)}\n ${notMeasuredHtml(notMeasuredLines(result))}\n <h2 style=\"font-size:16px;margin:24px 0 8px;\">Top fixes</h2>\n ${fixesHtml(result.analyze)}\n ${linkHtml(opts.link)}\n ${INTERNAL_NOTE}\n</body>\n</html>`;\n\n return { subject, html, attachmentHtml: renderProspectReport(result) };\n}\n\n/**\n * Parse `PROSPECT_AUDIT_RECIPIENTS` (comma-separated) into a clean address\n * list: trimmed, empties dropped, case-insensitively deduped — mirrors\n * `reports/send/orchestrate.ts`'s `parseAddresses` for the same reasons (an\n * operator-typed env var, same typo surface). No address is ever hardcoded\n * anywhere in this module; an unset or empty var yields `[]`, which\n * `sendAuditEmail` treats as \"skip, and say why\" rather than an error.\n */\nexport function parseProspectAuditRecipients(raw: string | undefined | null): string[] {\n if (!raw) return [];\n const seen = new Set<string>();\n const list: string[] = [];\n for (const part of raw.split(\",\")) {\n const trimmed = part.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list;\n}\n\n/** Filesystem-safe attachment-filename fragment. Tiny local copy of the CLI's\n * private `slugify` (prospect-audit.ts) — not worth exporting/sharing one\n * three-line predicate across a module boundary. */\nfunction slugify(s: string): string {\n const slug = s\n .toLowerCase()\n .replace(/[^a-z0-9.-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return slug || \"prospect\";\n}\n\nexport type SendAuditEmailOptions = {\n link: string | null;\n recipients: string[];\n /**\n * The persisted `prospect_audits` row id (from `createProspectAudit`), when\n * persistence succeeded. Mirrors orchestrate.ts's\n * `idempotencyKey: \\`report:${report.id}\\`` pattern, so re-sending for the\n * SAME already-persisted audit dedupes at Resend instead of double-mailing.\n * Null when persistence failed — the sheet can still be sent (it's built to\n * survive independently of Turso), but then there is no stable row id, so\n * the key falls back to `url`+`generatedAt`: that still protects a\n * same-process double-call, though — unlike a DB id — it can't survive an\n * entire pipeline rerun, which mints genuinely new content anyway.\n */\n auditId?: string | null;\n /**\n * The print-designed PDF leave-behind, when one was rendered.\n *\n * Optional on purpose: rendering it needs a live page and a headless browser,\n * and neither is worth losing a delivered report over. Absent, the email goes\n * exactly as it did before — the HTML sheet and the link — and the caller has\n * already recorded a warning saying why.\n */\n pdf?: Buffer | null;\n /** Defaults to `defaultResendClient()`. */\n client?: ResendClient;\n /** Test seam for the ATTACHMENT content only (production omits it and gets\n * the real `renderProspectReport` output `buildAuditEmail` already\n * computed) — lets a test of this function's sending mechanics (recipients,\n * idempotency key, attachment shape) skip re-deriving the full report. */\n renderReport?: (result: ProspectAuditResult) => string;\n};\n\nexport type SendAuditEmailResult =\n { sent: true; messageId: string; recipients: string[] } | { sent: false; reason: string };\n\n/**\n * Send the audit sheet with the full rendered report attached as an `.html`\n * file (base64), so the sheet survives independently of the link and of\n * Turso. Empty `recipients` is a legitimate, non-exceptional outcome (no\n * `PROSPECT_AUDIT_RECIPIENTS` configured) — returns `{sent:false}` rather\n * than throwing, so a caller can report it as a warning; a genuine send\n * failure (a throwing `client.send`) still propagates as a real exception.\n */\nexport async function sendAuditEmail(\n result: ProspectAuditResult,\n opts: SendAuditEmailOptions,\n): Promise<SendAuditEmailResult> {\n if (opts.recipients.length === 0) {\n return {\n sent: false,\n reason: \"no recipients configured (PROSPECT_AUDIT_RECIPIENTS is unset or empty)\",\n };\n }\n\n const built = buildAuditEmail(result, { link: opts.link });\n const attachmentHtml = opts.renderReport ? opts.renderReport(result) : built.attachmentHtml;\n const client = opts.client ?? defaultResendClient();\n const idempotencyKey = `prospect-audit:${opts.auditId ?? `${result.url}#${result.generatedAt}`}`;\n const filename = `prospect-audit-${slugify(hostnameOf(result.url))}.html`;\n\n const { messageId } = await client.send({\n from: FROM_ADDRESS,\n to: opts.recipients,\n subject: built.subject,\n html: built.html,\n attachments: [\n {\n filename,\n content: Buffer.from(attachmentHtml, \"utf-8\").toString(\"base64\"),\n contentType: \"text/html\",\n },\n // The PDF goes second so the HTML sheet stays the first attachment it has\n // always been, and appears only when one was actually rendered — an empty\n // or broken attachment is worse than none.\n ...(opts.pdf\n ? [\n {\n filename: filename.replace(/\\.html$/, \".pdf\"),\n content: opts.pdf.toString(\"base64\"),\n contentType: \"application/pdf\",\n },\n ]\n : []),\n ],\n idempotencyKey,\n });\n\n return { sent: true, messageId, recipients: opts.recipients };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -66,7 +66,7 @@ async function runEnsureSiteCommand(slug, opts) {
|
|
|
66
66
|
if (!slug) return { output: "Provide a <slug> (e.g. `ensure-site roalson`).", code: 2 };
|
|
67
67
|
try {
|
|
68
68
|
const base = openBase(readAirtableConfig());
|
|
69
|
-
const { makeSiteMirror } = await import("./site-mirror-
|
|
69
|
+
const { makeSiteMirror } = await import("./site-mirror-Q763PLGG.js");
|
|
70
70
|
const result = await ensureSite(
|
|
71
71
|
base,
|
|
72
72
|
{
|
|
@@ -93,4 +93,4 @@ async function runEnsureSiteCommand(slug, opts) {
|
|
|
93
93
|
export {
|
|
94
94
|
runEnsureSiteCommand
|
|
95
95
|
};
|
|
96
|
-
//# sourceMappingURL=ensure-site-
|
|
96
|
+
//# sourceMappingURL=ensure-site-FU6527OU.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
REPORT_LIST_COLUMNS,
|
|
2
3
|
getReportById,
|
|
3
4
|
getReportHtml,
|
|
4
5
|
getSiteById,
|
|
@@ -14,12 +15,13 @@ import {
|
|
|
14
15
|
mirrorSiteFields,
|
|
15
16
|
mirrorSiteInsert,
|
|
16
17
|
storeRenderedHtml
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-GQDDWYU2.js";
|
|
18
19
|
import "./chunk-P6AORG32.js";
|
|
19
20
|
import "./chunk-F3JWOPTG.js";
|
|
20
21
|
import "./chunk-VAD43MOR.js";
|
|
21
22
|
import "./chunk-7NYN2P6I.js";
|
|
22
23
|
export {
|
|
24
|
+
REPORT_LIST_COLUMNS,
|
|
23
25
|
getReportById,
|
|
24
26
|
getReportHtml,
|
|
25
27
|
getSiteById,
|
|
@@ -36,4 +38,4 @@ export {
|
|
|
36
38
|
mirrorSiteInsert,
|
|
37
39
|
storeRenderedHtml
|
|
38
40
|
};
|
|
39
|
-
//# sourceMappingURL=fleet-state-
|
|
41
|
+
//# sourceMappingURL=fleet-state-R5LU7IC3.js.map
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
describeNotifyTarget
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-SJOXWJ5Y.js";
|
|
4
|
+
import "./chunk-JRHIFA5R.js";
|
|
5
|
+
import "./chunk-NO74DXDP.js";
|
|
6
6
|
import "./chunk-5ML7FMBD.js";
|
|
7
7
|
import {
|
|
8
8
|
openBase,
|
|
9
9
|
readAirtableConfig
|
|
10
10
|
} from "./chunk-HN6I2XWI.js";
|
|
11
|
-
import "./chunk-
|
|
11
|
+
import "./chunk-BGTYPVLT.js";
|
|
12
12
|
import {
|
|
13
13
|
canonicalizeStatus,
|
|
14
14
|
listWebsites,
|
|
@@ -143,7 +143,7 @@ async function runFormsNotifyTargetCommand(site, opts) {
|
|
|
143
143
|
return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };
|
|
144
144
|
}
|
|
145
145
|
try {
|
|
146
|
-
const { makeSiteMirror } = await import("./site-mirror-
|
|
146
|
+
const { makeSiteMirror } = await import("./site-mirror-Q763PLGG.js");
|
|
147
147
|
const result = await formsNotifyTarget({
|
|
148
148
|
siteMirror: await makeSiteMirror(),
|
|
149
149
|
site: site.trim(),
|
|
@@ -163,4 +163,4 @@ export {
|
|
|
163
163
|
formatNotifyTarget,
|
|
164
164
|
runFormsNotifyTargetCommand
|
|
165
165
|
};
|
|
166
|
-
//# sourceMappingURL=forms-notify-target-
|
|
166
|
+
//# sourceMappingURL=forms-notify-target-SGDGXXK5.js.map
|
|
@@ -3,7 +3,8 @@ import {
|
|
|
3
3
|
} from "./chunk-PZQ3WED2.js";
|
|
4
4
|
import {
|
|
5
5
|
formatFleetWriteSummary
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-6UCVWYDA.js";
|
|
7
|
+
import "./chunk-RUPPWAD6.js";
|
|
7
8
|
import "./chunk-7BZUOZPU.js";
|
|
8
9
|
import {
|
|
9
10
|
detectSignalEvents,
|
|
@@ -23,9 +24,9 @@ import {
|
|
|
23
24
|
} from "./chunk-XHHMM5HX.js";
|
|
24
25
|
import {
|
|
25
26
|
recordFleetEventsBestEffort
|
|
26
|
-
} from "./chunk-
|
|
27
|
-
import "./chunk-
|
|
28
|
-
import "./chunk-
|
|
27
|
+
} from "./chunk-W45FZEGE.js";
|
|
28
|
+
import "./chunk-WS6NU475.js";
|
|
29
|
+
import "./chunk-MFHS7IQ2.js";
|
|
29
30
|
import "./chunk-LBYOLBW7.js";
|
|
30
31
|
import "./chunk-6VAL7XJT.js";
|
|
31
32
|
|
|
@@ -90,7 +91,7 @@ async function runGitHubSignalsCommand(opts, deps = {}) {
|
|
|
90
91
|
);
|
|
91
92
|
const sweptAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
92
93
|
const makeMirror = deps.makeMirror ?? (async () => {
|
|
93
|
-
const { makeHealthMirrorBestEffort } = await import("./health-mirror-
|
|
94
|
+
const { makeHealthMirrorBestEffort } = await import("./health-mirror-GCDN7BJL.js");
|
|
94
95
|
return makeHealthMirrorBestEffort();
|
|
95
96
|
});
|
|
96
97
|
const mirror = await makeMirror();
|
|
@@ -155,4 +156,4 @@ export {
|
|
|
155
156
|
githubSignalsExitCode,
|
|
156
157
|
runGitHubSignalsCommand
|
|
157
158
|
};
|
|
158
|
-
//# sourceMappingURL=github-signals-
|
|
159
|
+
//# sourceMappingURL=github-signals-DB25UWY4.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/audits/github-signals.ts","../src/cli/commands/github-signals.ts"],"sourcesContent":["import type { Site } from \"../types.js\";\nimport type { CiState, PullRequestSummary } from \"../github/gh.js\";\nimport { isFailingRenovatePR } from \"../alerts/renovate.js\";\n\n/** One swept row, ready for the Airtable writer (slug-keyed by `site`). */\nexport type GitHubSignalsRow = {\n site: string; // the site name/slug the writer matches on\n repo: string; // owner/repo\n renovateFailingCis: number;\n ciState: CiState;\n lastCommitAt: string | null;\n};\n\n/** Injected GitHub reads (so the sweep is pure + testable). */\nexport type GitHubSignalsDeps = {\n openPullRequests: (repo: string) => Promise<PullRequestSummary[]>;\n defaultBranchStatus: (repo: string) => Promise<{ ciState: CiState; lastCommitAt: string | null }>;\n};\n\n/** Per repo-backed site: count its failing Renovate PRs + read its default-branch\n * status. Sites without `gitRepo` are skipped (not errors). A repo whose probe\n * throws is reported via `onSkip` and produces no row — one GitHub hiccup never\n * sinks the sweep. PURE over `deps`. */\nexport async function collectGitHubSignals(\n sites: Site[],\n deps: GitHubSignalsDeps,\n onSkip: (s: { repo: string }) => void = () => {},\n): Promise<GitHubSignalsRow[]> {\n const rows: GitHubSignalsRow[] = [];\n for (const s of sites) {\n const repo = s.gitRepo;\n if (!repo) continue;\n const name = s.name;\n if (!name) continue;\n try {\n const prs = await deps.openPullRequests(repo);\n const status = await deps.defaultBranchStatus(repo);\n rows.push({\n site: name,\n repo,\n renovateFailingCis: prs.filter(isFailingRenovatePR).length,\n ciState: status.ciState,\n lastCommitAt: status.lastCommitAt,\n });\n } catch {\n onSkip({ repo });\n }\n }\n return rows;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport { listWebsites, siteSlug, updateGitHubSignals } from \"../../reports/airtable/websites.js\";\nimport type { Site } from \"../../types.js\";\nimport { collectGitHubSignals } from \"../../audits/github-signals.js\";\nimport { makeGitHub, type GitHub } from \"../../github/gh.js\";\nimport {\n formatFleetWriteSummary,\n type FleetWriteResult,\n} from \"../../audits/write-audits-to-airtable.js\";\nimport { detectSignalEvents, fleetSweptEvent } from \"../../audits/fleet-event-detectors.js\";\nimport { recordFleetEventsBestEffort } from \"../../audits/fleet-events-writer.js\";\nimport type { FleetEvent } from \"../../db/fleet-events.js\";\nimport type { HealthMirror } from \"../../audits/health-mirror.js\";\n\n/** The slice of the GitHub client this sweep actually probes. */\ntype GhProbes = Pick<\n GitHub,\n \"openPullRequests\" | \"defaultBranchStatus\" | \"mergedRenovatePullRequests\"\n>;\n\n/** Injectable wiring for {@link runGitHubSignalsCommand}. Every default is the\n * real fleet path; tests override to reach the per-row write+mirror loop —\n * the hand-rolled `makeHealthMirrorBestEffort()` call made that loop\n * untestable (no test could prove a mirror throw stays out of `failed`, that\n * the counters increment the right way round, or that the mirror sees the\n * same payload Airtable got). Same seam shape as\n * `writeFleetAuditsToAirtable`'s `mirror` argument. */\nexport type GitHubSignalsDeps = {\n /** Airtable base (default: real creds via readAirtableConfig). */\n openBase: () => AirtableBase;\n /** GitHub probe client for the fleet token (default: makeGitHub). */\n makeGh: (token: string) => GhProbes;\n /** Turso mirror factory (default: makeHealthMirrorBestEffort — null without\n * libSQL creds, leaving the Airtable sweep byte-for-byte unchanged). */\n makeMirror: () => Promise<HealthMirror | null>;\n /** Fleet-activity recorder (default: recordFleetEventsBestEffort). */\n recordEvents: (events: FleetEvent[], now: Date) => Promise<void>;\n};\n\n/** Exit code for a fleet github-signals run. Exit 1 when failures are the\n * MAJORITY of the fleet (`failed > written`), not only on a total wipeout —\n * a run where 11/12 repos failed but 1 wrote should still signal an outage.\n * All-success or a minority of flakes (e.g. 1/12 failed) stays exit 0. The\n * no-token clean-skip returns 0 separately (before any probe runs). */\nexport function githubSignalsExitCode(written: number, failed: number): number {\n return failed > written ? 1 : 0;\n}\n\n/** `github-signals --fleet --write-airtable`: sweep every repo-backed site for its\n * Renovate-failing count + default-branch CI state + last-commit date, write each\n * row serially (Airtable ~5 req/sec), and emit FLEET_WRITE_SUMMARY for CI. A\n * missing fleet token is a clean skip (local runs), not a failure. */\nexport async function runGitHubSignalsCommand(\n opts: {\n fleet?: boolean | undefined;\n writeAirtable?: boolean | undefined;\n },\n deps: Partial<GitHubSignalsDeps> = {},\n): Promise<{ output: string; code: number }> {\n if (!opts.fleet || !opts.writeAirtable) {\n return { output: \"github-signals currently supports only --fleet --write-airtable\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"github-signals skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet read) configured.\",\n code: 0,\n };\n }\n const base = deps.openBase ? deps.openBase() : openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const gh: GhProbes = deps.makeGh ? deps.makeGh(token) : makeGitHub({ token });\n const sites: Site[] = websites.map((w) => ({\n path: \"\",\n name: w.name,\n meta: {},\n ...(w.gitRepo ? { gitRepo: w.gitRepo } : {}),\n }));\n\n const skipped: string[] = [];\n const rows = await collectGitHubSignals(\n sites,\n {\n openPullRequests: (r) => gh.openPullRequests(r),\n defaultBranchStatus: (r) => gh.defaultBranchStatus(r),\n },\n ({ repo }) => skipped.push(repo),\n );\n\n const sweptAt = new Date().toISOString();\n // Phase 3 dual-write (#539): mirror each row's written FieldSet into\n // site_health. Null when libSQL creds are absent — the Airtable sweep\n // proceeds exactly as before. (Dynamic import so the no-mirror path never\n // loads the db client.)\n const makeMirror =\n deps.makeMirror ??\n (async () => {\n const { makeHealthMirrorBestEffort } = await import(\"../../audits/health-mirror.js\");\n return makeHealthMirrorBestEffort();\n });\n const mirror = await makeMirror();\n const result: FleetWriteResult = {\n written: [],\n failed: [],\n ...(mirror ? { mirrored: 0, mirrorFailed: 0, mirrorMissed: 0 } : {}),\n };\n const byRepo = new Map(websites.filter((w) => w.gitRepo).map((w) => [w.gitRepo, w]));\n const events: FleetEvent[] = [];\n const sweptMs = Date.parse(sweptAt);\n const since24h = new Date(sweptMs - 24 * 60 * 60 * 1000).toISOString();\n // Serial: Airtable's ~5 req/sec limit (matches writeFleetAuditsToAirtable).\n for (const row of rows) {\n const target = byRepo.get(row.repo);\n if (!target) {\n result.failed.push({ slug: siteSlug(row.site), error: \"no Websites row matched\" });\n continue;\n }\n try {\n const ghFields = await updateGitHubSignals(base, target.id, {\n renovateFailingCis: row.renovateFailingCis,\n ciState: row.ciState,\n lastCommitAt: row.lastCommitAt,\n sweptAt,\n });\n if (mirror) {\n // Count, never throw: a Turso blip must not move an Airtable-written\n // row into `failed` (that would red the sweep via githubSignalsExitCode\n // on a fleet-wide mirror outage). A 0-row match (site not yet imported)\n // is a miss, not a mirror — see FleetWriteResult.mirrorMissed.\n try {\n if (await mirror(target.id, ghFields)) {\n result.mirrored = (result.mirrored ?? 0) + 1;\n } else {\n result.mirrorMissed = (result.mirrorMissed ?? 0) + 1;\n }\n } catch (e) {\n result.mirrorFailed = (result.mirrorFailed ?? 0) + 1;\n console.error(`[health-mirror] ${target.name}: ${(e as Error).message}`);\n }\n }\n result.written.push({\n siteName: target.name,\n writes: [{ audit: \"github-signals\", counts: row }],\n });\n // Fleet-activity events for this repo: merged Renovate PRs since the last sweep\n // (watermark = the row's prior GitHub Signals At, else a 24h fallback) + a\n // CI-recovered transition. A PR-fetch hiccup drops only this repo's PR events.\n const since = target.githubSignalsAt ?? since24h;\n let merged: Awaited<ReturnType<typeof gh.mergedRenovatePullRequests>> = [];\n try {\n merged = await gh.mergedRenovatePullRequests(row.repo, since);\n } catch {\n // PR list unavailable this run — skip pr_automerged for this repo, keep ci_recovered\n }\n events.push(...detectSignalEvents(target, row, merged, sweptAt));\n } catch (e) {\n result.failed.push({ slug: siteSlug(row.site), error: (e as Error).message });\n }\n }\n for (const repo of skipped) result.failed.push({ slug: repo, error: \"probe failed (skipped)\" });\n\n events.push(fleetSweptEvent(\"github-signals\", result.written.length, sweptAt));\n await (deps.recordEvents ?? recordFleetEventsBestEffort)(events, new Date());\n\n // Exit non-zero when failures are the MAJORITY of the fleet, not only on a\n // total wipeout. A run where 11/12 repos failed but 1 wrote used to return 0,\n // masking a large outage. The nightly cron step is `continue-on-error`, so a\n // non-zero here is an operator-visibility signal, not a red build.\n return {\n output: formatFleetWriteSummary(result),\n code: githubSignalsExitCode(result.written.length, result.failed.length),\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/audits/github-signals.ts","../src/cli/commands/github-signals.ts"],"sourcesContent":["import type { Site } from \"../types.js\";\nimport type { CiState, PullRequestSummary } from \"../github/gh.js\";\nimport { isFailingRenovatePR } from \"../alerts/renovate.js\";\n\n/** One swept row, ready for the Airtable writer (slug-keyed by `site`). */\nexport type GitHubSignalsRow = {\n site: string; // the site name/slug the writer matches on\n repo: string; // owner/repo\n renovateFailingCis: number;\n ciState: CiState;\n lastCommitAt: string | null;\n};\n\n/** Injected GitHub reads (so the sweep is pure + testable). */\nexport type GitHubSignalsDeps = {\n openPullRequests: (repo: string) => Promise<PullRequestSummary[]>;\n defaultBranchStatus: (repo: string) => Promise<{ ciState: CiState; lastCommitAt: string | null }>;\n};\n\n/** Per repo-backed site: count its failing Renovate PRs + read its default-branch\n * status. Sites without `gitRepo` are skipped (not errors). A repo whose probe\n * throws is reported via `onSkip` and produces no row — one GitHub hiccup never\n * sinks the sweep. PURE over `deps`. */\nexport async function collectGitHubSignals(\n sites: Site[],\n deps: GitHubSignalsDeps,\n onSkip: (s: { repo: string }) => void = () => {},\n): Promise<GitHubSignalsRow[]> {\n const rows: GitHubSignalsRow[] = [];\n for (const s of sites) {\n const repo = s.gitRepo;\n if (!repo) continue;\n const name = s.name;\n if (!name) continue;\n try {\n const prs = await deps.openPullRequests(repo);\n const status = await deps.defaultBranchStatus(repo);\n rows.push({\n site: name,\n repo,\n renovateFailingCis: prs.filter(isFailingRenovatePR).length,\n ciState: status.ciState,\n lastCommitAt: status.lastCommitAt,\n });\n } catch {\n onSkip({ repo });\n }\n }\n return rows;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport { listWebsites, siteSlug, updateGitHubSignals } from \"../../reports/airtable/websites.js\";\nimport type { Site } from \"../../types.js\";\nimport { collectGitHubSignals } from \"../../audits/github-signals.js\";\nimport { makeGitHub, type GitHub } from \"../../github/gh.js\";\nimport {\n formatFleetWriteSummary,\n type FleetWriteResult,\n} from \"../../audits/write-audits-to-airtable.js\";\nimport { detectSignalEvents, fleetSweptEvent } from \"../../audits/fleet-event-detectors.js\";\nimport { recordFleetEventsBestEffort } from \"../../audits/fleet-events-writer.js\";\nimport type { FleetEvent } from \"../../db/fleet-events.js\";\nimport type { HealthMirror } from \"../../audits/health-mirror.js\";\n\n/** The slice of the GitHub client this sweep actually probes. */\ntype GhProbes = Pick<\n GitHub,\n \"openPullRequests\" | \"defaultBranchStatus\" | \"mergedRenovatePullRequests\"\n>;\n\n/** Injectable wiring for {@link runGitHubSignalsCommand}. Every default is the\n * real fleet path; tests override to reach the per-row write+mirror loop —\n * the hand-rolled `makeHealthMirrorBestEffort()` call made that loop\n * untestable (no test could prove a mirror throw stays out of `failed`, that\n * the counters increment the right way round, or that the mirror sees the\n * same payload Airtable got). Same seam shape as\n * `writeFleetAuditsToAirtable`'s `mirror` argument. */\nexport type GitHubSignalsDeps = {\n /** Airtable base (default: real creds via readAirtableConfig). */\n openBase: () => AirtableBase;\n /** GitHub probe client for the fleet token (default: makeGitHub). */\n makeGh: (token: string) => GhProbes;\n /** Turso mirror factory (default: makeHealthMirrorBestEffort — null without\n * libSQL creds, leaving the Airtable sweep byte-for-byte unchanged). */\n makeMirror: () => Promise<HealthMirror | null>;\n /** Fleet-activity recorder (default: recordFleetEventsBestEffort). */\n recordEvents: (events: FleetEvent[], now: Date) => Promise<void>;\n};\n\n/** Exit code for a fleet github-signals run. Exit 1 when failures are the\n * MAJORITY of the fleet (`failed > written`), not only on a total wipeout —\n * a run where 11/12 repos failed but 1 wrote should still signal an outage.\n * All-success or a minority of flakes (e.g. 1/12 failed) stays exit 0. The\n * no-token clean-skip returns 0 separately (before any probe runs). */\nexport function githubSignalsExitCode(written: number, failed: number): number {\n return failed > written ? 1 : 0;\n}\n\n/** `github-signals --fleet --write-airtable`: sweep every repo-backed site for its\n * Renovate-failing count + default-branch CI state + last-commit date, write each\n * row serially (Airtable ~5 req/sec), and emit FLEET_WRITE_SUMMARY for CI. A\n * missing fleet token is a clean skip (local runs), not a failure. */\nexport async function runGitHubSignalsCommand(\n opts: {\n fleet?: boolean | undefined;\n writeAirtable?: boolean | undefined;\n },\n deps: Partial<GitHubSignalsDeps> = {},\n): Promise<{ output: string; code: number }> {\n if (!opts.fleet || !opts.writeAirtable) {\n return { output: \"github-signals currently supports only --fleet --write-airtable\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"github-signals skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet read) configured.\",\n code: 0,\n };\n }\n const base = deps.openBase ? deps.openBase() : openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const gh: GhProbes = deps.makeGh ? deps.makeGh(token) : makeGitHub({ token });\n const sites: Site[] = websites.map((w) => ({\n path: \"\",\n name: w.name,\n meta: {},\n ...(w.gitRepo ? { gitRepo: w.gitRepo } : {}),\n }));\n\n const skipped: string[] = [];\n const rows = await collectGitHubSignals(\n sites,\n {\n openPullRequests: (r) => gh.openPullRequests(r),\n defaultBranchStatus: (r) => gh.defaultBranchStatus(r),\n },\n ({ repo }) => skipped.push(repo),\n );\n\n const sweptAt = new Date().toISOString();\n // Phase 3 dual-write (#539): mirror each row's written FieldSet into\n // site_health. Null when libSQL creds are absent — the Airtable sweep\n // proceeds exactly as before. (Dynamic import so the no-mirror path never\n // loads the db client.)\n const makeMirror =\n deps.makeMirror ??\n (async () => {\n const { makeHealthMirrorBestEffort } = await import(\"../../audits/health-mirror.js\");\n return makeHealthMirrorBestEffort();\n });\n const mirror = await makeMirror();\n const result: FleetWriteResult = {\n written: [],\n failed: [],\n ...(mirror ? { mirrored: 0, mirrorFailed: 0, mirrorMissed: 0 } : {}),\n };\n const byRepo = new Map(websites.filter((w) => w.gitRepo).map((w) => [w.gitRepo, w]));\n const events: FleetEvent[] = [];\n const sweptMs = Date.parse(sweptAt);\n const since24h = new Date(sweptMs - 24 * 60 * 60 * 1000).toISOString();\n // Serial: Airtable's ~5 req/sec limit (matches writeFleetAuditsToAirtable).\n for (const row of rows) {\n const target = byRepo.get(row.repo);\n if (!target) {\n result.failed.push({ slug: siteSlug(row.site), error: \"no Websites row matched\" });\n continue;\n }\n try {\n const ghFields = await updateGitHubSignals(base, target.id, {\n renovateFailingCis: row.renovateFailingCis,\n ciState: row.ciState,\n lastCommitAt: row.lastCommitAt,\n sweptAt,\n });\n if (mirror) {\n // Count, never throw: a Turso blip must not move an Airtable-written\n // row into `failed` (that would red the sweep via githubSignalsExitCode\n // on a fleet-wide mirror outage). A 0-row match (site not yet imported)\n // is a miss, not a mirror — see FleetWriteResult.mirrorMissed.\n try {\n if (await mirror(target.id, ghFields)) {\n result.mirrored = (result.mirrored ?? 0) + 1;\n } else {\n result.mirrorMissed = (result.mirrorMissed ?? 0) + 1;\n }\n } catch (e) {\n result.mirrorFailed = (result.mirrorFailed ?? 0) + 1;\n console.error(`[health-mirror] ${target.name}: ${(e as Error).message}`);\n }\n }\n result.written.push({\n siteName: target.name,\n writes: [{ audit: \"github-signals\", counts: row }],\n });\n // Fleet-activity events for this repo: merged Renovate PRs since the last sweep\n // (watermark = the row's prior GitHub Signals At, else a 24h fallback) + a\n // CI-recovered transition. A PR-fetch hiccup drops only this repo's PR events.\n const since = target.githubSignalsAt ?? since24h;\n let merged: Awaited<ReturnType<typeof gh.mergedRenovatePullRequests>> = [];\n try {\n merged = await gh.mergedRenovatePullRequests(row.repo, since);\n } catch {\n // PR list unavailable this run — skip pr_automerged for this repo, keep ci_recovered\n }\n events.push(...detectSignalEvents(target, row, merged, sweptAt));\n } catch (e) {\n result.failed.push({ slug: siteSlug(row.site), error: (e as Error).message });\n }\n }\n for (const repo of skipped) result.failed.push({ slug: repo, error: \"probe failed (skipped)\" });\n\n events.push(fleetSweptEvent(\"github-signals\", result.written.length, sweptAt));\n await (deps.recordEvents ?? recordFleetEventsBestEffort)(events, new Date());\n\n // Exit non-zero when failures are the MAJORITY of the fleet, not only on a\n // total wipeout. A run where 11/12 repos failed but 1 wrote used to return 0,\n // masking a large outage. The nightly cron step is `continue-on-error`, so a\n // non-zero here is an operator-visibility signal, not a red build.\n return {\n output: formatFleetWriteSummary(result),\n code: githubSignalsExitCode(result.written.length, result.failed.length),\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -102,7 +102,7 @@ async function runHeaderImageCommand(site, opts) {
|
|
|
102
102
|
}
|
|
103
103
|
let withStore = opts;
|
|
104
104
|
if (opts.writeAirtable && !opts.storeDb && process.env.TURSO_DATABASE_URL) {
|
|
105
|
-
const { openDb, readDbConfig } = await import("./client-
|
|
105
|
+
const { openDb, readDbConfig } = await import("./client-JYKPVCJX.js");
|
|
106
106
|
const { storeHeaderImage } = await import("./header-images-SQBCE5OD.js");
|
|
107
107
|
const db = await openDb(readDbConfig());
|
|
108
108
|
withStore = { ...opts, storeDb: (id, img) => storeHeaderImage(db, id, img) };
|
|
@@ -115,4 +115,4 @@ export {
|
|
|
115
115
|
resolveTargets,
|
|
116
116
|
runHeaderImageCommand
|
|
117
117
|
};
|
|
118
|
-
//# sourceMappingURL=header-image-
|
|
118
|
+
//# sourceMappingURL=header-image-7A7XLVEU.js.map
|
|
@@ -8,14 +8,14 @@ import {
|
|
|
8
8
|
} from "./chunk-TVL5MVUF.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-Q47DU255.js";
|
|
12
12
|
import {
|
|
13
13
|
healthEndpoint
|
|
14
14
|
} from "./chunk-BQRFQSGY.js";
|
|
15
15
|
import "./chunk-R53VL3RI.js";
|
|
16
16
|
import "./chunk-3G25KIWW.js";
|
|
17
|
-
import "./chunk-
|
|
18
|
-
import "./chunk-
|
|
17
|
+
import "./chunk-Z3JXLULD.js";
|
|
18
|
+
import "./chunk-RARREDQE.js";
|
|
19
19
|
import "./chunk-XTK5VIQB.js";
|
|
20
20
|
import "./chunk-LBYOLBW7.js";
|
|
21
21
|
import "./chunk-XXTZBPUY.js";
|
|
@@ -51,4 +51,4 @@ async function runHealthEndpointCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runHealthEndpointCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=health-endpoint-
|
|
54
|
+
//# sourceMappingURL=health-endpoint-UVX6R5N5.js.map
|