#!/usr/bin/env node import { acquireLockSafely, CHROME_UA, extendExistingTtl, getRedisCredentials, httpsProxyFetchRaw, loadEnvFile, logSeedResult, releaseLock, verifySeedKey, withRetry, } from './_seed-utils.mjs'; import { resolveProxyStringConnect } from './_proxy-utils.cjs'; import { createCountryResolvers, isIso2, resolveIso2, } from './_country-resolver.mjs'; import { isInRankableUniverse } from './shared/rankable-universe.mjs'; import wgiIndicatorKeys from './shared/wgi-indicator-keys.json' with { type: 'json' }; export { createCountryResolvers, resolveIso2 } from './_country-resolver.mjs'; loadEnvFile(import.meta.url); export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1'; export const RESILIENCE_STATIC_META_KEY = 'seed-meta:resilience:static'; export const RESILIENCE_STATIC_PREFIX = 'resilience:static:'; // Aggregated IPC Phase 3+ view — readers that want "which countries are in a // food crisis this year" without fanning out to 222 per-country keys. Shape is // compatible with scripts/backtest-resilience-outcomes.mjs::detectFoodCrisis: // { countries: { ISO2: { ipcPhase, phase, peopleInCrisis, year } } }. export const RESILIENCE_STATIC_FAO_KEY = 'resilience:static:fao'; export const RESILIENCE_STATIC_TTL_SECONDS = 400 * 24 * 60 * 60; // Plan 2026-04-26-002 §U2 (PR 1) bumped v7 → v8 because this PR adds // the rankable-universe filter at finalizeCountryPayloads. Without // the version bump, `shouldSkipSeedYear` (line ~70) would no-op the // post-merge seeder run since prod already has a successful 2026 v7 // seed — the new whitelist would never run, the static index would // remain at ~222 entries, and the universe filter would silently // not take effect. Caught by reviewer post-PR-3435 push. // v9 adds static-data quality provenance and guards, so current-year // successful v8 snapshots must not skip the updated publish path. export const RESILIENCE_STATIC_SOURCE_VERSION = 'resilience-static-v9'; export const RESILIENCE_STATIC_WINDOW_CRON = '0 */4 1-3 10 *'; const LOCK_DOMAIN = 'resilience:static'; const LOCK_TTL_MS = 2 * 60 * 60 * 1000; const TOTAL_DATASET_SLOTS = 11; const COUNTRY_DATASET_FIELDS = ['wgi', 'infrastructure', 'gpi', 'rsf', 'who', 'fao', 'aquastat', 'iea', 'tradeToGdp', 'fxReservesMonths', 'appliedTariffRate']; export const WGI_INDICATORS = wgiIndicatorKeys; const INFRASTRUCTURE_INDICATORS = ['EG.ELC.ACCS.ZS', 'IS.ROD.PAVE.ZS', 'EG.USE.ELEC.KH.PC', 'IT.NET.BBND.P2']; const WHO_INDICATORS = { hospitalBeds: 'WHS6_102', uhcIndex: 'UHC_INDEX_REPORTED', // WHS4_100 from the issue body no longer resolves; WHO currently exposes MCV1 coverage on WHS8_110. measlesCoverage: process.env.RESILIENCE_WHO_MEASLES_INDICATOR || 'WHS8_110', physiciansPer10k: 'HWF_0001', healthExpPerCapitaUsd: 'GHED_CHE_pc_US_SHA2011', }; const WORLD_BANK_BASE = 'https://api.worldbank.org/v2'; const WHO_BASE = 'https://ghoapi.azureedge.net/api'; const RSF_RANKING_URL = 'https://rsf.org/en/ranking'; const RSF_TOP_RANK_DIRECTION_GUARD_MAX_RANK = 10; // Top-10 RSF countries currently score in the 5-20 range; 30 is a conservative // upper bound before treating the feed as direction-inverted. const RSF_TOP_RANK_ABUSE_INDEX_MAX_SCORE = 30; const EUROSTAT_ENERGY_URL = 'https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/nrg_ind_id?freq=A'; const WB_ENERGY_IMPORT_INDICATOR = 'EG.IMP.CONS.ZS'; const COUNTRY_RESOLVERS = createCountryResolvers(); const EUROSTAT_ENERGY_EU_MEMBER_FLOOR = 24; const EUROSTAT_EU_MEMBER_ISO2 = new Set([ 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', ]); export function countryRedisKey(iso2) { return `${RESILIENCE_STATIC_PREFIX}${iso2}`; } function nowSeedYear(now = new Date()) { return now.getUTCFullYear(); } export function shouldSkipSeedYear(meta, seedYear = nowSeedYear()) { return Boolean( meta && meta.status === 'ok' && meta.sourceVersion === RESILIENCE_STATIC_SOURCE_VERSION && Number(meta.seedYear) === seedYear && !(meta.failedDatasets?.length > 0) && Number.isFinite(Number(meta.recordCount)) && Number(meta.recordCount) > 0, ); } function safeNum(value) { const numeric = typeof value === 'number' ? value : Number(value); return Number.isFinite(numeric) ? numeric : null; } function roundMetric(value, digits = 3) { const numeric = safeNum(value); if (numeric == null) return null; const factor = 10 ** digits; return Math.round(numeric * factor) / factor; } async function fetchTextDirect(url, accept, timeoutMs) { const response = await fetch(url, { headers: { Accept: accept, 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok) { const err = Object.assign(new Error(`HTTP ${response.status}`), { status: response.status }); throw err; } return { text: await response.text(), contentType: response.headers.get('content-type') || '' }; } async function fetchText(url, { accept = 'text/plain, text/html, application/json', timeoutMs = 30_000 } = {}) { try { return await fetchTextDirect(url, accept, timeoutMs); } catch (directErr) { const proxyAuth = resolveProxyStringConnect(); if (!proxyAuth) throw directErr; console.warn(` [fetchText] direct failed (${directErr.message}) — retrying via proxy`); const { buffer, contentType } = await httpsProxyFetchRaw(url, proxyAuth, { accept, timeoutMs }); return { text: buffer.toString('utf8'), contentType }; } } async function fetchJson(url, { timeoutMs = 30_000, accept = 'application/json' } = {}) { const response = await fetch(url, { headers: { Accept: accept, 'User-Agent': CHROME_UA, }, signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); } function parseWorldBankPayload(raw, indicatorId) { if (!Array.isArray(raw) || raw.length < 2 || !Array.isArray(raw[1])) { throw new Error(`Unexpected World Bank response shape for ${indicatorId}`); } return { meta: raw[0] || {}, rows: raw[1] || [], }; } async function fetchWorldBankIndicatorRows(indicatorId, extraParams = {}) { const rows = []; let page = 1; let totalPages = 1; const MAX_WB_PAGES = 100; while (page <= totalPages && page <= MAX_WB_PAGES) { const params = new URLSearchParams({ format: 'json', per_page: '1000', page: String(page), ...extraParams, }); const url = `${WORLD_BANK_BASE}/country/all/indicator/${encodeURIComponent(indicatorId)}?${params}`; const raw = await withRetry(() => fetchJson(url), 2, 750); const parsed = parseWorldBankPayload(raw, indicatorId); totalPages = Number(parsed.meta.pages || 1); rows.push(...parsed.rows); page += 1; } return rows; } function selectLatestWorldBankByCountry(rows) { const latest = new Map(); for (const row of rows) { const value = safeNum(row?.value); if (value == null) continue; const year = safeNum(row?.date); const iso2 = resolveIso2({ iso3: row?.countryiso3code, name: row?.country?.value, }); if (!iso2 || year == null) continue; const previous = latest.get(iso2); if (!previous || year > previous.year) { latest.set(iso2, { value: roundMetric(value), year, name: row?.country?.value || iso2, }); } } return latest; } function upsertDatasetRecord(target, iso2, datasetField, value) { if (!value) return; const current = target.get(iso2) || {}; current[datasetField] = value; target.set(iso2, current); } export async function fetchWgiDataset() { const merged = new Map(); const results = await Promise.allSettled( WGI_INDICATORS.map((indicatorId) => fetchWorldBankIndicatorRows(indicatorId, { mrv: '12' }) .then(selectLatestWorldBankByCountry) .then((countryMap) => ({ indicatorId, countryMap })), ), ); let successfulIndicators = 0; for (const result of results) { if (result.status !== 'fulfilled') continue; successfulIndicators += 1; for (const [iso2, entry] of result.value.countryMap.entries()) { const current = merged.get(iso2) || { source: 'worldbank-wgi', indicators: {}, }; current.indicators[result.value.indicatorId] = { value: entry.value, year: entry.year, }; merged.set(iso2, current); } } if (successfulIndicators === 0) { throw new Error('World Bank WGI: all indicator fetches failed'); } return merged; } export async function fetchInfrastructureDataset() { const merged = new Map(); const results = await Promise.allSettled( INFRASTRUCTURE_INDICATORS.map((indicatorId) => fetchWorldBankIndicatorRows(indicatorId, { mrv: '12' }) .then(selectLatestWorldBankByCountry) .then((countryMap) => ({ indicatorId, countryMap })), ), ); let successfulIndicators = 0; for (const result of results) { if (result.status !== 'fulfilled') continue; successfulIndicators += 1; for (const [iso2, entry] of result.value.countryMap.entries()) { const current = merged.get(iso2) || { source: 'worldbank-infrastructure', indicators: {}, }; current.indicators[result.value.indicatorId] = { value: entry.value, year: entry.year, }; merged.set(iso2, current); } } if (successfulIndicators === 0) { throw new Error('World Bank infrastructure: all indicator fetches failed'); } return merged; } async function fetchWhoIndicatorRows(indicatorCode) { const allRows = []; const PAGE_SIZE = 1000; const MAX_WHO_PAGES = 50; let skip = 0; let pageCount = 0; while (pageCount < MAX_WHO_PAGES) { pageCount += 1; const params = new URLSearchParams({ '$select': 'SpatialDim,TimeDim,NumericValue,Value', '$filter': "SpatialDimType eq 'COUNTRY'", '$top': String(PAGE_SIZE), '$skip': String(skip), }); const url = `${WHO_BASE}/${encodeURIComponent(indicatorCode)}?${params}`; const payload = await withRetry(() => fetchJson(url), 2, 750); if (!Array.isArray(payload?.value)) throw new Error(`Unexpected WHO response shape for ${indicatorCode}`); const rows = payload.value; allRows.push(...rows); const odataNext = payload['@odata.nextLink'] || payload['odata.nextLink'] || null; if (odataNext) { skip = allRows.length; continue; } if (rows.length < PAGE_SIZE) break; skip += PAGE_SIZE; } if (pageCount >= MAX_WHO_PAGES) throw new Error(`WHO ${indicatorCode}: pagination exceeded ${MAX_WHO_PAGES} pages`); console.log(` [who] ${indicatorCode}: ${allRows.length} rows (${pageCount} pages)`); return allRows; } function selectLatestWhoByCountry(rows) { const latest = new Map(); for (const row of rows) { const value = safeNum(row?.NumericValue ?? row?.Value); const year = safeNum(row?.TimeDim); const iso2 = resolveIso2({ iso3: row?.SpatialDim }); if (!iso2 || value == null || year == null) continue; const previous = latest.get(iso2); if (!previous || year > previous.year) { latest.set(iso2, { value: roundMetric(value), year, }); } } return latest; } export async function fetchWhoDataset() { const merged = new Map(); const results = await Promise.allSettled( Object.entries(WHO_INDICATORS).map(([metricKey, indicatorCode]) => fetchWhoIndicatorRows(indicatorCode) .then(selectLatestWhoByCountry) .then((countryMap) => ({ metricKey, indicatorCode, countryMap })), ), ); let successfulIndicators = 0; for (const result of results) { if (result.status !== 'fulfilled') continue; successfulIndicators += 1; for (const [iso2, entry] of result.value.countryMap.entries()) { const current = merged.get(iso2) || { source: 'who-gho', indicators: {}, }; current.indicators[result.value.metricKey] = { indicator: result.value.indicatorCode, value: entry.value, year: entry.year, }; merged.set(iso2, current); } } if (successfulIndicators === 0) { throw new Error('WHO: all indicator fetches failed'); } transformWhoPhysicianDensity(merged); return merged; } export function transformWhoPhysicianDensity(merged) { for (const [, record] of merged) { const per10k = record.indicators.physiciansPer10k; if (per10k && per10k.value != null) { record.indicators.physiciansPer1k = { indicator: per10k.indicator, value: roundMetric(per10k.value / 10), year: per10k.year, }; } delete record.indicators.physiciansPer10k; } } function parseDecimal(value) { return safeNum(String(value || '').replace(',', '.')); } function validateRsfFeedDirection(byCountry) { const topRanked = [...byCountry.values()] .filter((row) => row.rank <= RSF_TOP_RANK_DIRECTION_GUARD_MAX_RANK); if (byCountry.size >= 100 && topRanked.length === 0) { throw new Error('RSF ranking feed direction guard found no top-ranked countries'); } const highScoreTopRanked = topRanked.filter((row) => row.score > RSF_TOP_RANK_ABUSE_INDEX_MAX_SCORE); if (highScoreTopRanked.length === 0) return; const examples = highScoreTopRanked .slice(0, 3) .map((row) => `rank ${row.rank} score ${row.score}`) .join(', '); throw new Error(`RSF ranking feed direction guard failed: top-ranked countries must have low lower-is-better abuse-index scores (${examples})`); } export function parseRsfRanking(html) { const byCountry = new Map(); const rowRegex = /^\s*\|(\d+)\|([^|]+)\|([0-9]+(?:[.,][0-9]+)?)\|([^|]+)\|\s*(?:<[^>]+>)?\s*$/gm; for (const match of html.matchAll(rowRegex)) { const rank = safeNum(match[1]); const countryName = String(match[2] || '').trim(); const score = parseDecimal(match[3]); const differential = String(match[4] || '').trim(); const iso2 = resolveIso2({ name: countryName }); if (!iso2 || rank == null || score == null) continue; byCountry.set(iso2, { source: 'rsf-ranking', rank, score: roundMetric(score, 2), differential, year: null, }); } validateRsfFeedDirection(byCountry); return byCountry; } export async function fetchRsfDataset() { const { text } = await withRetry(() => fetchText(RSF_RANKING_URL), 2, 750); const parsed = parseRsfRanking(text); if (parsed.size < 100) throw new Error(`RSF ranking page returned only ${parsed.size} countries (expected 180+)`); return parsed; } function reverseCategoryIndex(index = {}) { return Object.entries(index).reduce((acc, [label, position]) => { acc[position] = label; return acc; }, {}); } function parseLatestEurostatValue(data, geoCode) { const dims = data?.dimension; const values = data?.value; if (!dims || !values) return null; const geoDim = dims.geo; const geoIndex = geoDim?.category?.index; if (!geoIndex || geoIndex[geoCode] === undefined) return null; const geoPos = geoIndex[geoCode]; const timeIndexObj = dims.time?.category?.index; let latestYear = null; const dimOrder = data.id || []; const dimSizes = data.size || []; const strides = {}; let stride = 1; for (let idx = dimOrder.length - 1; idx >= 0; idx -= 1) { strides[dimOrder[idx]] = stride; stride *= dimSizes[idx]; } let latestValue = null; for (const key of Object.keys(values).sort((left, right) => Number(right) - Number(left))) { const rawValue = values[key]; if (rawValue == null) continue; let remaining = Number(key); const coords = {}; for (const dim of dimOrder) { const strideSize = strides[dim]; const dimSize = dimSizes[dimOrder.indexOf(dim)]; coords[dim] = Math.floor(remaining / strideSize) % dimSize; remaining %= strideSize; } if (coords.geo !== geoPos) continue; if (reverseCategoryIndex(dims.siec?.category?.index)[coords.siec] !== 'TOTAL') continue; latestValue = safeNum(rawValue); const matchedTime = Object.entries(timeIndexObj || {}).find(([, position]) => position === coords.time); latestYear = safeNum(matchedTime?.[0]); break; } if (latestValue == null || latestYear == null) return null; return { value: roundMetric(latestValue), year: latestYear, }; } export function validateEurostatEnergyEuMemberFloor(parsed, floor = EUROSTAT_ENERGY_EU_MEMBER_FLOOR) { const euRows = [...EUROSTAT_EU_MEMBER_ISO2].filter((iso2) => parsed.has(iso2)).length; if (euRows < floor) { throw new Error(`Eurostat energy dependency parsed ${euRows} EU member rows (expected at least ${floor})`); } return { euRows, floor }; } export function parseEurostatEnergyDataset(data, { enforceEuMemberFloor = false } = {}) { const ids = Array.isArray(data?.id) ? data.id : []; const dimensions = data?.dimension || {}; if (!data?.value || !ids.length) { throw new Error('Eurostat dataset missing dimension metadata'); } const parsed = new Map(); const geoCodes = Object.keys(dimensions.geo?.category?.index || {}); for (const iso2 of geoCodes) { if (!isIso2(iso2)) continue; const latest = parseLatestEurostatValue(data, iso2); if (!latest) continue; parsed.set(iso2, { source: 'eurostat-nrg_ind_id', energyImportDependency: { value: latest.value, year: latest.year, source: 'eurostat', }, }); } if (enforceEuMemberFloor) validateEurostatEnergyEuMemberFloor(parsed); return parsed; } export async function fetchEnergyDependencyDataset() { // Eurostat is the authoritative EU slice; if it is unavailable or collapses, // fail the whole iea adapter so recovery preserves the previous full snapshot. const [eurostatData, worldBankRows] = await Promise.all([ withRetry(() => fetchJson(EUROSTAT_ENERGY_URL), 2, 750).catch((err) => { throw new Error(`Energy dependency: Eurostat fetch failed: ${err.message}`); }), fetchWorldBankIndicatorRows(WB_ENERGY_IMPORT_INDICATOR, { mrv: '12' }).catch(() => []), ]); let merged = new Map(); try { merged = parseEurostatEnergyDataset(eurostatData, { enforceEuMemberFloor: true }); } catch (err) { throw new Error(`Energy dependency: Eurostat parse/floor check failed: ${err.message}`); } const worldBankFallback = selectLatestWorldBankByCountry(worldBankRows); for (const [iso2, entry] of worldBankFallback.entries()) { if (merged.has(iso2)) continue; merged.set(iso2, { source: 'worldbank-energy-imports', energyImportDependency: { value: entry.value, year: entry.year, source: 'worldbank', }, }); } if (merged.size === 0) throw new Error('Energy dependency: both Eurostat and World Bank fallback failed'); return merged; } function parseDelimitedRow(line, delimiter) { const cells = []; let current = ''; let inQuotes = false; for (let idx = 0; idx < line.length; idx += 1) { const char = line[idx]; const next = line[idx + 1]; if (char === '"') { if (inQuotes && next === '"') { current += '"'; idx += 1; } else { inQuotes = !inQuotes; } continue; } if (char === delimiter && !inQuotes) { cells.push(current); current = ''; continue; } current += char; } cells.push(current); return cells.map((cell) => cell.trim()); } function parseDelimitedText(text, delimiter) { const lines = text .replace(/^\uFEFF/, '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); if (lines.length < 2) return []; const headers = parseDelimitedRow(lines[0], delimiter); return lines.slice(1).map((line) => { const values = parseDelimitedRow(line, delimiter); return Object.fromEntries(headers.map((header, idx) => [header, values[idx] ?? ''])); }); } export function gpiUrlForYear(yr) { return `https://www.visionofhumanity.org/wp-content/uploads/${yr}/06/GPI_${yr}_${yr}.csv`; } export function parseGpiRows(csvText, resolvedYear) { const rows = parseDelimitedText(csvText, ','); const parsed = new Map(); for (const row of rows) { const iso3 = String(row.code || '').trim().toUpperCase(); const iso2 = resolveIso2({ iso3 }); if (!iso2) continue; const rank = safeNum(row.rank); const score = safeNum(row.index_over); const year = safeNum(row.year) ?? resolvedYear; if (rank == null || score == null) continue; parsed.set(iso2, { source: 'gpi-voh', rank, score: roundMetric(score, 3), year, }); } if (parsed.size < 50) throw new Error(`GPI CSV returned only ${parsed.size} countries (expected 163+)`); return parsed; } export async function resolveGpiCsv( currentYear, { directFetch = (url) => fetchTextDirect(url, 'text/csv', 30_000), retryFetch = (url) => withRetry(() => fetchText(url, { accept: 'text/csv' }), 2, 750), } = {}, ) { try { const { text } = await directFetch(gpiUrlForYear(currentYear)); return { resolvedYear: currentYear, csvText: text }; } catch (err) { if (err.status !== 404) throw err; const resolvedYear = currentYear - 1; console.info(` [gpi] ${currentYear} report not yet available — using ${resolvedYear}`); const { text } = await retryFetch(gpiUrlForYear(resolvedYear)); return { resolvedYear, csvText: text }; } } async function fetchGpiDataset() { const currentYear = new Date().getUTCFullYear(); const { resolvedYear, csvText } = await resolveGpiCsv(currentYear); return parseGpiRows(csvText, resolvedYear); } export function parseFsinRows(csvText) { const rows = parseDelimitedText(csvText, ','); const parsed = new Map(); for (const row of rows) { const rawIso3 = String(row['Country (ISO3)'] || row['Country'] || '').trim().toUpperCase(); const iso2 = resolveIso2({ iso3: rawIso3, name: rawIso3 }); if (!iso2) continue; const phase3plus = safeNum(row['Phase 3+ #'] ?? row['Phase 3+ number current']); const phase4 = safeNum(row['Phase 4 #'] ?? row['Phase 4 number current']); const phase5 = safeNum(row['Phase 5 #'] ?? row['Phase 5 number current']); // Skip rows where no crisis-phase data is present (null or zero — IPC only lists active crises). if (!phase3plus && !phase4 && !phase5) continue; const yearCandidates = Object.keys(row) .filter((k) => /period|date|year/i.test(k)) .map((k) => safeNum(String(row[k]).slice(0, 4))) .filter((v) => v != null && v > 2000); const year = yearCandidates.length ? Math.max(...yearCandidates) : null; const highestPhase = phase5 ? 5 : phase4 ? 4 : 3; const higherPhaseTotal = (phase4 ?? 0) + (phase5 ?? 0); const peopleInCrisis = phase3plus && phase3plus > 0 ? phase3plus : higherPhaseTotal; parsed.set(iso2, { source: 'hdx-ipc', year, // Output matches the shape that scoreFoodWater() reads from staticRecord.fao. // phase3plus is the IPC Phase 3+ total when present; otherwise Phase 4+5 // is a conservative lower-bound fallback for rows missing the aggregate. peopleInCrisis: peopleInCrisis > 0 ? roundMetric(peopleInCrisis, 0) : null, phase: `IPC Phase ${highestPhase}`, }); } if (parsed.size === 0) throw new Error('HDX IPC CSV returned no usable rows'); return parsed; } async function fetchFsinDataset() { const hdxUrl = 'https://data.humdata.org/dataset/7a7e7428-b8d7-4d2e-91d3-19100500e016/resource/2e4f7475-105b-4fae-81f7-7c32076096b6/download/ipc_global_national_wide_latest.csv'; const { text: csvText } = await withRetry(() => fetchText(hdxUrl, { accept: 'text/csv' }), 2, 750); return parseFsinRows(csvText); } // WB indicator ER.H2O.FWST.ZS = "Level of water stress: freshwater withdrawal as a proportion // of available freshwater resources". Matches scoreAquastatValue()'s 'stress' keyword branch. const WB_WATER_STRESS_INDICATOR = 'ER.H2O.FWST.ZS'; export function buildAquastatWbMap(waterStressLatest) { const byCountry = new Map(); for (const [iso2, entry] of waterStressLatest.entries()) { byCountry.set(iso2, { source: 'worldbank-aquastat', value: entry.value, indicator: 'water stress', year: entry.year, }); } if (byCountry.size === 0) throw new Error('World Bank water stress returned no usable rows'); return byCountry; } async function fetchAquastatDataset() { const rows = await fetchWorldBankIndicatorRows(WB_WATER_STRESS_INDICATOR, { mrv: '15' }); const latest = selectLatestWorldBankByCountry(rows); return buildAquastatWbMap(latest); } const WB_TRADE_TO_GDP_INDICATOR = 'NE.TRD.GNFS.ZS'; export function buildTradeToGdpMap(latestByCountry) { const byCountry = new Map(); for (const [iso2, entry] of latestByCountry.entries()) { byCountry.set(iso2, { source: 'worldbank', tradeToGdpPct: entry.value, year: entry.year, }); } if (byCountry.size === 0) throw new Error('World Bank trade-to-GDP returned no usable rows'); return byCountry; } async function fetchTradeToGdpDataset() { const rows = await fetchWorldBankIndicatorRows(WB_TRADE_TO_GDP_INDICATOR, { mrv: '12' }); const latest = selectLatestWorldBankByCountry(rows); return buildTradeToGdpMap(latest); } const WB_FX_RESERVES_MONTHS_INDICATOR = 'FI.RES.TOTL.MO'; export function buildFxReservesMonthsMap(latestByCountry) { const byCountry = new Map(); for (const [iso2, entry] of latestByCountry.entries()) { byCountry.set(iso2, { source: 'worldbank', months: entry.value, year: entry.year, }); } if (byCountry.size === 0) throw new Error('World Bank FX reserves months returned no usable rows'); return byCountry; } async function fetchFxReservesMonthsDataset() { const rows = await fetchWorldBankIndicatorRows(WB_FX_RESERVES_MONTHS_INDICATOR, { mrv: '12' }); const latest = selectLatestWorldBankByCountry(rows); return buildFxReservesMonthsMap(latest); } const WB_APPLIED_TARIFF_INDICATOR = 'TM.TAX.MRCH.WM.AR.ZS'; export function buildAppliedTariffRateMap(latestByCountry) { const byCountry = new Map(); for (const [iso2, entry] of latestByCountry.entries()) { byCountry.set(iso2, { source: 'worldbank', value: entry.value, year: entry.year, }); } if (byCountry.size === 0) throw new Error('World Bank applied tariff rate returned no usable rows'); return byCountry; } async function fetchAppliedTariffRateDataset() { const rows = await fetchWorldBankIndicatorRows(WB_APPLIED_TARIFF_INDICATOR, { mrv: '12' }); const latest = selectLatestWorldBankByCountry(rows); return buildAppliedTariffRateMap(latest); } export function finalizeCountryPayloads(datasetMaps, seedYear = nowSeedYear(), seededAt = new Date().toISOString()) { const merged = new Map(); let droppedNonRankable = 0; for (const [datasetField, countryMap] of Object.entries(datasetMaps)) { for (const [iso2, payload] of countryMap.entries()) { // Plan 2026-04-26-002 §U2 (PR 1): drop non-rankable territories // (AS/GU/GL/IM/GI/FK/etc.) at universe-build time. Whitelist = // 193 UN members + 3 SARs (HK/MO/TW). Earlier behavior admitted // every ISO2 from any source map (~222 entries); now ~196. // HK/MO/TW are tagged 'sar' and stay in the dataset; the future // headlineEligible gate (PR 6) can separate them from the headline // ranking if/when that policy ships. if (!isInRankableUniverse(iso2)) { droppedNonRankable++; continue; } upsertDatasetRecord(merged, iso2, datasetField, payload); } } if (droppedNonRankable > 0) { console.log(`[resilience-static] Dropped ${droppedNonRankable} non-rankable territory entries (filter: 193 UN members + 3 SARs)`); } for (const [iso2, payload] of merged.entries()) { const fullPayload = {}; let availableDatasets = 0; for (const field of COUNTRY_DATASET_FIELDS) { const value = payload[field] ?? null; fullPayload[field] = value; if (value) availableDatasets += 1; } fullPayload.coverage = { availableDatasets, totalDatasets: TOTAL_DATASET_SLOTS, ratio: roundMetric(availableDatasets / TOTAL_DATASET_SLOTS, 3), }; fullPayload.seedYear = seedYear; fullPayload.seededAt = seededAt; merged.set(iso2, fullPayload); } return merged; } function normalizeRecoveredDatasets(recoveredDatasets = {}) { return Object.entries(recoveredDatasets) .sort(([left], [right]) => left.localeCompare(right)) .reduce((acc, [dataset, info]) => { acc[dataset] = { recordCount: Number(info?.recordCount) || 0, countries: [...(info?.countries ?? [])].sort(), seedYears: [...(info?.seedYears ?? [])].sort((left, right) => left - right), seededAts: [...(info?.seededAts ?? [])].sort(), sourceYears: [...(info?.sourceYears ?? [])].sort((left, right) => left - right), }; return acc; }, {}); } export function buildManifest(countryPayloads, failedDatasets, seedYear, seededAt, recoveredDatasets = {}) { const countries = [...countryPayloads.keys()].sort(); return { countries, recordCount: countries.length, failedDatasets: [...failedDatasets].sort(), recoveredDatasets: normalizeRecoveredDatasets(recoveredDatasets), seedYear, seededAt, sourceVersion: RESILIENCE_STATIC_SOURCE_VERSION, }; } function buildMetaPayload({ status, recordCount, seedYear, failedDatasets, message = null }) { return { fetchedAt: Date.now(), recordCount, seedYear, failedDatasets: [...failedDatasets].sort(), status, sourceVersion: RESILIENCE_STATIC_SOURCE_VERSION, message, }; } export function buildFailureRefreshKeys(manifest) { const keys = new Set([RESILIENCE_STATIC_INDEX_KEY, RESILIENCE_STATIC_META_KEY]); for (const iso2 of manifest?.countries || []) { if (isIso2(iso2)) keys.add(countryRedisKey(iso2)); } return [...keys]; } function collectSourceYears(value, years = new Set()) { if (!value || typeof value !== 'object') return years; for (const [key, nested] of Object.entries(value)) { if (key === 'seedYear') continue; if (/year$/i.test(key) && typeof nested !== 'object') { const numeric = safeNum(nested); if (numeric != null && numeric >= 1900 && numeric <= 2100) years.add(numeric); continue; } collectSourceYears(nested, years); } return years; } function recoveredDatasetProvenance(dataset, countryPayload, datasetValue) { const seedYear = safeNum(countryPayload?.seedYear); const seededAt = typeof countryPayload?.seededAt === 'string' ? countryPayload.seededAt : null; const sourceYears = [...collectSourceYears(datasetValue)].sort((left, right) => left - right); return { dataset, ...(seedYear != null ? { seedYear } : {}), ...(seededAt ? { seededAt } : {}), ...(sourceYears.length ? { sourceYears } : {}), }; } function annotateRecoveredDatasetValue(dataset, countryPayload, datasetValue) { if (!datasetValue || typeof datasetValue !== 'object' || Array.isArray(datasetValue)) return datasetValue; return { ...datasetValue, _recovered: recoveredDatasetProvenance(dataset, countryPayload, datasetValue), }; } function noteRecoveredDataset(recoveredDatasets, dataset, iso2, countryPayload, datasetValue) { const provenance = recoveredDatasetProvenance(dataset, countryPayload, datasetValue); const info = recoveredDatasets[dataset] ?? { recordCount: 0, countries: new Set(), seedYears: new Set(), seededAts: new Set(), sourceYears: new Set(), }; info.recordCount += 1; info.countries.add(iso2); if (provenance.seedYear != null) info.seedYears.add(provenance.seedYear); if (provenance.seededAt) info.seededAts.add(provenance.seededAt); for (const year of provenance.sourceYears ?? []) info.sourceYears.add(year); recoveredDatasets[dataset] = info; } async function redisPipeline(commands) { const { url, token } = getRedisCredentials(); const response = await fetch(`${url}/pipeline`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(commands), signal: AbortSignal.timeout(15_000), }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(`Redis pipeline failed: HTTP ${response.status} — ${text.slice(0, 200)}`); } return response.json(); } async function writeJsonKey(key, value, ttlSeconds) { return redisPipeline([['SET', key, JSON.stringify(value), 'EX', ttlSeconds]]); } async function readJsonKey(key) { return verifySeedKey(key); } /** * Build the aggregated `resilience:static:fao` payload from the per-country * FAO dataset map. Only includes countries that IPC lists as Phase 3+ — by * design, IPC's "global latest" CSV only publishes crisis cases, so absence * from this aggregate means "not an IPC-monitored crisis country" (consistent * with how scoreFoodWater() treats missing per-country fao data). * * Output shape matches backtest-resilience-outcomes.mjs::detectFoodCrisis: * { countries: { [iso2]: { ipcPhase, phase, peopleInCrisis, year, source } }, * fetchedAt, source, count, seedYear } */ export function buildFaoAggregate(faoMap, seedYear, seededAt, eligibleCountryCodes = null) { const eligible = eligibleCountryCodes ? new Set([...eligibleCountryCodes].map((code) => String(code).toUpperCase())) : null; const countries = {}; let count = 0; for (const [iso2, entry] of faoMap.entries()) { const countryCode = String(iso2).toUpperCase(); if (eligible ? !eligible.has(countryCode) : !isInRankableUniverse(countryCode)) continue; if (!entry || typeof entry !== 'object') continue; const phaseMatch = typeof entry.phase === 'string' ? entry.phase.match(/\d+/) : null; const ipcPhase = phaseMatch ? Number(phaseMatch[0]) : null; if (ipcPhase == null || ipcPhase < 3) continue; countries[countryCode] = { ipcPhase, phase: entry.phase, peopleInCrisis: entry.peopleInCrisis ?? null, year: entry.year ?? null, source: entry.source ?? 'hdx-ipc', }; count += 1; } return { countries, count, fetchedAt: seededAt, seedYear, source: 'hdx-ipc', status: 'ok', }; } export function buildFaoAggregateForPublish(datasetMaps, failedDatasets, recovery, seedYear, seededAt, eligibleCountryCodes) { const faoFailed = failedDatasets.includes('fao'); const recoveredFaoCount = safeNum(recovery?.recoveredDatasets?.fao?.recordCount) ?? 0; if (faoFailed && recoveredFaoCount <= 0) { return { countries: {}, count: 0, fetchedAt: seededAt, seedYear, source: 'hdx-ipc', status: 'error', failed: true, failedDatasets: ['fao'], }; } return buildFaoAggregate(datasetMaps.fao ?? new Map(), seedYear, seededAt, eligibleCountryCodes); } async function publishSuccess(countryPayloads, manifest, meta, { faoAggregate } = {}) { const commands = []; for (const [iso2, payload] of countryPayloads.entries()) { commands.push(['SET', countryRedisKey(iso2), JSON.stringify(payload), 'EX', RESILIENCE_STATIC_TTL_SECONDS]); } commands.push(['SET', RESILIENCE_STATIC_INDEX_KEY, JSON.stringify(manifest), 'EX', RESILIENCE_STATIC_TTL_SECONDS]); commands.push(['SET', RESILIENCE_STATIC_META_KEY, JSON.stringify(meta), 'EX', RESILIENCE_STATIC_TTL_SECONDS]); if (faoAggregate) { commands.push(['SET', RESILIENCE_STATIC_FAO_KEY, JSON.stringify(faoAggregate), 'EX', RESILIENCE_STATIC_TTL_SECONDS]); } const results = await redisPipeline(commands); const failures = results.filter(r => r?.error || r?.result === 'ERR'); if (failures.length > 0) { throw new Error(`Redis pipeline: ${failures.length}/${commands.length} commands failed`); } } async function preservePreviousSnapshotOnFailure(failedDatasets, seedYear, message) { const previousManifest = await readJsonKey(RESILIENCE_STATIC_INDEX_KEY); const previousMeta = await readJsonKey(RESILIENCE_STATIC_META_KEY); const recordCount = safeNum(previousManifest?.recordCount ?? previousMeta?.recordCount) ?? 0; const refreshKeys = buildFailureRefreshKeys(previousManifest); await extendExistingTtl(refreshKeys, RESILIENCE_STATIC_TTL_SECONDS); const failureMeta = buildMetaPayload({ status: 'error', recordCount, seedYear, failedDatasets, message, }); await writeJsonKey(RESILIENCE_STATIC_META_KEY, failureMeta, RESILIENCE_STATIC_TTL_SECONDS); return { previousManifest, failureMeta }; } async function fetchAllDatasetMaps() { const adapters = [ { key: 'wgi', fetcher: fetchWgiDataset }, { key: 'infrastructure', fetcher: fetchInfrastructureDataset }, { key: 'gpi', fetcher: fetchGpiDataset }, { key: 'rsf', fetcher: fetchRsfDataset }, { key: 'who', fetcher: fetchWhoDataset }, { key: 'fao', fetcher: fetchFsinDataset }, { key: 'aquastat', fetcher: fetchAquastatDataset }, { key: 'iea', fetcher: fetchEnergyDependencyDataset }, { key: 'tradeToGdp', fetcher: fetchTradeToGdpDataset }, { key: 'fxReservesMonths', fetcher: fetchFxReservesMonthsDataset }, { key: 'appliedTariffRate', fetcher: fetchAppliedTariffRateDataset }, ]; const results = await Promise.allSettled(adapters.map((adapter) => adapter.fetcher())); const datasetMaps = {}; const failedDatasets = []; for (let idx = 0; idx < adapters.length; idx += 1) { const adapter = adapters[idx]; const result = results[idx]; if (result.status === 'fulfilled') { datasetMaps[adapter.key] = result.value; } else { datasetMaps[adapter.key] = new Map(); failedDatasets.push(adapter.key); console.warn(` ${adapter.key}: ${result.reason?.message || result.reason || 'unknown error'}`); } } return { datasetMaps, failedDatasets }; } // Exported for testing. When a dataset fetch fails, reads the prior Redis snapshot and // injects the existing per-country values for that field back into datasetMaps so the // new publish does not overwrite good data with null. // Throws if the Redis recovery reads themselves fail — the caller must then call // preservePreviousSnapshotOnFailure and abort, rather than publishing corrupt data. export async function recoverFailedDatasets(datasetMaps, failedDatasets, { readIndex, readPipeline }) { const recoveredDatasets = {}; if (failedDatasets.length === 0) return { recoveredDatasets }; let existingIndex; try { existingIndex = await readIndex(); } catch (err) { throw new Error(`Dataset(s) (${failedDatasets.join(', ')}) failed and Redis index read also failed: ${err.message}`); } const existingCountries = existingIndex?.countries ?? []; if (existingCountries.length === 0) { console.warn(` [fallback] dataset(s) failed (${failedDatasets.join(', ')}) — no prior snapshot to recover from`); return { recoveredDatasets }; } let pipelineResults; try { pipelineResults = await readPipeline(existingCountries.map((iso2) => ['GET', countryRedisKey(iso2)])); } catch (err) { throw new Error(`Dataset(s) (${failedDatasets.join(', ')}) failed and Redis pipeline read also failed: ${err.message}`); } for (let i = 0; i < existingCountries.length; i++) { const iso2 = existingCountries[i]; let existing = null; try { existing = JSON.parse(pipelineResults[i]?.result ?? 'null'); } catch { /* skip */ } if (!existing) continue; for (const key of failedDatasets) { if (existing[key] != null && datasetMaps[key] instanceof Map && !datasetMaps[key].has(iso2)) { datasetMaps[key].set(iso2, annotateRecoveredDatasetValue(key, existing, existing[key])); noteRecoveredDataset(recoveredDatasets, key, iso2, existing, existing[key]); } } } for (const key of failedDatasets) { console.warn(` [fallback] dataset '${key}' failed — preserved ${datasetMaps[key].size} existing Redis records from prior snapshot`); } return { recoveredDatasets: normalizeRecoveredDatasets(recoveredDatasets) }; } export async function seedResilienceStatic() { const seedYear = nowSeedYear(); const existingMeta = await readJsonKey(RESILIENCE_STATIC_META_KEY).catch(() => null); if (shouldSkipSeedYear(existingMeta, seedYear)) { console.log(` resilience-static: seedYear ${seedYear} already written, skipping`); return { skipped: true, seedYear, reason: 'already_seeded', }; } const { datasetMaps, failedDatasets } = await fetchAllDatasetMaps(); let recovery = { recoveredDatasets: {} }; try { recovery = await recoverFailedDatasets(datasetMaps, failedDatasets, { readIndex: () => readJsonKey(RESILIENCE_STATIC_INDEX_KEY), readPipeline: redisPipeline, }); } catch (recoveryErr) { const failure = await preservePreviousSnapshotOnFailure( failedDatasets, seedYear, recoveryErr.message, ); const error = new Error(recoveryErr.message); error.failure = failure; throw error; } const seededAt = new Date().toISOString(); const countryPayloads = finalizeCountryPayloads(datasetMaps, seedYear, seededAt); const manifest = buildManifest(countryPayloads, failedDatasets, seedYear, seededAt, recovery?.recoveredDatasets ?? {}); if (manifest.recordCount === 0) { const failure = await preservePreviousSnapshotOnFailure( failedDatasets, seedYear, 'No datasets produced usable country rows', ); const error = new Error('Resilience static seed produced no country rows'); error.failure = failure; throw error; } const meta = buildMetaPayload({ status: 'ok', recordCount: manifest.recordCount, seedYear, failedDatasets, }); // Piggyback on the same fetch/recovery path: the FAO dataset map is // already in memory, just reshape and publish as an aggregate readable by // the weekly validation cron's Outcome-Backtest (resilience:static:fao). // If FAO failed and recovery found no prior FAO rows, publish an explicit // failed/empty aggregate so a prior long-lived FAO key cannot remain fresh // under the shared seed-meta:resilience:static heartbeat. const faoAggregate = buildFaoAggregateForPublish( datasetMaps, failedDatasets, recovery, seedYear, seededAt, countryPayloads.keys(), ); await publishSuccess(countryPayloads, manifest, meta, { faoAggregate }); return { skipped: false, manifest, meta, }; } export async function main() { const startedAt = Date.now(); const runId = `resilience-static:${startedAt}`; const lock = await acquireLockSafely(LOCK_DOMAIN, runId, LOCK_TTL_MS, { label: LOCK_DOMAIN }); if (lock.skipped) return; if (!lock.locked) { console.log(' resilience-static: another seed run is already active'); return; } try { const result = await seedResilienceStatic(); logSeedResult('resilience:static', result?.manifest?.recordCount ?? 0, Date.now() - startedAt, { skipped: Boolean(result?.skipped), seedYear: result?.seedYear ?? result?.manifest?.seedYear ?? nowSeedYear(), failedDatasets: result?.manifest?.failedDatasets ?? [], }); } finally { await releaseLock(LOCK_DOMAIN, runId); } } if (process.argv[1]?.endsWith('seed-resilience-static.mjs')) { main().catch((error) => { const cause = error?.cause ? ` (cause: ${error.cause.message || error.cause})` : ''; console.error(`FATAL: ${error.message || error}${cause}`); process.exit(1); }); }