orbit-studio / worker /scripts /generate-groups.mjs
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
14.2 kB
#!/usr/bin/env node
// Merge the committed core config with every data/custom/*/satvis.yaml plugin
// config, inline each group's extraRecordsFile TLE text into extraRecords,
// validate, and write worker/src/config/satvis.generated.json.
//
// A config contributes two independent sections: `groups` (what is served, as
// which unit) and `satellites` (static per-satellite facts, keyed by NORAD id
// and attached to records at refresh time). With no plugin configs present this
// still produces a valid generated file from the core config alone (so lint/CI
// stay green).
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import YAML from "yaml";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const workerDir = path.resolve(scriptDir, "..");
const repoRoot = path.resolve(workerDir, "..");
const coreConfigPath = path.join(workerDir, "src", "config", "satvis.core.yaml");
const customDir = path.join(repoRoot, "data", "custom");
const outPath = path.join(workerDir, "src", "config", "satvis.generated.json");
const PLUGIN_CONFIG_NAME = "satvis.yaml";
// Pre-YAML plugin config name. Detected only to fail loudly: silently skipping
// it would make a plugin's groups vanish from the build without a word.
const LEGACY_PLUGIN_CONFIG_NAME = "groups.json";
const GROUP_NAME_RE = /^[a-zA-Z0-9_-]+$/;
function readYaml(file) {
return YAML.parse(fs.readFileSync(file, "utf8"));
}
// Parse TLE text into TleRecord objects. Supports 3-line blocks (optional
// leading "0 " on the name line), and bare 2-line blocks (no name).
//
// Deliberate near-duplicate of src/modules/util/gp.ts parseTleText with an
// intentionally opposite error policy: this build tool fails loud (throws) so
// bad input never ships, while the browser path warns and skips. Do not
// "unify" them.
function parseTleText(text) {
const lines = text
.split(/\r?\n/)
.map((line) => line.replace(/\s+$/, ""))
.filter((line) => line.length > 0);
const records = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith("1 ") && i + 1 < lines.length && lines[i + 1].startsWith("2 ")) {
// Bare 2-line block.
records.push({ TLE_LINE1: line, TLE_LINE2: lines[i + 1] });
i += 2;
} else if (i + 2 < lines.length && lines[i + 1].startsWith("1 ") && lines[i + 2].startsWith("2 ")) {
// 3-line block; strip an optional "0 " name prefix.
const name = line.startsWith("0 ") ? line.slice(2) : line;
records.push({ OBJECT_NAME: name, TLE_LINE1: lines[i + 1], TLE_LINE2: lines[i + 2] });
i += 3;
} else {
throw new Error(`unrecognized TLE block at line ${i + 1}: ${JSON.stringify(line)}`);
}
}
return records;
}
// Load and normalize one config file. `extraRecordsFile` (generator-only) is
// resolved relative to the config's directory and inlined into extraRecords.
function loadConfig(configPath) {
const config = readYaml(configPath);
const dir = path.dirname(configPath);
const groups = (config.groups ?? []).map((group) => {
const { extraRecordsFile, ...rest } = group;
if (extraRecordsFile) {
const txtPath = path.join(dir, extraRecordsFile);
const parsed = parseTleText(fs.readFileSync(txtPath, "utf8"));
rest.extraRecords = [...(rest.extraRecords ?? []), ...parsed];
}
return rest;
});
return { groups, satellites: config.satellites ?? [] };
}
function discoverPluginConfigs() {
if (!fs.existsSync(customDir)) {
return [];
}
const configs = [];
for (const entry of fs.readdirSync(customDir, { withFileTypes: true }).toSorted((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isDirectory()) {
continue;
}
const dir = path.join(customDir, entry.name);
const candidate = path.join(dir, PLUGIN_CONFIG_NAME);
if (fs.existsSync(candidate)) {
configs.push(candidate);
continue;
}
// Fail loudly rather than silently dropping a plugin that has not migrated.
if (fs.existsSync(path.join(dir, LEGACY_PLUGIN_CONFIG_NAME))) {
throw new Error(
`${path.relative(repoRoot, path.join(dir, LEGACY_PLUGIN_CONFIG_NAME))} is the pre-YAML config format; ` +
`rename it to ${PLUGIN_CONFIG_NAME} and convert it to YAML (see worker/src/config/satvis.core.yaml)`,
);
}
}
return configs;
}
// Validate a group's `satellites` rows (if any). Each row must select by
// `noradId` (number) or `upstreamName` (string); `name`/`metadata` optional
// with checked types. Throws with the group name on any violation.
function validateSatellites(group) {
if (group.satellites === undefined) {
return;
}
if (!Array.isArray(group.satellites)) {
throw new Error(`group ${JSON.stringify(group.name)}: "satellites" must be an array`);
}
group.satellites.forEach((row, i) => {
const where = `group ${JSON.stringify(group.name)} satellites[${i}]`;
if (row === null || typeof row !== "object" || Array.isArray(row)) {
throw new Error(`${where}: must be an object`);
}
if (row.noradId !== undefined && typeof row.noradId !== "number") {
throw new Error(`${where}: "noradId" must be a number`);
}
if (row.upstreamName !== undefined && typeof row.upstreamName !== "string") {
throw new Error(`${where}: "upstreamName" must be a string`);
}
if (row.noradId === undefined && row.upstreamName === undefined) {
throw new Error(`${where}: must have a "noradId" or "upstreamName"`);
}
if (row.name !== undefined && typeof row.name !== "string") {
throw new Error(`${where}: "name" must be a string`);
}
if (row.metadata !== undefined) {
validateMetadata(row.metadata, where);
if (row.noradId === undefined) {
// Metadata is keyed by NORAD id in the merged table; a name-only row has
// no key to lift it under.
throw new Error(`${where}: "metadata" requires a "noradId" (matching is by NORAD id only)`);
}
// Same rule as a top-level table entry: an empty bag would attach a
// meaningless `metadata` key to the served record, which is supposed to be
// present only when the satellite actually has facts to carry.
if (Object.keys(row.metadata).length === 0) {
throw new Error(`${where}: "metadata" is empty — remove it or give it a field`);
}
}
if (row.decayed !== undefined && typeof row.decayed !== "boolean") {
throw new Error(`${where}: "decayed" must be a boolean`);
}
});
}
// Validate a metadata bag. The worker treats it as opaque, so only the fields
// whose shape the frontend depends on are checked here: the swath extents, which
// must be positive numbers and must be given for both sides or neither (see
// swathExtentsOf in src/config/satelliteMetadata.ts, which reads them as a pair
// and treats a half-specified swath as absent).
function validateMetadata(metadata, where) {
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
throw new Error(`${where}: "metadata" must be an object`);
}
const sides = ["swathStarboardKm", "swathPortKm"];
for (const side of sides) {
const value = metadata[side];
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
throw new Error(`${where}: "${side}" must be a positive number`);
}
}
const given = sides.filter((side) => metadata[side] !== undefined);
if (given.length === 1) {
throw new Error(`${where}: swath needs both "swathStarboardKm" and "swathPortKm" (got only ${JSON.stringify(given[0])})`);
}
}
// The keys of a satellite-table entry that are bookkeeping rather than payload.
// Everything else in the entry IS the metadata bag, which is what keeps adding a
// field a data-only edit.
const TABLE_ENTRY_KEYS = new Set(["noradId", "name", "decayed"]);
function metadataFields(entry) {
return Object.fromEntries(Object.entries(entry).filter(([key]) => !TABLE_ENTRY_KEYS.has(key)));
}
// Validate a config's top-level `satellites` table. Every entry keys on a
// numeric `noradId`; `name` is documentation only.
function validateSatelliteTable(entries, source) {
if (!Array.isArray(entries)) {
throw new Error(`${source}: "satellites" must be an array`);
}
entries.forEach((entry, i) => {
const where = `${source} satellites[${i}]`;
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`${where}: must be an object`);
}
if (typeof entry.noradId !== "number") {
throw new Error(`${where}: "noradId" must be a number`);
}
if (entry.name !== undefined && typeof entry.name !== "string") {
throw new Error(`${where}: "name" must be a string`);
}
if (entry.decayed !== undefined && typeof entry.decayed !== "boolean") {
throw new Error(`${where}: "decayed" must be a boolean`);
}
const metadata = metadataFields(entry);
validateMetadata(metadata, where);
if (Object.keys(metadata).length === 0) {
throw new Error(`${where}: has no metadata fields — remove the entry or give it something to attach`);
}
});
}
function validate(groups) {
const names = new Set();
for (const group of groups) {
if (typeof group.name !== "string" || !GROUP_NAME_RE.test(group.name)) {
throw new Error(`invalid group name ${JSON.stringify(group.name)} (must match ${GROUP_NAME_RE})`);
}
if (names.has(group.name)) {
throw new Error(`duplicate group name ${JSON.stringify(group.name)}`);
}
names.add(group.name);
validateSatellites(group);
}
// include targets must exist.
for (const group of groups) {
for (const dep of group.include ?? []) {
if (!names.has(dep)) {
throw new Error(`group ${JSON.stringify(group.name)} includes unknown group ${JSON.stringify(dep)}`);
}
}
}
// no include cycles (DFS).
const byName = new Map(groups.map((g) => [g.name, g]));
const state = new Map();
const visit = (name, stack) => {
if (state.get(name) === "done") {
return;
}
if (state.get(name) === "visiting") {
throw new Error(`include cycle detected: ${[...stack, name].join(" -> ")}`);
}
state.set(name, "visiting");
for (const dep of byName.get(name)?.include ?? []) {
visit(dep, [...stack, name]);
}
state.set(name, "done");
};
for (const group of groups) {
visit(group.name, []);
}
}
// Accumulator for the merged satellite table, keyed by NORAD id. Contributions
// arrive from two kinds of place — a config's top-level `satellites` table and a
// group's `satellites[].metadata` rows — and are merged field-wise in arrival
// order, so a later, more specific contribution wins per field.
//
// Conflicts (two places giving one field different values for one satellite) are
// a config bug: whichever won would depend on file discovery order, so we fail
// with both origins instead of picking. Identical values merge silently, which
// is what makes it safe to repeat a satellite across groups.
function createSatelliteTable() {
const byNoradId = new Map();
return {
add(noradId, fields, origin) {
const existing = byNoradId.get(noradId);
if (existing === undefined) {
byNoradId.set(noradId, { noradId, metadata: { ...fields.metadata }, origins: [origin], name: fields.name, decayed: fields.decayed });
return;
}
for (const [key, value] of Object.entries(fields.metadata)) {
const previous = existing.metadata[key];
if (previous !== undefined && previous !== value) {
throw new Error(
`conflicting metadata for noradId ${noradId}: ${origin} sets ${key}=${JSON.stringify(value)}, ` +
`but ${existing.origins.join(", ")} set ${key}=${JSON.stringify(previous)}`,
);
}
existing.metadata[key] = value;
}
existing.name ??= fields.name;
existing.decayed ||= fields.decayed;
existing.origins.push(origin);
},
// Strip the bookkeeping (`origins`) that only the merge needed, and drop
// absent optional keys so the generated JSON stays free of nulls.
entries() {
const out = [];
for (const { noradId, name, decayed, metadata } of byNoradId.values()) {
const entry = { noradId };
if (name !== undefined) {
entry.name = name;
}
if (decayed) {
entry.decayed = true;
}
entry.metadata = metadata;
out.push(entry);
}
return out.toSorted((a, b) => a.noradId - b.noradId);
},
};
}
function main() {
const configs = [
{ path: coreConfigPath, config: loadConfig(coreConfigPath) },
...discoverPluginConfigs().map((configPath) => ({ path: configPath, config: loadConfig(configPath) })),
];
const groups = configs.flatMap(({ config }) => config.groups);
validate(groups);
const table = createSatelliteTable();
for (const { path: configPath, config } of configs) {
const source = path.relative(repoRoot, configPath);
validateSatelliteTable(config.satellites, source);
for (const entry of config.satellites) {
table.add(entry.noradId, { metadata: metadataFields(entry), name: entry.name, decayed: entry.decayed }, `${source} satellites`);
}
}
// Group rows contribute after every table. Order only decides which origin is
// named first in a conflict message — it does NOT establish precedence: two
// places giving one satellite different values for a field is a build failure,
// because whichever won would depend on config discovery order.
for (const group of groups) {
for (const row of group.satellites ?? []) {
if (row.metadata !== undefined) {
table.add(row.noradId, { metadata: row.metadata, name: row.name, decayed: row.decayed }, `group ${JSON.stringify(group.name)}`);
}
}
}
const satellites = table.entries();
const generated = { groups, satellites };
fs.writeFileSync(outPath, `${JSON.stringify(generated, null, 2)}\n`);
process.stdout.write(`Wrote ${path.relative(repoRoot, outPath)} (${groups.length} groups, ${satellites.length} satellites)\n`);
}
main();