Files
worldmonitor/scripts/seed-aviation.mjs
Elie Habib ba7ed014e2 fix(aviation): preserve last-good intl snapshot on unhealthy/skipped fetch + restore NOTAM quota-exhaust handling
Review feedback on PR #3238:

(1) Intl unhealthy → was silently overwriting aviation:delays:intl:v3 with
    an empty or partial snapshot because fetchAll() always returned
    { alerts } and zeroIsValid:true let runSeed publish. Now:
      • seedIntlDelays() returns { alerts, healthy, skipped } unchanged
      • fetchAll() refuses to publish when !healthy || skipped:
          - extendExistingTtl([INTL_KEY, INTL_META_KEY], INTL_TTL)
          - throws so runSeed enters its graceful catch path (which also
            extends these TTLs — idempotent)
      • Per-run cache (cachedRun) short-circuits subsequent withRetry(3)
        invocations so the retries don't burn 3x NOTAM quota + 3x FAA/RSS
        fetches when intl is sick.

(2) NOTAM quota exhausted — PR claimed "preserved" but only logged; the
    NOTAM data key was drifting toward TTL expiry and seed-meta was going
    stale, which would flip api/health.js maxStaleMin=240 red after 4h
    despite the intended 24h backoff window. Now matches the pre-strip
    ais-relay behavior byte-for-byte:
      • extendExistingTtl([NOTAM_KEY], NOTAM_TTL)
      • upstashSet(NOTAM_META_KEY, {fetchedAt: now, recordCount: 0,
        quotaExhausted: true}, 604800)
    Consumers keep serving the last known closure list; health stays green.

Also added extendExistingTtl fallbacks on FAA/NOTAM network-rejection paths
so transient network failures also don't drift to TTL expiry.
2026-04-20 19:20:09 +04:00

872 lines
50 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Consolidated aviation seeder. Writes four Redis keys from one cron tick:
*
* aviation:delays:intl:v3 — AviationStack per-airport delay aggregates (51 intl)
* aviation:delays:faa:v1 — FAA ASWS XML delays (30 US)
* aviation:notam:closures:v2 — ICAO NOTAM closures (60 global)
* aviation:news::24:v1 — RSS news prewarmer (list-aviation-news.ts cache)
*
* Also publishes notifications for new severe/major airport disruptions and new
* NOTAM closures via the standard wm:events:queue LPUSH + wm:notif:scan-dedup SETNX.
* Prev-alerted state is persisted to Redis so short-lived cron invocations don't
* re-notify on every tick.
*
* Supersedes: scripts/seed-airport-delays.mjs (deleted) + the in-process seed
* loops that used to live inside scripts/ais-relay.cjs (stripped). ais-relay still
* hosts the /aviationstack live proxy for user-triggered flight lookups.
*/
import {
loadEnvFile,
CHROME_UA,
runSeed,
writeExtraKeyWithMeta,
extendExistingTtl,
getRedisCredentials,
} from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
// ─── Redis keys / TTLs ───────────────────────────────────────────────────────
const INTL_KEY = 'aviation:delays:intl:v3';
const FAA_KEY = 'aviation:delays:faa:v1';
const NOTAM_KEY = 'aviation:notam:closures:v2';
const NEWS_KEY = 'aviation:news::24:v1';
const INTL_TTL = 10_800; // 3h — survives ~5 consecutive missed 30min cron ticks
const FAA_TTL = 7_200; // 2h
const NOTAM_TTL = 7_200; // 2h
const NEWS_TTL = 2_400; // 40min
// health.js expects these exact meta keys (api/health.js:222,223,269)
const INTL_META_KEY = 'seed-meta:aviation:intl';
const FAA_META_KEY = 'seed-meta:aviation:faa';
const NOTAM_META_KEY = 'seed-meta:aviation:notam';
// Notification dedup state (persisted so cron runs don't spam on every tick)
const AVIATION_PREV_ALERTED_KEY = 'notifications:dedup:aviation:prev-alerted:v1';
const NOTAM_PREV_CLOSED_KEY = 'notam:prev-closed-state:v1';
const PREV_STATE_TTL = 86_400; // 24h — longer than any realistic cron cadence
// ─── Unified airport registry ────────────────────────────────────────────────
// Each row declares: iata, icao, name, city, country, region, lat, lon (where
// known), and which data sources cover it:
// 'aviationstack' — AviationStack /v1/flights?dep_iata={iata}
// 'faa' — FAA ASWS XML filter matches this IATA
// 'notam' — ICAO NOTAM list includes this ICAO
// lat/lon/city are only required for rows with 'aviationstack' (feed the
// AirportDelayAlert envelope).
const AIRPORTS = [
// ── Americas — AviationStack + NOTAM ──
{ iata: 'YYZ', icao: 'CYYZ', name: 'Toronto Pearson', city: 'Toronto', country: 'Canada', lat: 43.6777, lon: -79.6248, region: 'americas', sources: ['aviationstack', 'notam'] },
{ iata: 'YVR', icao: 'CYVR', name: 'Vancouver International', city: 'Vancouver', country: 'Canada', lat: 49.1947, lon: -123.1792, region: 'americas', sources: ['aviationstack'] },
{ iata: 'MEX', icao: 'MMMX', name: 'Mexico City International', city: 'Mexico City', country: 'Mexico', lat: 19.4363, lon: -99.0721, region: 'americas', sources: ['aviationstack', 'notam'] },
{ iata: 'GRU', icao: 'SBGR', name: 'São PauloGuarulhos', city: 'São Paulo', country: 'Brazil', lat: -23.4356, lon: -46.4731, region: 'americas', sources: ['aviationstack', 'notam'] },
{ iata: 'EZE', icao: 'SAEZ', name: 'Ministro Pistarini', city: 'Buenos Aires', country: 'Argentina', lat: -34.8222, lon: -58.5358, region: 'americas', sources: ['aviationstack'] },
{ iata: 'BOG', icao: 'SKBO', name: 'El Dorado International', city: 'Bogotá', country: 'Colombia', lat: 4.7016, lon: -74.1469, region: 'americas', sources: ['aviationstack', 'notam'] },
{ iata: 'SCL', icao: 'SCEL', name: 'Arturo Merino Benítez', city: 'Santiago', country: 'Chile', lat: -33.3930, lon: -70.7858, region: 'americas', sources: ['aviationstack', 'notam'] },
// ── Americas — FAA + NOTAM (US only; many dual-covered with AviationStack too for intl flights) ──
{ iata: 'ATL', icao: 'KATL', name: 'HartsfieldJackson Atlanta', city: 'Atlanta', country: 'USA', region: 'americas', sources: ['faa', 'notam'] },
{ iata: 'ORD', icao: 'KORD', name: "Chicago O'Hare", city: 'Chicago', country: 'USA', region: 'americas', sources: ['faa', 'notam'] },
{ iata: 'DFW', icao: 'KDFW', name: 'Dallas/Fort Worth', city: 'Dallas', country: 'USA', region: 'americas', sources: ['faa', 'notam'] },
{ iata: 'DEN', icao: 'KDEN', name: 'Denver International', city: 'Denver', country: 'USA', region: 'americas', sources: ['faa', 'notam'] },
{ iata: 'LAX', icao: 'KLAX', name: 'Los Angeles International', city: 'Los Angeles', country: 'USA', region: 'americas', sources: ['faa', 'notam'] },
{ iata: 'JFK', icao: 'KJFK', name: 'John F. Kennedy International', city: 'New York', country: 'USA', region: 'americas', sources: ['faa', 'notam'] },
{ iata: 'SFO', icao: 'KSFO', name: 'San Francisco International', city: 'San Francisco', country: 'USA', region: 'americas', sources: ['faa', 'notam'] },
{ iata: 'SEA', icao: 'KSEA', name: 'SeattleTacoma International', city: 'Seattle', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'LAS', icao: 'KLAS', name: 'Harry Reid International', city: 'Las Vegas', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'MCO', icao: 'KMCO', name: 'Orlando International', city: 'Orlando', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'EWR', icao: 'KEWR', name: 'Newark Liberty International', city: 'Newark', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'CLT', icao: 'KCLT', name: 'Charlotte Douglas International', city: 'Charlotte', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'PHX', icao: 'KPHX', name: 'Phoenix Sky Harbor International', city: 'Phoenix', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'IAH', icao: 'KIAH', name: 'George Bush Intercontinental', city: 'Houston', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'MIA', icao: 'KMIA', name: 'Miami International', city: 'Miami', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'BOS', icao: 'KBOS', name: 'Logan International', city: 'Boston', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'MSP', icao: 'KMSP', name: 'MinneapolisSaint Paul International', city: 'Minneapolis', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'DTW', icao: 'KDTW', name: 'Detroit Metropolitan', city: 'Detroit', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'FLL', icao: 'KFLL', name: 'Fort LauderdaleHollywood', city: 'Fort Lauderdale', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'PHL', icao: 'KPHL', name: 'Philadelphia International', city: 'Philadelphia', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'LGA', icao: 'KLGA', name: 'LaGuardia', city: 'New York', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'BWI', icao: 'KBWI', name: 'Baltimore/Washington International', city: 'Baltimore', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'SLC', icao: 'KSLC', name: 'Salt Lake City International', city: 'Salt Lake City', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'SAN', icao: 'KSAN', name: 'San Diego International', city: 'San Diego', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'IAD', icao: 'KIAD', name: 'Washington Dulles International', city: 'Washington', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'DCA', icao: 'KDCA', name: 'Ronald Reagan Washington National', city: 'Washington', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'MDW', icao: 'KMDW', name: 'Chicago Midway International', city: 'Chicago', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'TPA', icao: 'KTPA', name: 'Tampa International', city: 'Tampa', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'HNL', icao: 'PHNL', name: 'Daniel K. Inouye International', city: 'Honolulu', country: 'USA', region: 'americas', sources: ['faa'] },
{ iata: 'PDX', icao: 'KPDX', name: 'Portland International', city: 'Portland', country: 'USA', region: 'americas', sources: ['faa'] },
// ── Europe — AviationStack + NOTAM ──
{ iata: 'LHR', icao: 'EGLL', name: 'London Heathrow', city: 'London', country: 'UK', lat: 51.4700, lon: -0.4543, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'CDG', icao: 'LFPG', name: 'Paris Charles de Gaulle', city: 'Paris', country: 'France', lat: 49.0097, lon: 2.5479, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'FRA', icao: 'EDDF', name: 'Frankfurt Airport', city: 'Frankfurt', country: 'Germany', lat: 50.0379, lon: 8.5622, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'AMS', icao: 'EHAM', name: 'Amsterdam Schiphol', city: 'Amsterdam', country: 'Netherlands', lat: 52.3105, lon: 4.7683, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'MAD', icao: 'LEMD', name: 'Adolfo Suárez MadridBarajas', city: 'Madrid', country: 'Spain', lat: 40.4983, lon: -3.5676, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'FCO', icao: 'LIRF', name: 'Leonardo da VinciFiumicino', city: 'Rome', country: 'Italy', lat: 41.8003, lon: 12.2389, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'MUC', icao: 'EDDM', name: 'Munich Airport', city: 'Munich', country: 'Germany', lat: 48.3537, lon: 11.7750, region: 'europe', sources: ['aviationstack'] },
{ iata: 'BCN', icao: 'LEBL', name: 'BarcelonaEl Prat', city: 'Barcelona', country: 'Spain', lat: 41.2974, lon: 2.0833, region: 'europe', sources: ['aviationstack'] },
{ iata: 'ZRH', icao: 'LSZH', name: 'Zurich Airport', city: 'Zurich', country: 'Switzerland', lat: 47.4647, lon: 8.5492, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'IST', icao: 'LTFM', name: 'Istanbul Airport', city: 'Istanbul', country: 'Turkey', lat: 41.2753, lon: 28.7519, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'VIE', icao: 'LOWW', name: 'Vienna International', city: 'Vienna', country: 'Austria', lat: 48.1103, lon: 16.5697, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'CPH', icao: 'EKCH', name: 'Copenhagen Airport', city: 'Copenhagen', country: 'Denmark', lat: 55.6180, lon: 12.6508, region: 'europe', sources: ['aviationstack', 'notam'] },
{ iata: 'DUB', icao: 'EIDW', name: 'Dublin Airport', city: 'Dublin', country: 'Ireland', lat: 53.4264, lon: -6.2499, region: 'europe', sources: ['aviationstack'] },
{ iata: 'LIS', icao: 'LPPT', name: 'Humberto Delgado Airport', city: 'Lisbon', country: 'Portugal', lat: 38.7756, lon: -9.1354, region: 'europe', sources: ['aviationstack'] },
{ iata: 'ATH', icao: 'LGAV', name: 'Athens International', city: 'Athens', country: 'Greece', lat: 37.9364, lon: 23.9445, region: 'europe', sources: ['aviationstack'] },
{ iata: 'WAW', icao: 'EPWA', name: 'Warsaw Chopin Airport', city: 'Warsaw', country: 'Poland', lat: 52.1657, lon: 20.9671, region: 'europe', sources: ['aviationstack', 'notam'] },
// Europe NOTAM-only (no AviationStack coverage today)
{ iata: 'OSL', icao: 'ENGM', name: 'Oslo Gardermoen', city: 'Oslo', country: 'Norway', region: 'europe', sources: ['notam'] },
{ iata: 'ARN', icao: 'ESSA', name: 'Stockholm Arlanda', city: 'Stockholm', country: 'Sweden', region: 'europe', sources: ['notam'] },
{ iata: 'HEL', icao: 'EFHK', name: 'Helsinki-Vantaa', city: 'Helsinki', country: 'Finland', region: 'europe', sources: ['notam'] },
// ── APAC — AviationStack + NOTAM ──
{ iata: 'HND', icao: 'RJTT', name: 'Tokyo Haneda', city: 'Tokyo', country: 'Japan', lat: 35.5494, lon: 139.7798, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'NRT', icao: 'RJAA', name: 'Narita International', city: 'Tokyo', country: 'Japan', lat: 35.7720, lon: 140.3929, region: 'apac', sources: ['aviationstack'] },
{ iata: 'PEK', icao: 'ZBAA', name: 'Beijing Capital', city: 'Beijing', country: 'China', lat: 40.0799, lon: 116.6031, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'PVG', icao: 'ZSPD', name: 'Shanghai Pudong', city: 'Shanghai', country: 'China', lat: 31.1443, lon: 121.8083, region: 'apac', sources: ['aviationstack'] },
{ iata: 'HKG', icao: 'VHHH', name: 'Hong Kong International', city: 'Hong Kong', country: 'China', lat: 22.3080, lon: 113.9185, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'SIN', icao: 'WSSS', name: 'Singapore Changi', city: 'Singapore', country: 'Singapore', lat: 1.3644, lon: 103.9915, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'ICN', icao: 'RKSI', name: 'Incheon International', city: 'Seoul', country: 'South Korea', lat: 37.4602, lon: 126.4407, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'BKK', icao: 'VTBS', name: 'Suvarnabhumi Airport', city: 'Bangkok', country: 'Thailand', lat: 13.6900, lon: 100.7501, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'SYD', icao: 'YSSY', name: 'Sydney Kingsford Smith', city: 'Sydney', country: 'Australia', lat: -33.9461, lon: 151.1772, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'DEL', icao: 'VIDP', name: 'Indira Gandhi International', city: 'Delhi', country: 'India', lat: 28.5562, lon: 77.1000, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'BOM', icao: 'VABB', name: 'Chhatrapati Shivaji Maharaj', city: 'Mumbai', country: 'India', lat: 19.0896, lon: 72.8656, region: 'apac', sources: ['aviationstack'] },
{ iata: 'KUL', icao: 'WMKK', name: 'Kuala Lumpur International', city: 'Kuala Lumpur', country: 'Malaysia', lat: 2.7456, lon: 101.7099, region: 'apac', sources: ['aviationstack', 'notam'] },
{ iata: 'CAN', icao: 'ZGGG', name: 'Guangzhou Baiyun International', city: 'Guangzhou', country: 'China', lat: 23.3924, lon: 113.2988, region: 'apac', sources: ['aviationstack'] },
{ iata: 'TPE', icao: 'RCTP', name: 'Taiwan Taoyuan International', city: 'Taipei', country: 'Taiwan', lat: 25.0797, lon: 121.2342, region: 'apac', sources: ['aviationstack'] },
{ iata: 'MNL', icao: 'RPLL', name: 'Ninoy Aquino International', city: 'Manila', country: 'Philippines', lat: 14.5086, lon: 121.0197, region: 'apac', sources: ['aviationstack'] },
// APAC NOTAM-only
{ iata: 'KMG', icao: 'ZPPP', name: 'Kunming Changshui', city: 'Kunming', country: 'China', region: 'apac', sources: ['notam'] },
// ── MENA — AviationStack + NOTAM ──
{ iata: 'DXB', icao: 'OMDB', name: 'Dubai International', city: 'Dubai', country: 'UAE', lat: 25.2532, lon: 55.3657, region: 'mena', sources: ['aviationstack', 'notam'] },
{ iata: 'DOH', icao: 'OTHH', name: 'Hamad International', city: 'Doha', country: 'Qatar', lat: 25.2731, lon: 51.6081, region: 'mena', sources: ['aviationstack', 'notam'] },
{ iata: 'AUH', icao: 'OMAA', name: 'Abu Dhabi International', city: 'Abu Dhabi', country: 'UAE', lat: 24.4330, lon: 54.6511, region: 'mena', sources: ['aviationstack', 'notam'] },
{ iata: 'RUH', icao: 'OERK', name: 'King Khalid International', city: 'Riyadh', country: 'Saudi Arabia', lat: 24.9576, lon: 46.6988, region: 'mena', sources: ['aviationstack', 'notam'] },
{ iata: 'CAI', icao: 'HECA', name: 'Cairo International', city: 'Cairo', country: 'Egypt', lat: 30.1219, lon: 31.4056, region: 'mena', sources: ['aviationstack', 'notam'] },
{ iata: 'TLV', icao: 'LLBG', name: 'Ben Gurion Airport', city: 'Tel Aviv', country: 'Israel', lat: 32.0055, lon: 34.8854, region: 'mena', sources: ['aviationstack'] },
{ iata: 'AMM', icao: 'OJAI', name: 'Queen Alia International', city: 'Amman', country: 'Jordan', lat: 31.7226, lon: 35.9932, region: 'mena', sources: ['aviationstack', 'notam'] },
{ iata: 'KWI', icao: 'OKBK', name: 'Kuwait International', city: 'Kuwait City', country: 'Kuwait', lat: 29.2266, lon: 47.9689, region: 'mena', sources: ['aviationstack', 'notam'] },
{ iata: 'CMN', icao: 'GMMN', name: 'Mohammed V International', city: 'Casablanca', country: 'Morocco', lat: 33.3675, lon: -7.5898, region: 'mena', sources: ['aviationstack', 'notam'] },
// MENA NOTAM-only
{ iata: 'JED', icao: 'OEJN', name: 'King Abdulaziz', city: 'Jeddah', country: 'Saudi Arabia', region: 'mena', sources: ['notam'] },
{ iata: 'MED', icao: 'OEMA', name: 'Prince Mohammad bin Abdulaziz', city: 'Medina', country: 'Saudi Arabia', region: 'mena', sources: ['notam'] },
{ iata: 'DMM', icao: 'OEDF', name: 'King Fahd International', city: 'Dammam', country: 'Saudi Arabia', region: 'mena', sources: ['notam'] },
{ iata: 'SHJ', icao: 'OMSJ', name: 'Sharjah International', city: 'Sharjah', country: 'UAE', region: 'mena', sources: ['notam'] },
{ iata: 'BAH', icao: 'OBBI', name: 'Bahrain International', city: 'Manama', country: 'Bahrain', region: 'mena', sources: ['notam'] },
{ iata: 'MCT', icao: 'OOMS', name: 'Muscat International', city: 'Muscat', country: 'Oman', region: 'mena', sources: ['notam'] },
{ iata: 'BEY', icao: 'OLBA', name: 'BeirutRafic Hariri', city: 'Beirut', country: 'Lebanon', region: 'mena', sources: ['notam'] },
{ iata: 'DAM', icao: 'OSDI', name: 'Damascus International', city: 'Damascus', country: 'Syria', region: 'mena', sources: ['notam'] },
{ iata: 'BGW', icao: 'ORBI', name: 'Baghdad International', city: 'Baghdad', country: 'Iraq', region: 'mena', sources: ['notam'] },
{ iata: 'IKA', icao: 'OIIE', name: 'Imam Khomeini International', city: 'Tehran', country: 'Iran', region: 'mena', sources: ['notam'] },
{ iata: 'SYZ', icao: 'OISS', name: 'Shiraz International', city: 'Shiraz', country: 'Iran', region: 'mena', sources: ['notam'] },
{ iata: 'MHD', icao: 'OIMM', name: 'Mashhad International', city: 'Mashhad', country: 'Iran', region: 'mena', sources: ['notam'] },
{ iata: 'BND', icao: 'OIKB', name: 'Bandar Abbas International', city: 'Bandar Abbas', country: 'Iran', region: 'mena', sources: ['notam'] },
{ iata: 'TUN', icao: 'DTTA', name: 'TunisCarthage', city: 'Tunis', country: 'Tunisia', region: 'mena', sources: ['notam'] },
{ iata: 'ALG', icao: 'DAAG', name: 'Houari Boumediene', city: 'Algiers', country: 'Algeria', region: 'mena', sources: ['notam'] },
{ iata: 'TIP', icao: 'HLLT', name: 'Tripoli International', city: 'Tripoli', country: 'Libya', region: 'mena', sources: ['notam'] },
// ── Africa — AviationStack + NOTAM ──
{ iata: 'JNB', icao: 'FAOR', name: "O.R. Tambo International", city: 'Johannesburg', country: 'South Africa', lat: -26.1392, lon: 28.2460, region: 'africa', sources: ['aviationstack', 'notam'] },
{ iata: 'NBO', icao: 'HKJK', name: 'Jomo Kenyatta International', city: 'Nairobi', country: 'Kenya', lat: -1.3192, lon: 36.9278, region: 'africa', sources: ['aviationstack', 'notam'] },
{ iata: 'LOS', icao: 'DNMM', name: 'Murtala Muhammed International', city: 'Lagos', country: 'Nigeria', lat: 6.5774, lon: 3.3212, region: 'africa', sources: ['aviationstack', 'notam'] },
{ iata: 'ADD', icao: 'HAAB', name: 'Bole International', city: 'Addis Ababa', country: 'Ethiopia', lat: 8.9779, lon: 38.7993, region: 'africa', sources: ['aviationstack'] },
{ iata: 'CPT', icao: 'FACT', name: 'Cape Town International', city: 'Cape Town', country: 'South Africa', lat: -33.9715, lon: 18.6021, region: 'africa', sources: ['aviationstack'] },
// Africa NOTAM-only
{ iata: 'GBE', icao: 'GABS', name: 'Sir Seretse Khama International', city: 'Gaborone', country: 'Botswana', region: 'africa', sources: ['notam'] },
];
// Derived per-source views (built once at module load)
const AVIATIONSTACK_LIST = AIRPORTS.filter(a => a.sources.includes('aviationstack'));
const FAA_LIST = AIRPORTS.filter(a => a.sources.includes('faa')).map(a => a.iata);
const NOTAM_LIST = AIRPORTS.filter(a => a.sources.includes('notam')).map(a => a.icao);
// iata → aviationstack-enriched meta (for building AirportDelayAlert envelopes)
const AIRPORT_META = Object.fromEntries(AVIATIONSTACK_LIST.map(a => [a.iata, a]));
// Protobuf enum mappers (mirror ais-relay.cjs mappings; consumers parse strings)
const REGION_MAP = {
americas: 'AIRPORT_REGION_AMERICAS',
europe: 'AIRPORT_REGION_EUROPE',
apac: 'AIRPORT_REGION_APAC',
mena: 'AIRPORT_REGION_MENA',
africa: 'AIRPORT_REGION_AFRICA',
};
const DELAY_TYPE_MAP = {
ground_stop: 'FLIGHT_DELAY_TYPE_GROUND_STOP',
ground_delay: 'FLIGHT_DELAY_TYPE_GROUND_DELAY',
departure_delay: 'FLIGHT_DELAY_TYPE_DEPARTURE_DELAY',
arrival_delay: 'FLIGHT_DELAY_TYPE_ARRIVAL_DELAY',
general: 'FLIGHT_DELAY_TYPE_GENERAL',
closure: 'FLIGHT_DELAY_TYPE_CLOSURE',
};
const SEVERITY_MAP = {
normal: 'FLIGHT_DELAY_SEVERITY_NORMAL',
minor: 'FLIGHT_DELAY_SEVERITY_MINOR',
moderate: 'FLIGHT_DELAY_SEVERITY_MODERATE',
major: 'FLIGHT_DELAY_SEVERITY_MAJOR',
severe: 'FLIGHT_DELAY_SEVERITY_SEVERE',
};
const AVIATION_BATCH_CONCURRENCY = 10;
const AVIATION_MIN_FLIGHTS_FOR_CLOSURE = 10;
const RESOLVED_STATUSES = new Set(['cancelled', 'landed', 'active', 'arrived', 'diverted']);
// ─── Inline Upstash helpers (LPUSH + SETNX + GET/SET) ────────────────────────
// These aren't in _seed-utils.mjs (which focuses on SET/GET/EXPIRE). Pattern
// mirrors ais-relay.cjs upstashLpush/upstashSetNx/upstashSet/upstashGet so the
// notification queue + prev-state reads speak the same wire protocol.
async function upstashCommand(cmd) {
const { url, token } = getRedisCredentials();
const resp = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(cmd),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) throw new Error(`Upstash ${cmd[0]} failed: HTTP ${resp.status}`);
return resp.json();
}
async function upstashGet(key) {
try {
const result = await upstashCommand(['GET', key]);
if (!result?.result) return null;
try { return JSON.parse(result.result); } catch { return null; }
} catch { return null; }
}
async function upstashSet(key, value, ttlSeconds) {
try {
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
const result = await upstashCommand(['SET', key, serialized, 'EX', String(ttlSeconds)]);
return result?.result === 'OK';
} catch { return false; }
}
async function upstashSetNx(key, value, ttlSeconds) {
try {
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
const result = await upstashCommand(['SET', key, serialized, 'NX', 'EX', String(ttlSeconds)]);
return result?.result === 'OK' ? 'OK' : null;
} catch { return null; }
}
async function upstashLpush(key, value) {
try {
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
const result = await upstashCommand(['LPUSH', key, serialized]);
return typeof result?.result === 'number' && result.result > 0;
} catch { return false; }
}
async function upstashDel(key) {
try {
const result = await upstashCommand(['DEL', key]);
return result?.result === 1;
} catch { return false; }
}
// ─── Notification publishing ─────────────────────────────────────────────────
// Mirrors ais-relay.cjs::publishNotificationEvent: LPUSH the event onto
// wm:events:queue, guarded by a SETNX dedup key (TTL = dedupTtl). On LPUSH
// failure, rollback the dedup key so the next run can retry.
function notifyHash(str) {
let h = 0;
for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
return Math.abs(h).toString(36);
}
async function publishNotificationEvent({ eventType, payload, severity, variant, dedupTtl = 1800 }) {
try {
const variantSuffix = variant ? `:${variant}` : '';
const dedupKey = `wm:notif:scan-dedup:${eventType}${variantSuffix}:${notifyHash(`${eventType}:${payload.title ?? ''}`)}`;
const isNew = await upstashSetNx(dedupKey, '1', dedupTtl);
if (!isNew) {
console.log(`[Notify] Dedup hit — ${eventType}: ${String(payload.title ?? '').slice(0, 60)}`);
return;
}
const msg = JSON.stringify({ eventType, payload, severity, ...(variant ? { variant } : {}), publishedAt: Date.now() });
const ok = await upstashLpush('wm:events:queue', msg);
if (ok) {
console.log(`[Notify] Queued ${severity} event: ${eventType}${String(payload.title ?? '').slice(0, 60)}`);
} else {
console.warn(`[Notify] LPUSH failed for ${eventType} — rolling back dedup key`);
await upstashDel(dedupKey);
}
} catch (e) {
console.warn(`[Notify] publishNotificationEvent error (${eventType}):`, e?.message || e);
}
}
// ─── Section 1: AviationStack intl delays ────────────────────────────────────
const AVIATIONSTACK_URL = 'https://api.aviationstack.com/v1/flights';
function aviationDetermineSeverity(avgDelay, delayedPct) {
if (avgDelay >= 60 || (delayedPct && delayedPct >= 60)) return 'severe';
if (avgDelay >= 45 || (delayedPct && delayedPct >= 45)) return 'major';
if (avgDelay >= 30 || (delayedPct && delayedPct >= 30)) return 'moderate';
if (avgDelay >= 15 || (delayedPct && delayedPct >= 15)) return 'minor';
return 'normal';
}
async function fetchAviationStackSingle(apiKey, iata) {
const today = new Date().toISOString().slice(0, 10);
const url = `${AVIATIONSTACK_URL}?access_key=${apiKey}&dep_iata=${iata}&flight_date=${today}&limit=100`;
try {
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
console.warn(`[Aviation] ${iata}: HTTP ${resp.status}`);
return { ok: false, alert: null };
}
const json = await resp.json();
if (json.error) {
console.warn(`[Aviation] ${iata}: ${json.error.message}`);
return { ok: false, alert: null };
}
const flights = json?.data ?? [];
const alert = aviationAggregateFlights(iata, flights);
return { ok: true, alert };
} catch (err) {
console.warn(`[Aviation] ${iata}: fetch error: ${err?.message || err}`);
return { ok: false, alert: null };
}
}
function aviationAggregateFlights(iata, flights) {
if (flights.length === 0) return null;
const meta = AIRPORT_META[iata];
if (!meta) return null;
let delayed = 0, cancelled = 0, totalDelay = 0, resolved = 0;
for (const f of flights) {
if (RESOLVED_STATUSES.has(f.flight_status || '')) resolved++;
if (f.flight_status === 'cancelled') cancelled++;
if (f.departure?.delay && f.departure.delay > 0) {
delayed++;
totalDelay += f.departure.delay;
}
}
const total = resolved >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE ? resolved : flights.length;
const cancelledPct = (cancelled / total) * 100;
const delayedPct = (delayed / total) * 100;
const avgDelay = delayed > 0 ? Math.round(totalDelay / delayed) : 0;
let severity, delayType, reason;
if (cancelledPct >= 80 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
severity = 'severe'; delayType = 'closure';
reason = 'Airport closure / airspace restrictions';
} else if (cancelledPct >= 50 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
severity = 'major'; delayType = 'ground_stop';
reason = `${Math.round(cancelledPct)}% flights cancelled`;
} else if (cancelledPct >= 20 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
severity = 'moderate'; delayType = 'ground_delay';
reason = `${Math.round(cancelledPct)}% flights cancelled`;
} else if (cancelledPct >= 10 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
severity = 'minor'; delayType = 'general';
reason = `${Math.round(cancelledPct)}% flights cancelled`;
} else if (avgDelay > 0) {
severity = aviationDetermineSeverity(avgDelay, delayedPct);
delayType = avgDelay >= 60 ? 'ground_delay' : 'general';
reason = `Avg ${avgDelay}min delay, ${Math.round(delayedPct)}% delayed`;
} else {
return null;
}
if (severity === 'normal') return null;
return {
id: `avstack-${iata}`,
iata,
icao: meta.icao,
name: meta.name,
city: meta.city,
country: meta.country,
location: { latitude: meta.lat, longitude: meta.lon },
region: REGION_MAP[meta.region] || 'AIRPORT_REGION_UNSPECIFIED',
delayType: DELAY_TYPE_MAP[delayType] || 'FLIGHT_DELAY_TYPE_GENERAL',
severity: SEVERITY_MAP[severity] || 'FLIGHT_DELAY_SEVERITY_NORMAL',
avgDelayMinutes: avgDelay,
delayedFlightsPct: Math.round(delayedPct),
cancelledFlights: cancelled,
totalFlights: total,
reason,
source: 'FLIGHT_DELAY_SOURCE_AVIATIONSTACK',
updatedAt: Date.now(),
};
}
async function seedIntlDelays() {
const apiKey = process.env.AVIATIONSTACK_API;
if (!apiKey) {
console.log('[Intl] No AVIATIONSTACK_API key — skipping');
return { alerts: [], healthy: false, skipped: true };
}
const t0 = Date.now();
const alerts = [];
let succeeded = 0, failed = 0;
for (let i = 0; i < AVIATIONSTACK_LIST.length; i += AVIATION_BATCH_CONCURRENCY) {
const chunk = AVIATIONSTACK_LIST.slice(i, i + AVIATION_BATCH_CONCURRENCY);
const results = await Promise.allSettled(
chunk.map(a => fetchAviationStackSingle(apiKey, a.iata)),
);
for (const r of results) {
if (r.status === 'fulfilled') {
if (r.value.ok) { succeeded++; if (r.value.alert) alerts.push(r.value.alert); }
else failed++;
} else {
failed++;
}
}
}
const healthy = AVIATIONSTACK_LIST.length < 5 || failed <= succeeded;
console.log(`[Intl] ${alerts.length} alerts (${succeeded} ok, ${failed} failed, healthy: ${healthy}) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
return { alerts, healthy, skipped: false };
}
// ─── Section 2: FAA delays (XML) ─────────────────────────────────────────────
const FAA_URL = 'https://nasstatus.faa.gov/api/airport-status-information';
function parseDelayTypeFromReason(reason) {
const r = reason.toLowerCase();
if (r.includes('ground stop')) return 'ground_stop';
if (r.includes('ground delay') || r.includes('gdp')) return 'ground_delay';
if (r.includes('departure')) return 'departure_delay';
if (r.includes('arrival')) return 'arrival_delay';
if (r.includes('clos')) return 'ground_stop';
return 'general';
}
function faaSeverityFromAvg(avgDelay) {
if (avgDelay >= 90) return 'severe';
if (avgDelay >= 60) return 'major';
if (avgDelay >= 30) return 'moderate';
if (avgDelay >= 15) return 'minor';
return 'normal';
}
function parseFaaXml(text) {
const delays = new Map();
const parseTag = (xml, tag) => {
const re = new RegExp(`<${tag}>(.*?)</${tag}>`, 'gs');
const out = [];
let m;
while ((m = re.exec(xml))) out.push(m[1]);
return out;
};
const getVal = (block, tag) => {
const m = block.match(new RegExp(`<${tag}>(.*?)</${tag}>`));
return m ? m[1].trim() : '';
};
for (const gd of parseTag(text, 'Ground_Delay')) {
const arpt = getVal(gd, 'ARPT');
if (arpt) {
delays.set(arpt, { airport: arpt, reason: getVal(gd, 'Reason') || 'Ground delay', avgDelay: parseInt(getVal(gd, 'Avg') || '30', 10), type: 'ground_delay' });
}
}
for (const gs of parseTag(text, 'Ground_Stop')) {
const arpt = getVal(gs, 'ARPT');
if (arpt) {
delays.set(arpt, { airport: arpt, reason: getVal(gs, 'Reason') || 'Ground stop', avgDelay: 60, type: 'ground_stop' });
}
}
for (const d of parseTag(text, 'Delay')) {
const arpt = getVal(d, 'ARPT');
if (arpt) {
const existing = delays.get(arpt);
if (!existing || existing.type !== 'ground_stop') {
const min = parseInt(getVal(d, 'Min') || '15', 10);
const max = parseInt(getVal(d, 'Max') || '30', 10);
delays.set(arpt, { airport: arpt, reason: getVal(d, 'Reason') || 'Delays', avgDelay: Math.round((min + max) / 2), type: parseDelayTypeFromReason(getVal(d, 'Reason') || '') });
}
}
}
for (const ac of parseTag(text, 'Airport')) {
const arpt = getVal(ac, 'ARPT');
if (arpt && FAA_LIST.includes(arpt)) {
delays.set(arpt, { airport: arpt, reason: 'Airport closure', avgDelay: 120, type: 'ground_stop' });
}
}
return delays;
}
async function seedFaaDelays() {
const t0 = Date.now();
const resp = await fetch(FAA_URL, {
headers: { Accept: 'application/xml', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(15_000),
});
if (!resp.ok) throw new Error(`FAA HTTP ${resp.status}`);
const xml = await resp.text();
const faaDelays = parseFaaXml(xml);
const alerts = [];
for (const iata of FAA_LIST) {
const d = faaDelays.get(iata);
if (!d) continue;
alerts.push({
id: `faa-${iata}`,
iata,
icao: '',
name: iata,
city: '',
country: 'USA',
location: { latitude: 0, longitude: 0 },
region: 'AIRPORT_REGION_AMERICAS',
delayType: `FLIGHT_DELAY_TYPE_${d.type.toUpperCase()}`,
severity: `FLIGHT_DELAY_SEVERITY_${faaSeverityFromAvg(d.avgDelay).toUpperCase()}`,
avgDelayMinutes: d.avgDelay,
delayedFlightsPct: 0,
cancelledFlights: 0,
totalFlights: 0,
reason: d.reason,
source: 'FLIGHT_DELAY_SOURCE_FAA',
updatedAt: Date.now(),
});
}
console.log(`[FAA] ${alerts.length} alerts in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
return { alerts };
}
// ─── Section 3: NOTAM closures (ICAO) ────────────────────────────────────────
const ICAO_NOTAM_URL = 'https://dataservices.icao.int/api/notams-realtime-list';
const NOTAM_CLOSURE_QCODES = new Set(['FA', 'AH', 'AL', 'AW', 'AC', 'AM']);
// Returns: Array of NOTAMs on success, null on quota exhaustion, [] on other errors.
async function fetchIcaoNotams() {
const apiKey = process.env.ICAO_API_KEY;
if (!apiKey) return [];
const locations = NOTAM_LIST.join(',');
const url = `${ICAO_NOTAM_URL}?api_key=${apiKey}&format=json&locations=${locations}`;
try {
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
const body = await resp.text();
if (/reach call limit/i.test(body) || /quota.?exceed/i.test(body)) {
console.warn('[NOTAM] ICAO quota exhausted ("Reach call limit")');
return null;
}
if (!resp.ok) {
console.warn(`[NOTAM] ICAO HTTP ${resp.status}`);
return [];
}
const ct = resp.headers.get('content-type') || '';
if (ct.includes('text/html')) {
console.warn('[NOTAM] ICAO returned HTML (challenge page)');
return [];
}
try {
const data = JSON.parse(body);
return Array.isArray(data) ? data : [];
} catch {
console.warn('[NOTAM] Invalid JSON from ICAO');
return [];
}
} catch (err) {
console.warn(`[NOTAM] Fetch error: ${err?.message || err}`);
return [];
}
}
async function seedNotamClosures() {
if (!process.env.ICAO_API_KEY) {
console.log('[NOTAM] No ICAO_API_KEY — skipping');
return { closedIcaos: [], reasons: {}, quotaExhausted: false, skipped: true };
}
const t0 = Date.now();
const notams = await fetchIcaoNotams();
if (notams === null) {
// Quota exhausted — don't blank the key; signal upstream to touch TTL.
return { closedIcaos: [], reasons: {}, quotaExhausted: true, skipped: false };
}
const now = Math.floor(Date.now() / 1000);
const closedSet = new Set();
const reasons = {};
for (const n of notams) {
const icao = n.itema || n.location || '';
if (!icao || !NOTAM_LIST.includes(icao)) continue;
if (n.endvalidity && n.endvalidity < now) continue;
const code23 = (n.code23 || '').toUpperCase();
const code45 = (n.code45 || '').toUpperCase();
const text = (n.iteme || '').toUpperCase();
const isClosureCode = NOTAM_CLOSURE_QCODES.has(code23) &&
(code45 === 'LC' || code45 === 'AS' || code45 === 'AU' || code45 === 'XX' || code45 === 'AW');
const isClosureText = /\b(AD CLSD|AIRPORT CLOSED|AIRSPACE CLOSED|AD NOT AVBL|CLSD TO ALL)\b/.test(text);
if (isClosureCode || isClosureText) {
closedSet.add(icao);
reasons[icao] = n.iteme || 'Airport closure (NOTAM)';
}
}
const closedIcaos = [...closedSet];
console.log(`[NOTAM] ${notams.length} raw NOTAMs, ${closedIcaos.length} closures in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
return { closedIcaos, reasons, quotaExhausted: false, skipped: false };
}
// ─── Section 4: Aviation RSS news prewarmer ──────────────────────────────────
const AVIATION_RSS_FEEDS = [
{ url: 'https://www.flightglobal.com/rss', name: 'FlightGlobal' },
{ url: 'https://simpleflying.com/feed/', name: 'Simple Flying' },
{ url: 'https://aerotime.aero/feed', name: 'AeroTime' },
{ url: 'https://thepointsguy.com/feed/', name: 'The Points Guy' },
{ url: 'https://airlinegeeks.com/feed/', name: 'Airline Geeks' },
{ url: 'https://onemileatatime.com/feed/', name: 'One Mile at a Time' },
{ url: 'https://viewfromthewing.com/feed/', name: 'View from the Wing' },
{ url: 'https://www.aviationpros.com/rss', name: 'Aviation Pros' },
{ url: 'https://www.aviationweek.com/rss', name: 'Aviation Week' },
];
function parseRssItems(xml, sourceName) {
try {
const items = [];
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
let match;
while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1];
const title = block.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim() || '';
const link = block.match(/<link[^>]*>([\s\S]*?)<\/link>/i)?.[1]?.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim() || '';
const pubDate = block.match(/<pubDate[^>]*>([\s\S]*?)<\/pubDate>/i)?.[1]?.trim() || '';
const desc = block.match(/<description[^>]*>([\s\S]*?)<\/description>/i)?.[1]?.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim() || '';
if (title && link) items.push({ title, link, pubDate, description: desc, _source: sourceName });
}
return items.slice(0, 30);
} catch {
return [];
}
}
async function seedAviationNews() {
const t0 = Date.now();
const now = Date.now();
const cutoff = now - 24 * 60 * 60 * 1000;
const allItems = [];
await Promise.allSettled(
AVIATION_RSS_FEEDS.map(async (feed) => {
try {
const resp = await fetch(feed.url, {
headers: { 'User-Agent': CHROME_UA, Accept: 'application/rss+xml, application/xml, text/xml, */*' },
signal: AbortSignal.timeout(8_000),
});
if (!resp.ok) return;
const xml = await resp.text();
allItems.push(...parseRssItems(xml, feed.name));
} catch { /* skip */ }
}),
);
const items = allItems.map((item) => {
let publishedAt = 0;
if (item.pubDate) try { publishedAt = new Date(item.pubDate).getTime(); } catch { /* skip */ }
if (publishedAt && publishedAt < cutoff) return null;
const snippet = (item.description || '').replace(/<[^>]+>/g, '').slice(0, 200);
return {
id: Buffer.from(item.link).toString('base64').slice(0, 32),
title: item.title, url: item.link, sourceName: item._source,
publishedAt: publishedAt || now, snippet, matchedEntities: [], imageUrl: '',
};
}).filter(Boolean).sort((a, b) => b.publishedAt - a.publishedAt);
console.log(`[News] ${items.length} articles from ${AVIATION_RSS_FEEDS.length} feeds in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
return { items };
}
// ─── Section 5: Notification dispatch ────────────────────────────────────────
// Aviation: new entries into severe/major state trigger an aviation_closure notification.
// NOTAM: new ICAOs in the closed-set trigger a notam_closure notification.
// Both sources persist their prev-state to Redis so short-lived cron runs don't
// spam on every tick.
async function dispatchAviationNotifications(alerts) {
const severeAlerts = alerts.filter(a =>
a.severity === 'FLIGHT_DELAY_SEVERITY_SEVERE' || a.severity === 'FLIGHT_DELAY_SEVERITY_MAJOR',
);
const currentIatas = new Set(severeAlerts.map(a => a.iata).filter(Boolean));
const prev = await upstashGet(AVIATION_PREV_ALERTED_KEY);
const prevSet = new Set(Array.isArray(prev) ? prev : []);
const newAlerts = severeAlerts.filter(a => a.iata && !prevSet.has(a.iata));
// Persist current set for next tick's diff (24h TTL guards restarts).
await upstashSet(AVIATION_PREV_ALERTED_KEY, [...currentIatas], PREV_STATE_TTL);
for (const a of newAlerts.slice(0, 3)) {
await publishNotificationEvent({
eventType: 'aviation_closure',
payload: { title: `${a.iata}${a.city ? ` (${a.city})` : ''}: ${a.reason || 'Airport disruption'}`, source: 'AviationStack' },
severity: a.severity === 'FLIGHT_DELAY_SEVERITY_SEVERE' ? 'critical' : 'high',
variant: undefined,
dedupTtl: 14_400, // 4h
});
}
}
async function dispatchNotamNotifications(closedIcaos, reasons) {
const prev = await upstashGet(NOTAM_PREV_CLOSED_KEY);
const prevSet = new Set(Array.isArray(prev) ? prev : []);
const newClosures = closedIcaos.filter(icao => !prevSet.has(icao));
await upstashSet(NOTAM_PREV_CLOSED_KEY, closedIcaos, PREV_STATE_TTL);
for (const icao of newClosures.slice(0, 3)) {
await publishNotificationEvent({
eventType: 'notam_closure',
payload: { title: `NOTAM: ${icao}${reasons[icao] || 'Airport closure'}`, source: 'ICAO NOTAM' },
severity: 'high',
variant: undefined,
dedupTtl: 21_600, // 6h
});
}
}
// ─── Orchestration ───────────────────────────────────────────────────────────
// runSeed's primary key = INTL (largest spend, most-consumed). FAA + NOTAM +
// News are written as "extra keys" after the primary publish. Each has its own
// seed-meta override that matches api/health.js expectations.
// Per-run cache. runSeed wraps fetchFn in withRetry(3) — if our first attempt
// throws (e.g. intl unhealthy → throw to preserve prior snapshot), the retry
// re-invokes fetchAll. Without this cache, each retry would re-fetch FAA /
// NOTAM / RSS (burning ICAO quota 3x). Memoize the single real fetch so retries
// short-circuit to the same result.
let cachedRun = null;
async function doFetchAll() {
const [intlRes, faaRes, notamRes, newsRes] = await Promise.allSettled([
seedIntlDelays(),
seedFaaDelays(),
seedNotamClosures(),
seedAviationNews(),
]);
// Secondary key: FAA (free API — safe to overwrite when the fetch succeeded)
if (faaRes.status === 'fulfilled' && Array.isArray(faaRes.value?.alerts)) {
try {
await writeExtraKeyWithMeta(FAA_KEY, faaRes.value, FAA_TTL, faaRes.value.alerts.length, FAA_META_KEY);
} catch (e) { console.warn(` ${FAA_KEY} write error: ${e?.message || e}`); }
} else if (faaRes.status === 'rejected') {
console.warn(` FAA fetch failed: ${faaRes.reason?.message || faaRes.reason} — extending TTL`);
try { await extendExistingTtl([FAA_KEY, FAA_META_KEY], FAA_TTL); } catch {}
}
// Secondary key: NOTAM.
// • quota exhausted ("Reach call limit"): refresh data-key TTL AND write a
// fresh seed-meta{fetchedAt: now, recordCount: 0, quotaExhausted: true}
// so consumers keep serving the last known closure list and api/health.js
// stays green during the 24h ICAO backoff (matches prior ais-relay
// behavior at scripts/ais-relay.cjs:2805-2808 pre-strip).
// • fetch rejected / network error: just extend the existing TTL.
// • success: normal write.
if (notamRes.status === 'fulfilled' && !notamRes.value.skipped) {
const n = notamRes.value;
if (n.quotaExhausted) {
try { await extendExistingTtl([NOTAM_KEY], NOTAM_TTL); } catch {}
// Fresh seed-meta with quotaExhausted flag keeps health maxStaleMin=240
// satisfied even though the data key wasn't rewritten.
try {
await upstashSet(NOTAM_META_KEY, { fetchedAt: Date.now(), recordCount: 0, quotaExhausted: true }, 604_800);
} catch (e) { console.warn(` ${NOTAM_META_KEY} write error: ${e?.message || e}`); }
console.log(` ${NOTAM_KEY}: ICAO quota exhausted — extended data TTL + wrote fresh meta (quotaExhausted=true)`);
} else {
try {
await writeExtraKeyWithMeta(NOTAM_KEY, { closedIcaos: n.closedIcaos, reasons: n.reasons }, NOTAM_TTL, n.closedIcaos.length, NOTAM_META_KEY);
} catch (e) { console.warn(` ${NOTAM_KEY} write error: ${e?.message || e}`); }
}
} else if (notamRes.status === 'rejected') {
console.warn(` NOTAM fetch failed: ${notamRes.reason?.message || notamRes.reason} — extending TTL`);
try { await extendExistingTtl([NOTAM_KEY, NOTAM_META_KEY], NOTAM_TTL); } catch {}
}
// Secondary key: News (RSS prewarm, free)
if (newsRes.status === 'fulfilled' && newsRes.value?.items?.length > 0) {
try {
await writeExtraKeyWithMeta(NEWS_KEY, newsRes.value, NEWS_TTL, newsRes.value.items.length);
} catch (e) { console.warn(` ${NEWS_KEY} write error: ${e?.message || e}`); }
} else if (newsRes.status === 'rejected') {
console.warn(` News fetch failed: ${newsRes.reason?.message || newsRes.reason}`);
}
// Dispatch notifications only when source was healthy AND produced real data.
if (intlRes.status === 'fulfilled' && intlRes.value?.healthy && !intlRes.value.skipped) {
try { await dispatchAviationNotifications(intlRes.value.alerts); }
catch (e) { console.warn(` Aviation notify error: ${e?.message || e}`); }
}
if (notamRes.status === 'fulfilled' && !notamRes.value.skipped && !notamRes.value.quotaExhausted) {
try { await dispatchNotamNotifications(notamRes.value.closedIcaos, notamRes.value.reasons); }
catch (e) { console.warn(` NOTAM notify error: ${e?.message || e}`); }
}
// Intl primary key decision: publish only when the fetch succeeded AND was healthy
// AND wasn't skipped (missing API key). An unhealthy or skipped intl fetch MUST
// NOT overwrite the last-good snapshot — consumers keep serving it while we
// extend the TTL + meta TTL so health.js stays green.
const intlFulfilled = intlRes.status === 'fulfilled';
const intlPublishable = intlFulfilled && intlRes.value.healthy && !intlRes.value.skipped;
if (!intlPublishable) {
// Refresh intl key TTL + seed-meta TTL so consumers keep the prior payload
// until the next healthy tick. runSeed's catch-path also calls
// extendExistingTtl on these keys — double-extend is idempotent.
try { await extendExistingTtl([INTL_KEY, INTL_META_KEY], INTL_TTL); } catch {}
const why = !intlFulfilled
? (intlRes.reason?.message || intlRes.reason)
: intlRes.value.skipped ? 'no AVIATIONSTACK_API key'
: 'systemic fetch failure (failures > successes)';
return { _publishable: false, _reason: why };
}
return { _publishable: true, alerts: intlRes.value.alerts };
}
async function fetchAll() {
if (!cachedRun) cachedRun = await doFetchAll();
if (!cachedRun._publishable) {
// Throw so runSeed enters its graceful failure path and ALSO extends the
// existing TTL on INTL_KEY + seed-meta:aviation:intl. withRetry will
// invoke us up to 3 more times; the cachedRun gate makes each retry a
// no-op that just rethrows — no duplicate fetches or quota burn.
throw new Error(`intl unpublishable: ${cachedRun._reason}`);
}
return { alerts: cachedRun.alerts };
}
function validate(data) {
// Zero alerts is a valid steady state (no current airport disruptions).
return !!(data && Array.isArray(data.alerts));
}
export function declareRecords(data) {
return data?.alerts?.length ?? 0;
}
runSeed('aviation', 'intl', INTL_KEY, fetchAll, {
validateFn: validate,
ttlSeconds: INTL_TTL,
sourceVersion: 'aviationstack',
schemaVersion: 3,
declareRecords,
maxStaleMin: 90,
zeroIsValid: true,
}).catch((err) => {
const cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + cause);
process.exit(1);
});