/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/api/src/store.ts * Purpose: In-memory model store — boots from registry+fixtures, tracks blocked/stale metrics, emits "model" events for SSE */ import { EventEmitter } from "node:events"; import type { CounterModel, DisplayHints } from "@earth-now/counter"; import type { MetricConstraints, ValidationIssue } from "@earth-now/models"; import { type MetricEntry, type Registry, fitAllModels, loadFixtures, loadRegistry, } from "@earth-now/registry"; export interface BlockedMetric { metricId: string; issues: ValidationIssue[]; } export interface QuakesSnapshot { count24h: number; lastMajor: { mag: number; place: string; timeIso: string } | null; fetchedAt: string; } export type ModelListener = (model: CounterModel) => void; /** Registry display block → runtime DisplayHints (same mapping the fitter uses). */ export function displayHintsOf(entry: MetricEntry, locale: "fr" | "en" = "en"): DisplayHints { const hints: DisplayHints = { decimals: entry.display.decimals, unit: entry.display.unit[locale], }; if (entry.display.sigFigs !== undefined) hints.sigFigs = entry.display.sigFigs; if (entry.display.scale !== undefined) hints.scale = entry.display.scale; return hints; } /** Registry constraints block → validation MetricConstraints. */ export function metricConstraints(entry: MetricEntry): MetricConstraints { const c: MetricConstraints = { kind: entry.kind === "cumulative" ? "cumulative" : "stock", }; if (entry.constraints.maxAbsRatePerSec !== undefined) c.maxAbsRatePerSec = entry.constraints.maxAbsRatePerSec; if (entry.constraints.maxJumpOnRefit !== undefined) c.maxJumpOnRefit = entry.constraints.maxJumpOnRefit; return c; } /** Fit/validation horizon: Jan 1 of the current UTC year → Dec 31 of the next year. */ export function fitHorizon(nowMs: number): { fromMs: number; toMs: number } { const year = new Date(nowMs).getUTCFullYear(); return { fromMs: Date.UTC(year, 0, 1), toMs: Date.UTC(year + 2, 0, 1) }; } /** * Holds the currently deployable CounterModel of every metric. The API serves * models, never per-tick values; SSE subscribers listen to the "model" event * and only receive a payload when a NEW model is deployed. */ export class ModelStore extends EventEmitter { private readonly modelMap = new Map(); private blockedList: BlockedMetric[] = []; private readonly staleSinceMap = new Map(); private loadedRegistry: Registry | null = null; private quakesSnapshot: QuakesSnapshot = { count24h: 0, lastMajor: null, fetchedAt: new Date(0).toISOString(), }; /** Load registry + fixtures and fit every model. Blocked metrics are served as stale. */ boot(nowMs: number = Date.now()): void { const registry = loadRegistry(); const fixtures = loadFixtures(); const { fromMs, toMs } = fitHorizon(nowMs); const { models, blocked } = fitAllModels(registry.metrics, fixtures, { validateFromMs: fromMs, validateToMs: toMs, }); this.loadedRegistry = registry; this.modelMap.clear(); for (const [id, model] of models) this.modelMap.set(id, model); this.blockedList = blocked; // Loud by design: a blocked metric must never fail silently (stale badge instead). for (const b of blocked) { console.error(`[store] BLOCKED metric '${b.metricId}' — no model served (stale):`); for (const issue of b.issues) console.error(`[store] ${issue.code}: ${issue.message}`); } // Fixture-backed defaults for /v1/rt/quakes until the USGS poller first succeeds. const quakesModel = models.get("earthquakes_24h"); if (quakesModel) { this.quakesSnapshot = { count24h: quakesModel.anchorValue, lastMajor: null, fetchedAt: quakesModel.observedAt, }; } } get registry(): Registry { if (!this.loadedRegistry) throw new Error("ModelStore used before boot()"); return this.loadedRegistry; } get models(): ReadonlyMap { return this.modelMap; } get blocked(): readonly BlockedMetric[] { return this.blockedList; } get quakes(): QuakesSnapshot { return this.quakesSnapshot; } getModel(metricId: string): CounterModel | undefined { return this.modelMap.get(metricId); } isStale(metricId: string): boolean { return !this.modelMap.has(metricId) || this.staleSinceMap.has(metricId); } staleSince(metricId: string): string | undefined { return this.staleSinceMap.get(metricId); } /** * Swap the model of a true-RT (event) metric and notify SSE subscribers. * Event-driven jumps are legitimate (no interpolation, no anti-teleportation * diff); every other model family MUST go through fitAllModels + validation. */ updateRtModel(metricId: string, model: CounterModel): void { const entry = this.registry.byId.get(metricId); if (!entry) throw new Error(`updateRtModel: unknown metric '${metricId}'`); if (entry.level !== "rt") { throw new Error(`updateRtModel: metric '${metricId}' is not event-driven (level ${entry.level})`); } this.modelMap.set(metricId, model); this.staleSinceMap.delete(metricId); this.emit("model", model); } /** Record when a metric's ingestion started failing (previous model keeps serving). */ markStale(metricId: string, atIso: string = new Date().toISOString()): void { if (!this.staleSinceMap.has(metricId)) this.staleSinceMap.set(metricId, atIso); } setQuakesSnapshot(snapshot: QuakesSnapshot): void { this.quakesSnapshot = snapshot; } onModel(listener: ModelListener): void { this.on("model", listener); } offModel(listener: ModelListener): void { this.off("model", listener); } }