import { statSync } from 'node:fs'; import { BodyRecord } from '../../src/app/shared/models/body.model'; import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model'; import { StarRecord } from '../../src/app/shared/models/star.model'; import { fetchExoplanets } from './fetchExoplanets'; import { fetchSolarSystem } from './fetchSolarSystem'; import { fetchStars } from './fetchStars'; import { dataPath } from './lib/paths'; class ValidationError extends Error {} function assertCondition(condition: boolean, message: string): void { if (!condition) { throw new ValidationError(message); } } function validateStars(stars: StarRecord[]): void { assertCondition(stars.length > 0, 'No stars were produced.'); const ids = new Set(); for (const star of stars) { assertCondition(Number.isFinite(star.id), `Star has a non-numeric id: ${JSON.stringify(star)}`); assertCondition(!ids.has(star.id), `Duplicate star id: ${star.id}`); ids.add(star.id); assertCondition(!!star.name, `Star ${star.id} has no name.`); assertCondition([star.x, star.y, star.z].every(Number.isFinite), `Star ${star.id} has a non-finite position.`); } const binSize = statSync(dataPath('stars.bin')).size; assertCondition(binSize === stars.length * 3 * 4, `stars.bin size (${binSize}) does not match ${stars.length} stars.`); } function validateBodies(bodies: BodyRecord[]): void { assertCondition(bodies.length > 0, 'No solar-system bodies were produced.'); const ids = new Set(bodies.map((body) => body.id)); assertCondition(ids.size === bodies.length, 'Duplicate body ids were found.'); for (const body of bodies) { const orbitValues = Object.values(body.orbit); assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`); if (body.kind === 'moon') { assertCondition(!!body.parentBodyId && ids.has(body.parentBodyId), `Moon ${body.id} has no valid parentBodyId.`); } } const planetCount = bodies.filter((body) => body.kind === 'planet').length; assertCondition(planetCount === 8, `Expected 8 planets, found ${planetCount}.`); } function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set): void { assertCondition(exoplanets.length > 0, 'No exoplanets were produced.'); let crossReferenced = 0; for (const exoplanet of exoplanets) { assertCondition(!!exoplanet.name, `Exoplanet ${exoplanet.id} has no name.`); if (exoplanet.hostStarId !== null) { assertCondition(starIds.has(exoplanet.hostStarId), `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`); crossReferenced++; } } console.log(` ${crossReferenced}/${exoplanets.length} exoplanets cross-referenced to a HYG host star.`); } /** * Orchestrates the whole ETL pipeline: fetches every source (each caches its own raw * responses under `tools/etl/.cache/`), writes the static assets under `src/assets/data/`, * then validates the combined output for completeness before declaring success. */ async function build(): Promise { console.log('=== NASA star map ETL ===\n'); const stars = await fetchStars(); console.log(); const bodies = await fetchSolarSystem(); console.log(); const exoplanets = await fetchExoplanets(stars); console.log(); console.log('Validating output...'); validateStars(stars); validateBodies(bodies); validateExoplanets(exoplanets, new Set(stars.map((star) => star.id))); console.log('\nETL completed successfully:'); console.log(` stars: ${stars.length}`); console.log(` bodies: ${bodies.length}`); console.log(` exoplanets: ${exoplanets.length}`); } build().catch((error) => { console.error('\nETL failed:', error instanceof Error ? error.message : error); process.exitCode = 1; });