diff --git a/CHANGELOG.md b/CHANGELOG.md index 074c6f754..e2dc1ec2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- FIRMS records a source as successful only after appending its rows, avoiding + contradictory success/failure status if aggregation throws. - Radio country filtering and voice country requests now resolve common English names and exonyms that `Intl.DisplayNames`' primary label omits, so requests like "play radio in Turkey" no longer fail closed (Turkey → Türkiye, plus diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e85c38db0..b70fdf86a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -10,6 +10,13 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n Updated: August 24, 2026 +## FIRMS source status + +The FIRMS proxy records source success after appending its rows. If aggregation +throws, that source reports failure without a contradictory success entry. +The existing per-record append continues to support large feeds; sequential +fetching, trailing-24-hour filtering and partial-success caching are unchanged. + ## Installations and map-source guidance - On an uncached Overpass failure, mapped installations keep their existing diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs new file mode 100644 index 000000000..5de363090 --- /dev/null +++ b/src/data/firmsProxy.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js'; + +const config = fs.readFileSync(new URL('../../vite.config.js', import.meta.url), 'utf8'); +const start = config.indexOf(' async function refreshUpstream(key) {'); +assert.notEqual(start, -1, 'FIRMS refresh function exists'); +const end = config.indexOf('\n }', start); +assert.notEqual(end, -1, 'FIRMS refresh function closes'); +const refreshSource = config.slice(start, end + 4); +const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT']; +const NOW = Date.UTC(2026, 8, 11, 12); +const recent = { acqDate: '2026-09-11', acqTime: '1100' }; + +// Exercise the production refresh without opening a server or using a MAP_KEY. +// Inject only its upstream, clock and filter dependencies; keep its aggregation +// and source-status code intact, including failures while consuming records. +function createRefresh(fetchSource, filter = filterTrailing24h) { + return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console', + `return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} }); +} + +test('FIRMS retains large sources in order and filters expired rows', async () => { + const header = 'latitude,longitude,acq_date,acq_time,confidence,frp\n'; + const large = parseFirmsCsv(header + '1,2,2026-09-11,1100,n,4\n'.repeat(200_000)); + const expired = { ...recent, acqDate: '2026-09-09' }; + const last = { ...recent, marker: 'last' }; + const calls = []; + let active = 0; + const refresh = createRefresh(async (key, source) => { + assert.equal(active++, 0, 'sources must be fetched sequentially'); + calls.push(source); + await Promise.resolve(); + active--; + return source === SOURCES[0] ? [...large, expired] : source === SOURCES[1] ? [last] : []; + }); + const result = await refresh('fixture'); + assert.deepEqual(calls, SOURCES); + assert.equal(result.fires.length, 200_001); + assert.equal(result.fires[0], large[0]); + assert.equal(result.fires[199_999], large.at(-1)); + assert.equal(result.fires.at(-1), last); + assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ + source, count: [200_000, 1, 0][index], ok: true, + }))); +}); + +test('FIRMS keeps successful sources when another upstream fails', async () => { + const result = await createRefresh(async (key, source) => { + if (source === SOURCES[1]) throw new Error('upstream unavailable'); + return [recent]; + })('fixture'); + assert.equal(result.fires.length, 2); + assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ + source, count: index === 1 ? 0 : 1, ok: index !== 1, + }))); +}); + +test('FIRMS reports one failure if consuming a source throws before append', async () => { + const failing = []; + const result = await createRefresh(async (key, source) => source === SOURCES[0] ? failing : [recent], + (records, now) => { + if (records !== failing) return filterTrailing24h(records, now); + // Fault injection for aggregation; ordinary parsed CSV returns an array. + return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } }; + })('fixture'); + assert.equal(result.fires.length, 2); + assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ + source, count: index === 0 ? 0 : 1, ok: index !== 0, + }))); +}); + +test('FIRMS distinguishes all-source failure from successful empty sources', async () => { + await assert.rejects(createRefresh(async () => { throw new Error('upstream unavailable'); })('fixture'), + /all FIRMS sources failed/); + const result = await createRefresh(async () => [])('fixture'); + assert.deepEqual(result.fires, []); + assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true }))); +}); diff --git a/vite.config.js b/vite.config.js index 34dac5291..0ad4f2c37 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2094,11 +2094,11 @@ function firmsProxy() { for (const source of SOURCES) { try { const records = filterTrailing24h(await fetchSource(key, source), now); - sources.push({ source, count: records.length, ok: true }); // NOT fires.push(...records): spread passes each record as an argument, // and a world/2 VIIRS pull exceeds V8's argument limit (~125k) at // ~131k records — RangeError, and the whole source is silently dropped. for (const record of records) fires.push(record); + sources.push({ source, count: records.length, ok: true }); } catch (err) { console.warn(`[firms-proxy] ${source} fetch failed:`, err?.message || err); sources.push({ source, count: 0, ok: false });