mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(economy): GSCPI shape mismatch with ais-relay payload (#3072)
* fix(economy): GSCPI shape mismatch with ais-relay payload
`seed-economy.mjs` was reporting `[StressIndex] GSCPI not in Redis yet
(ais-relay lag or first run) — excluding` even when GSCPI was current
in Redis. The Stress Index then computed on 5/6 components instead
of 6/6 every run.
Root cause: shape mismatch.
- ais-relay.cjs (`seedGscpi()`) writes the FRED-compatible payload
`{ series: { series_id, title, units, frequency, observations: [...] } }`
- seed-economy.mjs `fetchGscpiFromRedis()` was reading the legacy
flat shape `{ observations: [...] }` (top-level), so
`Array.isArray(parsed.observations)` was always false → null returned
→ "not in Redis yet" log, even though 343 monthly observations were
sitting in `economic:fred:v1:GSCPI:0`
Fix: extract the parsing into `extractGscpiObservations()` which checks
both shapes (`parsed.series.observations` first, then top-level
`parsed.observations` for back-compat). The "not in Redis yet" message
will now correctly fire only when the relay is genuinely behind.
Verified against live Redis: returns `{ observations: 343 entries,
latest 2026-03-01 = 0.68 }` instead of null.
Tests added in tests/gscpi-shape-extraction.test.mjs (3 cases:
ais-relay shape, legacy flat shape, malformed payload).
* style(economy): single @type cast in extractGscpiObservations
PR #3072 review (P2): cast `parsed` to `any` once into a local instead
of repeating the inline `/** @type {any} */` annotation on every access.
Same behavior, less visual noise.
This commit is contained in:
@@ -59,9 +59,23 @@ function stressLabel(score) {
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract GSCPI observations from the Redis-stored payload.
|
||||
* ais-relay writes the FRED-compatible shape `{ series: { series_id, title, units,
|
||||
* frequency, observations: [{ date, value }] } }` (see seedGscpi() in ais-relay.cjs).
|
||||
* Earlier versions stored a flat `{ observations }` shape, so accept both.
|
||||
* Exported for unit testing.
|
||||
* @param {unknown} parsed
|
||||
* @returns {{ observations: { date: string; value: number }[] } | null}
|
||||
*/
|
||||
export function extractGscpiObservations(parsed) {
|
||||
const p = /** @type {any} */ (parsed);
|
||||
const obs = p?.series?.observations ?? p?.observations;
|
||||
return Array.isArray(obs) ? { observations: obs } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read GSCPI from Redis (seeded by ais-relay from NY Fed, not available via FRED API).
|
||||
* Format stored: { observations: [{ date, value }] } — no series wrapper.
|
||||
* @returns {Promise<{ observations: { date: string; value: number }[] } | null>}
|
||||
*/
|
||||
async function fetchGscpiFromRedis() {
|
||||
@@ -74,8 +88,7 @@ async function fetchGscpiFromRedis() {
|
||||
if (!resp.ok) return null;
|
||||
const body = /** @type {{ result: string | null }} */ (await resp.json());
|
||||
if (!body.result) return null;
|
||||
const parsed = JSON.parse(body.result);
|
||||
return Array.isArray(parsed.observations) ? parsed : null;
|
||||
return extractGscpiObservations(JSON.parse(body.result));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
47
tests/gscpi-shape-extraction.test.mjs
Normal file
47
tests/gscpi-shape-extraction.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { extractGscpiObservations } from '../scripts/seed-economy.mjs';
|
||||
|
||||
describe('extractGscpiObservations', () => {
|
||||
it('reads the ais-relay FRED-compatible shape (observations under .series)', () => {
|
||||
// This is the actual shape ais-relay.cjs writes — see seedGscpi() in that file.
|
||||
const parsed = {
|
||||
series: {
|
||||
series_id: 'GSCPI',
|
||||
title: 'Global Supply Chain Pressure Index',
|
||||
units: 'Standard Deviations',
|
||||
frequency: 'Monthly',
|
||||
observations: [
|
||||
{ date: '2026-02-01', value: 0.42 },
|
||||
{ date: '2026-03-01', value: 0.68 },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = extractGscpiObservations(parsed);
|
||||
assert.ok(result, 'should return non-null');
|
||||
assert.equal(result.observations.length, 2);
|
||||
assert.equal(result.observations[1].value, 0.68);
|
||||
});
|
||||
|
||||
it('reads the legacy flat shape (top-level observations) for back-compat', () => {
|
||||
// Earlier ais-relay versions stored this shape — keep working if any
|
||||
// long-lived Redis key still has it.
|
||||
const parsed = {
|
||||
observations: [
|
||||
{ date: '2026-03-01', value: 0.68 },
|
||||
],
|
||||
};
|
||||
const result = extractGscpiObservations(parsed);
|
||||
assert.ok(result, 'should return non-null');
|
||||
assert.equal(result.observations.length, 1);
|
||||
});
|
||||
|
||||
it('returns null when neither shape is present', () => {
|
||||
assert.equal(extractGscpiObservations(null), null);
|
||||
assert.equal(extractGscpiObservations({}), null);
|
||||
assert.equal(extractGscpiObservations({ series: {} }), null);
|
||||
assert.equal(extractGscpiObservations({ observations: 'not-an-array' }), null);
|
||||
assert.equal(extractGscpiObservations({ series: { observations: 'nope' } }), null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user