/** * Shared scalar guards for the analysis adapters (#5696). * * Both adapter modules map untrusted seeded-cache JSON onto the analysis * cores' input types, so both need the same defensive primitives. They were * written independently and grew two names for each concept * (`finiteNumber`/`num`, `nonEmptyString`/`str`); one definition removes the * drift risk, since a divergence in null-handling here silently empties a * result set rather than raising. * * Semantics are deliberately permissive on INPUT (a seeder that stores a * number as a string still parses) and strict on VALIDATION (a coordinate * must be real and in range). * * Dependency-free: importable from Vite client code, Vercel Edge bundles, * server handlers, and tsx tests alike. */ /** Narrow to a plain object; arrays and null are rejected. */ export function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null; } /** Always yields an iterable — a non-array becomes empty rather than throwing. */ export function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } /** Finite number, accepting a numeric string; anything else is null. */ export function finiteNumber(value: unknown): number | null { if (typeof value === 'number') return Number.isFinite(value) ? value : null; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } /** Trimmed string, or '' for anything non-string or whitespace-only. */ export function nonEmptyString(value: unknown): string { return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''; } /** * A coordinate pair usable as a real position: both components present, in * range, and not null island — (0, 0) is overwhelmingly a missing-data * artifact in these feeds rather than a location in the Gulf of Guinea. * * Note the exposure adapters deliberately do NOT apply this: they enrich * whatever the caller asks about, and rejecting a point there would silently * drop an event rather than decline to place it on a grid. */ export function usableCoord(lat: number | null, lon: number | null): lat is number { return ( lat !== null && lon !== null && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 && !(lat === 0 && lon === 0) ); } /** Read a nested `{ location: { latitude, longitude } }` pair. */ export function nestedLocation(record: Record): { lat: number | null; lon: number | null } { const location = asRecord(record.location); return { lat: finiteNumber(location?.latitude), lon: finiteNumber(location?.longitude), }; }