diff --git a/api/bootstrap.js b/api/bootstrap.js index 5a60b1d23..62d0e219a 100644 --- a/api/bootstrap.js +++ b/api/bootstrap.js @@ -19,7 +19,19 @@ const BOOTSTRAP_CACHE_KEYS = { giving: 'giving:summary:v1', climateAnomalies: 'climate:anomalies:v1', wildfires: 'wildfire:fires:v1', - ucdpEvents: 'conflict:ucdp-events:v1', +}; + +const SLOW_KEYS = new Set([ + 'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving', + 'sectors', 'etfFlows', 'shippingRates', 'wildfires', 'climateAnomalies', +]); +const FAST_KEYS = new Set([ + 'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints', +]); + +const TIER_CACHE = { + slow: 'public, s-maxage=3600, stale-while-revalidate=600, stale-if-error=3600', + fast: 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900', }; const NEG_SENTINEL = '__WM_NEG__'; @@ -72,10 +84,17 @@ export default async function handler(req) { }); const url = new URL(req.url); - const requested = url.searchParams.get('keys')?.split(',').filter(Boolean); - const registry = requested - ? Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => requested.includes(k))) - : BOOTSTRAP_CACHE_KEYS; + const tier = url.searchParams.get('tier'); + let registry; + if (tier === 'slow' || tier === 'fast') { + const tierSet = tier === 'slow' ? SLOW_KEYS : FAST_KEYS; + registry = Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => tierSet.has(k))); + } else { + const requested = url.searchParams.get('keys')?.split(',').filter(Boolean).sort(); + registry = requested + ? Object.fromEntries(Object.entries(BOOTSTRAP_CACHE_KEYS).filter(([k]) => requested.includes(k))) + : BOOTSTRAP_CACHE_KEYS; + } const keys = Object.values(registry); const names = Object.keys(registry); @@ -98,12 +117,14 @@ export default async function handler(req) { else missing.push(names[i]); } + const cacheControl = (tier && TIER_CACHE[tier]) || 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900'; + return new Response(JSON.stringify({ data, missing }), { status: 200, headers: { ...cors, 'Content-Type': 'application/json', - 'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900', + 'Cache-Control': cacheControl, }, }); } diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index ff502351f..1b874d21f 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -19,3 +19,12 @@ export const BOOTSTRAP_CACHE_KEYS: Record = { climateAnomalies: 'climate:anomalies:v1', wildfires: 'wildfire:fires:v1', }; + +export const BOOTSTRAP_TIERS: Record = { + bisPolicy: 'slow', bisExchange: 'slow', bisCredit: 'slow', + minerals: 'slow', giving: 'slow', sectors: 'slow', + etfFlows: 'slow', shippingRates: 'slow', wildfires: 'slow', + climateAnomalies: 'slow', + earthquakes: 'fast', outages: 'fast', serviceStatuses: 'fast', + macroSignals: 'fast', chokepoints: 'fast', +}; diff --git a/src/services/bootstrap.ts b/src/services/bootstrap.ts index f834b24ce..2a75d9bb6 100644 --- a/src/services/bootstrap.ts +++ b/src/services/bootstrap.ts @@ -6,19 +6,34 @@ export function getHydratedData(key: string): unknown | undefined { return val; } -export async function fetchBootstrapData(): Promise { +function populateCache(data: Record): void { + for (const [k, v] of Object.entries(data)) { + if (v !== null && v !== undefined) { + hydrationCache.set(k, v); + } + } +} + +async function fetchTier(tier: string, signal: AbortSignal): Promise { try { - const resp = await fetch('/api/bootstrap', { - signal: AbortSignal.timeout(800), - }); + const resp = await fetch(`/api/bootstrap?tier=${tier}`, { signal }); if (!resp.ok) return; const { data } = (await resp.json()) as { data: Record }; - for (const [k, v] of Object.entries(data)) { - if (v !== null && v !== undefined) { - hydrationCache.set(k, v); - } - } + populateCache(data); } catch { // silent — panels fall through to individual calls } } + +export async function fetchBootstrapData(): Promise { + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), 800); + try { + await Promise.all([ + fetchTier('slow', ctrl.signal), + fetchTier('fast', ctrl.signal), + ]); + } finally { + clearTimeout(timeout); + } +} diff --git a/tests/bootstrap.test.mjs b/tests/bootstrap.test.mjs index 984f26372..bd87abc20 100644 --- a/tests/bootstrap.test.mjs +++ b/tests/bootstrap.test.mjs @@ -12,17 +12,21 @@ describe('Bootstrap cache key registry', () => { const cacheKeysSrc = readFileSync(cacheKeysPath, 'utf-8'); const bootstrapSrc = readFileSync(join(root, 'api', 'bootstrap.js'), 'utf-8'); + const cacheKeysBlock = cacheKeysSrc.match(/BOOTSTRAP_CACHE_KEYS[^{]*\{([^}]+)\}/)?.[1] ?? ''; + it('exports BOOTSTRAP_CACHE_KEYS with at least 10 entries', () => { - const matches = cacheKeysSrc.match(/^\s+\w+:\s+'[^']+'/gm); + const matches = cacheKeysBlock.match(/^\s+\w+:\s+'[^']+'/gm); assert.ok(matches && matches.length >= 10, `Expected ≥10 keys, found ${matches?.length ?? 0}`); }); it('api/bootstrap.js inlined keys match server/_shared/cache-keys.ts', () => { const extractKeys = (src) => { + const block = src.match(/BOOTSTRAP_CACHE_KEYS[^=]*=\s*\{([^}]+)\}/); + if (!block) return {}; const re = /(\w+):\s+'([a-z_]+(?::[a-z_-]+)+:v\d+)'/g; const keys = {}; let m; - while ((m = re.exec(src)) !== null) keys[m[1]] = m[2]; + while ((m = re.exec(block[1])) !== null) keys[m[1]] = m[2]; return keys; }; const canonical = extractKeys(cacheKeysSrc); @@ -40,7 +44,7 @@ describe('Bootstrap cache key registry', () => { const keyRe = /:\s+'([^']+)'/g; let m; const keys = []; - while ((m = keyRe.exec(cacheKeysSrc)) !== null) { + while ((m = keyRe.exec(cacheKeysBlock)) !== null) { keys.push(m[1]); } for (const key of keys) { @@ -52,7 +56,7 @@ describe('Bootstrap cache key registry', () => { const keyRe = /:\s+'([^']+)'/g; let m; const keys = []; - while ((m = keyRe.exec(cacheKeysSrc)) !== null) { + while ((m = keyRe.exec(cacheKeysBlock)) !== null) { keys.push(m[1]); } const unique = new Set(keys); @@ -63,7 +67,7 @@ describe('Bootstrap cache key registry', () => { const nameRe = /^\s+(\w+):/gm; let m; const names = []; - while ((m = nameRe.exec(cacheKeysSrc)) !== null) { + while ((m = nameRe.exec(cacheKeysBlock)) !== null) { names.push(m[1]); } const unique = new Set(names); @@ -71,10 +75,11 @@ describe('Bootstrap cache key registry', () => { }); it('every cache key maps to a handler file with a matching cache key string', () => { + const block = cacheKeysSrc.match(/BOOTSTRAP_CACHE_KEYS[^{]*\{([^}]+)\}/); const keyRe = /:\s+'([^']+)'/g; let m; const keys = []; - while ((m = keyRe.exec(cacheKeysSrc)) !== null) { + while ((m = keyRe.exec(block[1])) !== null) { keys.push(m[1]); } @@ -127,8 +132,9 @@ describe('Bootstrap endpoint (api/bootstrap.js)', () => { assert.ok(src.includes('missing'), 'Missing missing field in response'); }); - it('sets Cache-Control header with s-maxage', () => { - assert.ok(src.includes('s-maxage=60'), 'Missing s-maxage=60 Cache-Control'); + it('sets Cache-Control header with s-maxage for both tiers', () => { + assert.ok(src.includes('s-maxage=3600'), 'Missing s-maxage=3600 for slow tier'); + assert.ok(src.includes('s-maxage=600'), 'Missing s-maxage=600 for fast tier'); assert.ok(src.includes('stale-while-revalidate'), 'Missing stale-while-revalidate'); }); @@ -140,6 +146,13 @@ describe('Bootstrap endpoint (api/bootstrap.js)', () => { assert.ok(src.includes("'OPTIONS'"), 'Missing OPTIONS method handling'); assert.ok(src.includes('getCorsHeaders'), 'Missing CORS headers'); }); + + it('supports ?tier= query param for tiered fetching', () => { + assert.ok(src.includes("'tier'"), 'Missing tier query param handling'); + assert.ok(src.includes('SLOW_KEYS'), 'Missing SLOW_KEYS set'); + assert.ok(src.includes('FAST_KEYS'), 'Missing FAST_KEYS set'); + assert.ok(src.includes('TIER_CACHE'), 'Missing TIER_CACHE map'); + }); }); describe('Frontend hydration (src/services/bootstrap.ts)', () => { @@ -159,19 +172,25 @@ describe('Frontend hydration (src/services/bootstrap.ts)', () => { }); it('has a fast timeout cap to avoid regressing startup', () => { - const timeoutMatch = src.match(/AbortSignal\.timeout\((\d+)\)/); - assert.ok(timeoutMatch, 'Missing AbortSignal.timeout'); + const timeoutMatch = src.match(/(?:AbortSignal\.timeout|setTimeout)\D+(\d+)\)/); + assert.ok(timeoutMatch, 'Missing timeout'); const ms = parseInt(timeoutMatch[1], 10); assert.ok(ms <= 2000, `Timeout ${ms}ms too high — should be ≤2000ms to avoid regressing startup`); }); - it('fetches from /api/bootstrap', () => { - assert.ok(src.includes('/api/bootstrap'), 'Missing /api/bootstrap fetch URL'); + it('fetches tiered bootstrap URLs', () => { + assert.ok(src.includes('/api/bootstrap?tier='), 'Missing tiered bootstrap fetch URLs'); }); it('handles fetch failure silently', () => { assert.ok(src.includes('catch'), 'Missing error handling — panels should fall through to individual calls'); }); + + it('fetches both tiers in parallel', () => { + assert.ok(src.includes('Promise.all'), 'Missing Promise.all for parallel tier fetches'); + assert.ok(src.includes("'slow'"), 'Missing slow tier fetch'); + assert.ok(src.includes("'fast'"), 'Missing fast tier fetch'); + }); }); describe('Panel hydration consumers', () => { @@ -194,12 +213,12 @@ describe('Panel hydration consumers', () => { describe('Bootstrap key hydration coverage', () => { it('every bootstrap key has a getHydratedData consumer in src/', () => { const bootstrapSrc = readFileSync(join(root, 'api', 'bootstrap.js'), 'utf-8'); + const block = bootstrapSrc.match(/BOOTSTRAP_CACHE_KEYS\s*=\s*\{([^}]+)\}/); const keyRe = /(\w+):\s+'[a-z_]+(?::[a-z_-]+)+:v\d+'/g; const keys = []; let m; - while ((m = keyRe.exec(bootstrapSrc)) !== null) keys.push(m[1]); + while ((m = keyRe.exec(block[1])) !== null) keys.push(m[1]); - // Gather all src/ .ts files const srcFiles = []; function walk(dir) { for (const entry of readdirSync(dir)) { @@ -220,6 +239,62 @@ describe('Bootstrap key hydration coverage', () => { }); }); +describe('Bootstrap tier definitions', () => { + const bootstrapSrc = readFileSync(join(root, 'api', 'bootstrap.js'), 'utf-8'); + const cacheKeysSrc = readFileSync(join(root, 'server', '_shared', 'cache-keys.ts'), 'utf-8'); + + function extractSetKeys(src, varName) { + const re = new RegExp(`${varName}\\s*=\\s*new Set\\(\\[([^\\]]+)\\]`, 's'); + const m = src.match(re); + if (!m) return new Set(); + return new Set([...m[1].matchAll(/'(\w+)'/g)].map(x => x[1])); + } + + function extractBootstrapKeys(src) { + const block = src.match(/BOOTSTRAP_CACHE_KEYS\s*=\s*\{([^}]+)\}/); + if (!block) return new Set(); + return new Set([...block[1].matchAll(/(\w+):\s+'/g)].map(x => x[1])); + } + + function extractTierKeys(src) { + const block = src.match(/BOOTSTRAP_TIERS[^{]*\{([^}]+)\}/); + if (!block) return {}; + const result = {}; + for (const m of block[1].matchAll(/(\w+):\s+'(slow|fast)'/g)) { + result[m[1]] = m[2]; + } + return result; + } + + it('SLOW_KEYS + FAST_KEYS cover all BOOTSTRAP_CACHE_KEYS with no overlap', () => { + const slow = extractSetKeys(bootstrapSrc, 'SLOW_KEYS'); + const fast = extractSetKeys(bootstrapSrc, 'FAST_KEYS'); + const all = extractBootstrapKeys(bootstrapSrc); + + const union = new Set([...slow, ...fast]); + assert.deepEqual([...union].sort(), [...all].sort(), 'SLOW_KEYS ∪ FAST_KEYS must equal BOOTSTRAP_CACHE_KEYS'); + + const intersection = [...slow].filter(k => fast.has(k)); + assert.equal(intersection.length, 0, `Overlap between tiers: ${intersection.join(', ')}`); + }); + + it('tier sets in bootstrap.js match BOOTSTRAP_TIERS in cache-keys.ts', () => { + const slow = extractSetKeys(bootstrapSrc, 'SLOW_KEYS'); + const fast = extractSetKeys(bootstrapSrc, 'FAST_KEYS'); + const tiers = extractTierKeys(cacheKeysSrc); + + for (const k of slow) { + assert.equal(tiers[k], 'slow', `SLOW_KEYS has '${k}' but BOOTSTRAP_TIERS says '${tiers[k]}'`); + } + for (const k of fast) { + assert.equal(tiers[k], 'fast', `FAST_KEYS has '${k}' but BOOTSTRAP_TIERS says '${tiers[k]}'`); + } + const tierKeys = new Set(Object.keys(tiers)); + const setKeys = new Set([...slow, ...fast]); + assert.deepEqual([...tierKeys].sort(), [...setKeys].sort(), 'BOOTSTRAP_TIERS keys must match SLOW_KEYS ∪ FAST_KEYS'); + }); +}); + describe('Adaptive backoff adopters', () => { it('ServiceStatusPanel.fetchStatus returns Promise', () => { const src = readFileSync(join(root, 'src/components/ServiceStatusPanel.ts'), 'utf-8');