Files
worldmonitor/scripts/seed-aviation.mjs
Elie Habib 8b12ecdf43 fix(aviation): seeder writes delays-bootstrap aggregate (close EMPTY-on-quiet-traffic alarm) (#3334)
* fix(aviation): seeder writes delays-bootstrap aggregate (close EMPTY-on-quiet-traffic alarm)

api/health.js BOOTSTRAP_KEYS.flightDelays points at aviation:delays-bootstrap:v1,
but no seeder ever produced it — the key was only written as a 1800s side-effect
inside list-airport-delays.ts. Quiet user-traffic windows >30 min let the
bootstrap expire, tripping EMPTY (CRIT) even with healthy upstream FAA + intl
+ NOTAM seeds. PR #3073 (Apr 13) doubled the cron cadence to 30 min, putting
the bootstrap TTL right at the failure edge.

Make seed-aviation.mjs the canonical writer:
  - New writeDelaysBootstrap() reads FAA + intl + NOTAM from Redis, applies
    the same NOTAM merge + Normal-operations filler the RPC builds, writes
    aviation:delays-bootstrap:v1 with TTL=7200 (~4 missed cron ticks of cushion).
  - Called pre-runSeed (last-good intl, covers intl-fail tick) AND inside
    afterPublishIntl (this-tick intl, happy-path overwrite).
  - Bump RPC's incidental write TTL 1800 → 7200 so a user-triggered RPC
    doesn't shorten the seeder's expiry and re-create the failure mode.

NOTAM merge logic + filler shape are now mirrored in two files (seeder + RPC's
_shared.ts). Both carry comments pointing at the other to surface drift risk.

Verified: typecheck (both tsconfigs) clean; node --test tests/aviation-*.test.mjs
green; full test:data 6590/6590 green.

* fix(aviation): seeder writes restrictedIcaos + bootstrap unwraps intl envelope

PR #3334 review (P1 + P2):

P1 — bootstrap silently dropped NOTAM restrictions
  seedNotamClosures() only tracked NOTAM_CLOSURE_QCODES; the live RPC's
  classifier in server/worldmonitor/aviation/v1/_shared.ts also derives
  restrictions via NOTAM_RESTRICTION_QCODES (RA, RO) + restriction code45s
  + restriction-text regex. Seeded NOTAM payload only had `closedIcaos`,
  so restrictedIcaos was always empty in Redis — both the new bootstrap
  aggregate AND the RPC's seed-read path silently dropped every NOTAM
  restriction. Mirror the full classifier from _shared.ts:438-452;
  side-car write now includes restrictedIcaos and seed-meta count
  reflects closures + restrictions.

P2 — pre-runSeed bootstrap built with no intl alerts on intl-fail tick
  runSeed wraps the canonical INTL_KEY in {_seed, data} when declareRecords
  is enabled. writeDelaysBootstrap()'s upstashGet only JSON.parsed — no
  envelope unwrap — so intlPayload.alerts was undefined on the pre-runSeed
  bootstrap-build path, and an intl-fail tick would publish a bootstrap
  with all intl alerts dropped instead of preserving the last-good
  snapshot. Add upstashGetUnwrapped() (delegates to unwrapEnvelope from
  _seed-envelope-source.mjs); use it for all three reads (FAA/NOTAM bare
  values pass through unchanged via unwrapEnvelope's permissive path).

Verified: typecheck (both tsconfigs) clean; aviation + edge-functions
tests green; full test:data 6590/6590 green.

* fix(aviation): bootstrap iterates union of seeder + RPC airport registries

PR #3334 review (P2 ×2):

P2 — AIRPORTS vs MONITORED_AIRPORTS registry drift
  Today the two diverge by ~45 iata codes (29 RPC-only, 16 seeder-only).
  Pre-fix the bootstrap iterated the seeder's local AIRPORTS list for
  Normal-operations filler and NOTAM airport lookup, so 29 monitored
  airports never appeared in the bootstrap aggregate even though the
  live RPC included them. Fix: parse src/config/airports.ts as text at
  startup (regex over the static const), memoise the parse, build a
  by-iata Map union (seeder wins on conflict for canonical meta), and
  iterate that for both NOTAM lookup and filler. First-run divergence
  summary logged to surface future drift in cron logs without blocking
  writes. Degrades to seeder AIRPORTS only with a warning if parse fails.

P2 — afterPublishIntl receives raw pre-transform data
  runSeed forwards the RAW fetchIntl() result to afterPublish, NOT the
  publishTransform()'d shape. Today publishTransform is a pass-through
  wrapper so data.alerts is correct, but coupling is subtle — added an
  inline CONTRACT comment so a future publishTransform mutation doesn't
  silently drift bootstrap from INTL_KEY.

Verified: typecheck (both tsconfigs) clean; aviation + edge-functions
tests green; full test:data 6590/6590 green; standalone parse harness
recovers all 111 MONITORED_AIRPORTS rows.
2026-04-23 11:43:54 +04:00

1180 lines
64 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 { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
loadEnvFile,
CHROME_UA,
runSeed,
writeExtraKeyWithMeta,
extendExistingTtl,
acquireLockSafely,
releaseLock,
getRedisCredentials,
} from './_seed-utils.mjs';
import { unwrapEnvelope } from './_seed-envelope-source.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';
// Page-load hydration aggregate. Health (api/health.js BOOTSTRAP_KEYS.flightDelays)
// reads STRLEN here. Historically only written as a 1800s RPC side-effect inside
// list-airport-delays.ts — quiet user windows >30min would let it expire, tripping
// EMPTY (CRIT) even with healthy upstream feeds. Now produced canonically by this
// seeder; RPC keeps its write at the same TTL as a courtesy mid-tick refresh.
const BOOTSTRAP_KEY = 'aviation:delays-bootstrap: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
const BOOTSTRAP_TTL = 7_200; // 2h — matches FAA/NOTAM; survives ~4 missed cron ticks
// 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: 'FBSK', 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
// with coordinates — aviationstack rows are the only ones with lat/lon).
const AIRPORT_META = Object.fromEntries(AVIATIONSTACK_LIST.map(a => [a.iata, a]));
// iata → FAA-row meta (icao/name/city/country for alert envelopes; no lat/lon
// by design — FAA rows are US-regional airports we don't render on the globe).
const FAA_META = Object.fromEntries(
AIRPORTS.filter(a => a.sources.includes('faa')).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; }
}
// Envelope-aware GET. runSeed wraps canonical keys in `{_seed, data}` when the
// seeder opts into the seed contract (declareRecords + envelopeMeta) — INTL_KEY
// is one such key (see runSeed call w/ declareRecords below). Bare values
// (FAA_KEY, NOTAM_KEY via writeExtraKey w/o envelopeMeta) pass through.
async function upstashGetUnwrapped(key) {
const raw = await upstashGet(key);
return unwrapEnvelope(raw).data;
}
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;
const meta = FAA_META[iata];
alerts.push({
id: `faa-${iata}`,
iata,
icao: meta?.icao ?? '',
name: meta?.name ?? iata,
city: meta?.city ?? '',
country: meta?.country ?? 'USA',
location: { latitude: 0, longitude: 0 }, // FAA rows have no lat/lon in the registry
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']);
// Restrictions: NOTAM Q-codes RA (restricted area) and RO (overfly prohibited)
// + restricted code45s and text patterns. Mirrors NOTAM_RESTRICTION_QCODES +
// the restriction-text regex in server/worldmonitor/aviation/v1/_shared.ts:29,
// :440-444 — keep in lockstep so seeded NOTAM data matches the live RPC's
// classifier.
const NOTAM_RESTRICTION_QCODES = new Set(['RA', 'RO']);
// 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: [], restrictedIcaos: [], 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: [], restrictedIcaos: [], reasons: {}, quotaExhausted: true, skipped: false };
}
const now = Math.floor(Date.now() / 1000);
const closedSet = new Set();
const restrictedSet = 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 closureCode45 = code45 === 'LC' || code45 === 'AS' || code45 === 'AU' || code45 === 'XX' || code45 === 'AW';
const restrictionCode45 = code45 === 'RE' || code45 === 'RT';
const isClosureCode = NOTAM_CLOSURE_QCODES.has(code23) && closureCode45;
const isRestrictionCode = (NOTAM_RESTRICTION_QCODES.has(code23) || NOTAM_CLOSURE_QCODES.has(code23)) && restrictionCode45;
const isClosureText = /\b(AD CLSD|AIRPORT CLOSED|AIRSPACE CLOSED|AD NOT AVBL|CLSD TO ALL)\b/.test(text);
const isRestrictionText = /\b(RESTRICTED AREA|PROHIBITED AREA|DANGER AREA|TFR|TEMPORARY FLIGHT RESTRICTION)\b/.test(text);
// Closure wins over restriction for the same NOTAM (mirrors _shared.ts
// if/else chain at line 446-452).
if (isClosureCode || isClosureText) {
closedSet.add(icao);
reasons[icao] = n.iteme || 'Airport closure (NOTAM)';
} else if (isRestrictionCode || isRestrictionText) {
restrictedSet.add(icao);
reasons[icao] = n.iteme || 'Airspace restriction (NOTAM)';
}
}
const closedIcaos = [...closedSet];
const restrictedIcaos = [...restrictedSet];
console.log(`[NOTAM] ${notams.length} raw NOTAMs, ${closedIcaos.length} closures, ${restrictedIcaos.length} restrictions in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
return { closedIcaos, restrictedIcaos, 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
});
}
}
// ─── Page-load bootstrap aggregate ───────────────────────────────────────────
// Mirror of the alerts-array assembly in
// server/worldmonitor/aviation/v1/list-airport-delays.ts (FAA + intl + NOTAM
// merge + Normal-operations filler from AIRPORTS). Keep the two builders in
// lockstep — when the RPC's NOTAM merge / filler shape / enum mapping changes,
// update both. Enum-string forms here match SEVERITY_MAP/DELAY_TYPE_MAP/REGION_MAP
// at the top of this file so consumers parse identically to the RPC's output.
const SEV_ORDER = ['normal', 'minor', 'moderate', 'major', 'severe'];
function buildNormalOpsAlert(airport) {
return {
id: `status-${airport.iata}`,
iata: airport.iata,
icao: airport.icao,
name: airport.name,
city: airport.city ?? '',
country: airport.country,
location: { latitude: airport.lat ?? 0, longitude: airport.lon ?? 0 },
region: REGION_MAP[airport.region] ?? 'AIRPORT_REGION_AMERICAS',
delayType: 'FLIGHT_DELAY_TYPE_GENERAL',
severity: 'FLIGHT_DELAY_SEVERITY_NORMAL',
avgDelayMinutes: 0,
delayedFlightsPct: 0,
cancelledFlights: 0,
totalFlights: 0,
reason: 'Normal operations',
source: 'FLIGHT_DELAY_SOURCE_COMPUTED',
updatedAt: Date.now(),
};
}
function buildNotamAlert(airport, reason, severity = 'severe', delayType = 'closure') {
const trimmed = reason.length > 200 ? reason.slice(0, 200) + '…' : reason;
return {
id: `notam-${airport.iata}`,
iata: airport.iata,
icao: airport.icao,
name: airport.name,
city: airport.city ?? '',
country: airport.country,
location: { latitude: airport.lat ?? 0, longitude: airport.lon ?? 0 },
region: REGION_MAP[airport.region] ?? 'AIRPORT_REGION_AMERICAS',
delayType: DELAY_TYPE_MAP[delayType] ?? 'FLIGHT_DELAY_TYPE_CLOSURE',
severity: SEVERITY_MAP[severity] ?? 'FLIGHT_DELAY_SEVERITY_SEVERE',
avgDelayMinutes: 0,
delayedFlightsPct: 0,
cancelledFlights: 0,
totalFlights: 0,
reason: trimmed,
source: 'FLIGHT_DELAY_SOURCE_NOTAM',
updatedAt: Date.now(),
};
}
function mergeNotamWithExistingAlert(airport, notamReason, existing, severity = 'severe', delayType = 'closure') {
if (!existing || existing.totalFlights === 0) {
return buildNotamAlert(airport, notamReason, severity, delayType);
}
const cancelRate = (existing.cancelledFlights / existing.totalFlights) * 100;
const notamCancelSev = cancelRate >= 50 ? 'severe' : cancelRate >= 25 ? 'major' : cancelRate >= 10 ? 'moderate' : 'minor';
const existingSevName = (existing.severity ?? '')
.replace('FLIGHT_DELAY_SEVERITY_', '').toLowerCase() || 'normal';
const effectiveSev = SEV_ORDER[Math.max(
SEV_ORDER.indexOf(existingSevName),
SEV_ORDER.indexOf(notamCancelSev),
SEV_ORDER.indexOf('moderate'), // notamFloor
)] ?? 'moderate';
const cancelText = `${Math.round(cancelRate)}% cxl`;
const reason = `NOTAM: ${notamReason.slice(0, 120)}${cancelText}`;
const trimmed = reason.length > 200 ? reason.slice(0, 200) + '…' : reason;
return {
...existing,
id: `notam-${airport.iata}`,
severity: SEVERITY_MAP[effectiveSev] ?? 'FLIGHT_DELAY_SEVERITY_MODERATE',
delayType: DELAY_TYPE_MAP[delayType] ?? 'FLIGHT_DELAY_TYPE_CLOSURE',
reason: trimmed,
source: 'FLIGHT_DELAY_SOURCE_NOTAM',
updatedAt: Date.now(),
};
}
// Parse src/config/airports.ts as text to recover the live RPC's MONITORED_AIRPORTS
// registry without a TS build step. The RPC iterates this for "Normal operations"
// filler; the seeder's local AIRPORTS list is a related-but-different set
// (carries `sources` for which feed covers each airport, omits some RPC-only
// entries, has some seeder-only entries). Today the two diverge by ~45 iata codes.
// We read both at runtime and union them by iata so the bootstrap covers every
// airport either registry knows about — match RPC output exactly + future-proof
// against drift in either direction.
//
// Memoised: read-once at module load. If parse fails (unexpected file shape, file
// missing in some packaging), we degrade to seeder's AIRPORTS only and warn —
// bootstrap is still produced, just without the RPC-only iata coverage.
let _monitoredAirportsCache = null;
function loadMonitoredAirportsFromConfigFile() {
if (_monitoredAirportsCache !== null) return _monitoredAirportsCache;
try {
const here = dirname(fileURLToPath(import.meta.url));
const path = join(here, '..', 'src', 'config', 'airports.ts');
const src = readFileSync(path, 'utf8');
const rows = [];
// Match rows of shape: { iata: 'XXX', icao: 'YYYY', name: '...', city: '...',
// country: '...', lat: N, lon: N, region: '...' }. Allows both single + double
// quotes for `name` (some rows use double-quote to embed apostrophes).
const rowRe = /\{\s*iata:\s*'([A-Z]{3})'\s*,\s*icao:\s*'([A-Z0-9]{3,4})'\s*,\s*name:\s*(?:'([^']*)'|"([^"]*)")\s*,\s*city:\s*(?:'([^']*)'|"([^"]*)")\s*,\s*country:\s*(?:'([^']*)'|"([^"]*)")\s*,\s*lat:\s*(-?\d+(?:\.\d+)?)\s*,\s*lon:\s*(-?\d+(?:\.\d+)?)\s*,\s*region:\s*'([a-z]+)'\s*\}/g;
let m;
while ((m = rowRe.exec(src)) !== null) {
rows.push({
iata: m[1],
icao: m[2],
name: m[3] ?? m[4],
city: m[5] ?? m[6],
country: m[7] ?? m[8],
lat: parseFloat(m[9]),
lon: parseFloat(m[10]),
region: m[11],
});
}
if (rows.length === 0) {
console.warn(`[Bootstrap] parsed 0 rows from ${path} — falling back to seeder AIRPORTS only`);
_monitoredAirportsCache = [];
return _monitoredAirportsCache;
}
_monitoredAirportsCache = rows;
return rows;
} catch (err) {
console.warn(`[Bootstrap] failed to parse src/config/airports.ts: ${err?.message || err} — falling back to seeder AIRPORTS only`);
_monitoredAirportsCache = [];
return _monitoredAirportsCache;
}
}
// Union the seeder's AIRPORTS with RPC's MONITORED_AIRPORTS by iata. Seeder rows
// win on conflict (they have the more recent canonical NOTAM/AviationStack meta).
// Logs a warning summary on first divergence so registry drift surfaces in cron
// logs without blocking writes.
let _filterRegistryWarnLogged = false;
function buildFillerRegistry() {
const monitored = loadMonitoredAirportsFromConfigFile();
const byIata = new Map();
for (const a of monitored) byIata.set(a.iata, a);
for (const a of AIRPORTS) byIata.set(a.iata, a); // seeder wins on conflict
if (!_filterRegistryWarnLogged) {
const seederIatas = new Set(AIRPORTS.map(a => a.iata));
const monitoredIatas = new Set(monitored.map(a => a.iata));
const monitoredOnly = [...monitoredIatas].filter(i => !seederIatas.has(i));
const seederOnly = [...seederIatas].filter(i => !monitoredIatas.has(i));
if (monitoredOnly.length > 0 || seederOnly.length > 0) {
console.warn(`[Bootstrap] registry drift: ${monitoredOnly.length} RPC-only iatas (${monitoredOnly.slice(0, 10).join(',')}${monitoredOnly.length > 10 ? '…' : ''}), ${seederOnly.length} seeder-only iatas (${seederOnly.slice(0, 10).join(',')}${seederOnly.length > 10 ? '…' : ''}). Bootstrap covers union of both (${byIata.size} airports).`);
}
_filterRegistryWarnLogged = true;
}
return [...byIata.values()];
}
// Build + write the page-load bootstrap aggregate. Pass `intlAlertsOverride` to
// use this-tick's intl from afterPublish (skips the Redis round-trip and avoids
// a one-tick lag); omit to fall back to the last-good intl in Redis (used by
// the pre-runSeed call so a current-tick intl failure still refreshes bootstrap).
async function writeDelaysBootstrap(intlAlertsOverride) {
try {
const [faaPayload, intlPayload, notamPayload] = await Promise.all([
upstashGetUnwrapped(FAA_KEY),
intlAlertsOverride ? Promise.resolve({ alerts: intlAlertsOverride }) : upstashGetUnwrapped(INTL_KEY),
upstashGetUnwrapped(NOTAM_KEY),
]);
const faaAlerts = Array.isArray(faaPayload?.alerts) ? faaPayload.alerts : [];
const intlAlerts = Array.isArray(intlPayload?.alerts) ? intlPayload.alerts : [];
const closedIcaos = Array.isArray(notamPayload?.closedIcaos) ? notamPayload.closedIcaos : [];
const restrictedIcaos = Array.isArray(notamPayload?.restrictedIcaos) ? notamPayload.restrictedIcaos : [];
const reasons = (notamPayload?.reasons && typeof notamPayload.reasons === 'object') ? notamPayload.reasons : {};
const allAlerts = [...faaAlerts, ...intlAlerts];
// Union of seeder AIRPORTS + RPC MONITORED_AIRPORTS so the bootstrap matches
// what the live RPC produces even when registries drift.
const fillerRegistry = buildFillerRegistry();
const existingIatas = new Set(allAlerts.map(a => a.iata));
const applyNotam = (icao, severity, delayType, fallback) => {
const airport = fillerRegistry.find(a => a.icao === icao);
if (!airport) return;
const reason = reasons[icao] || fallback;
if (existingIatas.has(airport.iata)) {
const idx = allAlerts.findIndex(a => a.iata === airport.iata);
if (idx >= 0) allAlerts[idx] = mergeNotamWithExistingAlert(airport, reason, allAlerts[idx], severity, delayType);
} else {
allAlerts.push(buildNotamAlert(airport, reason, severity, delayType));
existingIatas.add(airport.iata);
}
};
for (const icao of closedIcaos) applyNotam(icao, 'severe', 'closure', 'Airport closure (NOTAM)');
for (const icao of restrictedIcaos) applyNotam(icao, 'major', 'general', 'Airspace restriction (NOTAM)');
const alertedIatas = new Set(allAlerts.map(a => a.iata));
for (const airport of fillerRegistry) {
if (!alertedIatas.has(airport.iata)) allAlerts.push(buildNormalOpsAlert(airport));
}
const ok = await upstashSet(BOOTSTRAP_KEY, { alerts: allAlerts }, BOOTSTRAP_TTL);
if (ok) {
console.log(`[Bootstrap] wrote ${allAlerts.length} alerts to ${BOOTSTRAP_KEY} (faa=${faaAlerts.length}, intl=${intlAlerts.length}, notam-closed=${closedIcaos.length}, notam-restricted=${restrictedIcaos.length})`);
} else {
console.warn(`[Bootstrap] SET ${BOOTSTRAP_KEY} returned false`);
}
} catch (err) {
console.warn(`[Bootstrap] build/write error: ${err?.message || err}`);
}
}
// ─── 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.
// ─── Side-car seed runners ───────────────────────────────────────────────────
// Each secondary data source (FAA, NOTAM, news) seeds INDEPENDENTLY of the
// AviationStack intl path. A transient intl outage or missing AVIATIONSTACK_API
// MUST NOT freeze FAA/NOTAM/news writes — they have their own upstream sources
// (FAA ASWS, ICAO API, RSS) and their own consumers (list-airport-delays,
// loadNotamClosures, list-aviation-news).
//
// Each side-car: acquires its own Redis lock (distinct from intl's lock),
// fetches, writes data-key + seed-meta on success, extends TTL on failure,
// releases the lock in finally. Sequential so concurrent Railway cron fires
// don't stomp; each source's cost is independent so total wall time ≈ sum.
async function withLock(lockDomain, body) {
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const lockResult = await acquireLockSafely(lockDomain, runId, 120_000, { label: lockDomain });
if (lockResult.skipped) {
console.log(` ${lockDomain}: SKIPPED (Redis unavailable)`);
return;
}
if (!lockResult.locked) {
console.log(` ${lockDomain}: SKIPPED (lock held by another run)`);
return;
}
try {
await body();
} finally {
await releaseLock(lockDomain, runId);
}
}
async function runFaaSideCar() {
await withLock('aviation:faa', async () => {
try {
const faa = await seedFaaDelays();
if (faa?.alerts) {
await writeExtraKeyWithMeta(FAA_KEY, faa, FAA_TTL, faa.alerts.length, FAA_META_KEY);
console.log(`[FAA] wrote ${faa.alerts.length} alerts to ${FAA_KEY}`);
}
} catch (err) {
console.warn(`[FAA] fetch/write error: ${err?.message || err} — extending TTL`);
try { await extendExistingTtl([FAA_KEY, FAA_META_KEY], FAA_TTL); } catch {}
}
});
}
async function runNotamSideCar() {
await withLock('aviation:notam', async () => {
try {
const notam = await seedNotamClosures();
if (notam.skipped) return; // no ICAO_API_KEY
if (notam.quotaExhausted) {
// ICAO quota exhausted ("Reach call limit") — preserve the last known
// closure list by refreshing the data-key TTL + writing fresh meta
// with quotaExhausted=true. Keeps api/health.js (maxStaleMin: 240)
// green through the 24h backoff window. Matches pre-strip
// ais-relay.cjs:2805-2808 byte-for-byte.
try { await extendExistingTtl([NOTAM_KEY], NOTAM_TTL); } catch {}
try {
await upstashSet(NOTAM_META_KEY, { fetchedAt: Date.now(), recordCount: 0, quotaExhausted: true }, 604_800);
} catch (e) { console.warn(`[NOTAM] meta write error: ${e?.message || e}`); }
console.log(`[NOTAM] ICAO quota exhausted — extended data TTL + wrote fresh meta (quotaExhausted=true)`);
return;
}
await writeExtraKeyWithMeta(
NOTAM_KEY,
{ closedIcaos: notam.closedIcaos, restrictedIcaos: notam.restrictedIcaos, reasons: notam.reasons },
NOTAM_TTL,
notam.closedIcaos.length + notam.restrictedIcaos.length,
NOTAM_META_KEY,
);
console.log(`[NOTAM] wrote ${notam.closedIcaos.length} closures + ${notam.restrictedIcaos.length} restrictions to ${NOTAM_KEY}`);
try { await dispatchNotamNotifications(notam.closedIcaos, notam.reasons); }
catch (e) { console.warn(`[NOTAM] notify error: ${e?.message || e}`); }
} catch (err) {
console.warn(`[NOTAM] fetch/write error: ${err?.message || err} — extending TTL`);
try { await extendExistingTtl([NOTAM_KEY, NOTAM_META_KEY], NOTAM_TTL); } catch {}
}
});
}
async function runNewsSideCar() {
await withLock('aviation:news', async () => {
try {
const news = await seedAviationNews();
if (news?.items?.length > 0) {
await writeExtraKeyWithMeta(NEWS_KEY, news, NEWS_TTL, news.items.length);
console.log(`[News] wrote ${news.items.length} articles to ${NEWS_KEY}`);
}
} catch (err) {
console.warn(`[News] fetch/write error: ${err?.message || err}`);
}
});
}
// ─── Intl via runSeed ────────────────────────────────────────────────────────
// Intl (the paid-API, high-cost canonical) uses runSeed's full machinery:
// contract-mode envelope, retry-on-throw, graceful TTL-extend on failure,
// seed-meta freshness. When intl is unhealthy we throw to force runSeed into
// its catch path — which extends the INTL_KEY + seed-meta:aviation:intl TTLs
// and exits 0 without touching afterPublish. Consumers keep serving the
// last-good snapshot. FAA/NOTAM/news already ran via their side-cars and are
// independent — an intl outage does NOT freeze their freshness.
async function fetchIntl() {
const result = await seedIntlDelays();
if (!result.healthy || result.skipped) {
const why = result.skipped
? 'no AVIATIONSTACK_API key'
: 'systemic fetch failure (failures > successes)';
throw new Error(`intl unpublishable: ${why}`);
}
return result;
}
export function declareRecords(data) {
return data?.alerts?.length ?? 0;
}
// publishTransform reshapes seedIntlDelays' output into the canonical envelope
// shape consumers read ({ alerts: AirportDelayAlert[] }). declareRecords sees
// this transformed shape; afterPublish still receives the raw fetchIntl result.
function publishTransform(data) {
return { alerts: data?.alerts ?? [] };
}
async function afterPublishIntl(data) {
// CONTRACT: runSeed forwards the RAW fetchIntl() result here, NOT the
// publishTransform()'d shape. fetchIntl returns seedIntlDelays' output
// ({ alerts, healthy, skipped, ... }), so data.alerts is the same array
// publishTransform wraps into INTL_KEY. If publishTransform ever filters
// or mutates alerts (today it's a pass-through wrapper), this bootstrap
// write would silently diverge from INTL_KEY — keep them in lockstep.
try { await dispatchAviationNotifications(data.alerts); }
catch (e) { console.warn(`[Intl] notify error: ${e?.message || e}`); }
// Refresh the page-load bootstrap with this-tick intl. The pre-runSeed call
// in main() already wrote a bootstrap using last-good intl; this overwrite
// upgrades it to current.
await writeDelaysBootstrap(data?.alerts);
}
function validate(publishData) {
// Zero alerts is a valid steady state (no current airport disruptions) —
// but shape must be { alerts: [] } regardless.
return !!(publishData && Array.isArray(publishData.alerts));
}
// Entry point: run the three independent side-cars sequentially, then hand off
// to runSeed for intl. runSeed calls process.exit() on every terminal path, so
// it MUST be the last thing invoked in this file.
async function main() {
console.log('=== Aviation Seeder (side-cars + intl) ===');
await runFaaSideCar();
await runNotamSideCar();
await runNewsSideCar();
// Pre-runSeed bootstrap write: ensures the page-load aggregate refreshes even
// if intl fetch fails this tick (runSeed's catch-path skips afterPublish).
// Uses last-good intl from Redis; afterPublishIntl will overwrite with fresh
// intl on success.
await writeDelaysBootstrap();
return runSeed('aviation', 'intl', INTL_KEY, fetchIntl, {
validateFn: validate,
ttlSeconds: INTL_TTL,
sourceVersion: 'aviationstack',
schemaVersion: 3,
declareRecords,
publishTransform,
afterPublish: afterPublishIntl,
maxStaleMin: 90,
zeroIsValid: true,
});
}
main().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);
});