SPB Git

spb/earth-now Public License

earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.

TypeScript 93% Shell 2.3% SQL 1.4% JavaScript 1.3% Dockerfile 1.2% CSS 0.8%
5.7 KB · 172 lines typescript
Raw Blame History
1/**2 * earth-now.co3 * Author:  Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File:    apps/api/src/store.ts6 * Purpose: In-memory model store — boots from registry+fixtures, tracks blocked/stale metrics, emits "model" events for SSE7 */89import { EventEmitter } from "node:events";10import type { CounterModel, DisplayHints } from "@earth-now/counter";11import type { MetricConstraints, ValidationIssue } from "@earth-now/models";12import {13  type MetricEntry,14  type Registry,15  fitAllModels,16  loadFixtures,17  loadRegistry,18} from "@earth-now/registry";1920export interface BlockedMetric {21  metricId: string;22  issues: ValidationIssue[];23}2425export interface QuakesSnapshot {26  count24h: number;27  lastMajor: { mag: number; place: string; timeIso: string } | null;28  fetchedAt: string;29}3031export type ModelListener = (model: CounterModel) => void;3233/** Registry display block → runtime DisplayHints (same mapping the fitter uses). */34export function displayHintsOf(entry: MetricEntry, locale: "fr" | "en" = "en"): DisplayHints {35  const hints: DisplayHints = {36    decimals: entry.display.decimals,37    unit: entry.display.unit[locale],38  };39  if (entry.display.sigFigs !== undefined) hints.sigFigs = entry.display.sigFigs;40  if (entry.display.scale !== undefined) hints.scale = entry.display.scale;41  return hints;42}4344/** Registry constraints block → validation MetricConstraints. */45export function metricConstraints(entry: MetricEntry): MetricConstraints {46  const c: MetricConstraints = {47    kind: entry.kind === "cumulative" ? "cumulative" : "stock",48  };49  if (entry.constraints.maxAbsRatePerSec !== undefined)50    c.maxAbsRatePerSec = entry.constraints.maxAbsRatePerSec;51  if (entry.constraints.maxJumpOnRefit !== undefined)52    c.maxJumpOnRefit = entry.constraints.maxJumpOnRefit;53  return c;54}5556/** Fit/validation horizon: Jan 1 of the current UTC year → Dec 31 of the next year. */57export function fitHorizon(nowMs: number): { fromMs: number; toMs: number } {58  const year = new Date(nowMs).getUTCFullYear();59  return { fromMs: Date.UTC(year, 0, 1), toMs: Date.UTC(year + 2, 0, 1) };60}6162/**63 * Holds the currently deployable CounterModel of every metric. The API serves64 * models, never per-tick values; SSE subscribers listen to the "model" event65 * and only receive a payload when a NEW model is deployed.66 */67export class ModelStore extends EventEmitter {68  private readonly modelMap = new Map<string, CounterModel>();69  private blockedList: BlockedMetric[] = [];70  private readonly staleSinceMap = new Map<string, string>();71  private loadedRegistry: Registry | null = null;72  private quakesSnapshot: QuakesSnapshot = {73    count24h: 0,74    lastMajor: null,75    fetchedAt: new Date(0).toISOString(),76  };7778  /** Load registry + fixtures and fit every model. Blocked metrics are served as stale. */79  boot(nowMs: number = Date.now()): void {80    const registry = loadRegistry();81    const fixtures = loadFixtures();82    const { fromMs, toMs } = fitHorizon(nowMs);83    const { models, blocked } = fitAllModels(registry.metrics, fixtures, {84      validateFromMs: fromMs,85      validateToMs: toMs,86    });8788    this.loadedRegistry = registry;89    this.modelMap.clear();90    for (const [id, model] of models) this.modelMap.set(id, model);91    this.blockedList = blocked;9293    // Loud by design: a blocked metric must never fail silently (stale badge instead).94    for (const b of blocked) {95      console.error(`[store] BLOCKED metric '${b.metricId}' — no model served (stale):`);96      for (const issue of b.issues) console.error(`[store]   ${issue.code}: ${issue.message}`);97    }9899    // Fixture-backed defaults for /v1/rt/quakes until the USGS poller first succeeds.100    const quakesModel = models.get("earthquakes_24h");101    if (quakesModel) {102      this.quakesSnapshot = {103        count24h: quakesModel.anchorValue,104        lastMajor: null,105        fetchedAt: quakesModel.observedAt,106      };107    }108  }109110  get registry(): Registry {111    if (!this.loadedRegistry) throw new Error("ModelStore used before boot()");112    return this.loadedRegistry;113  }114115  get models(): ReadonlyMap<string, CounterModel> {116    return this.modelMap;117  }118119  get blocked(): readonly BlockedMetric[] {120    return this.blockedList;121  }122123  get quakes(): QuakesSnapshot {124    return this.quakesSnapshot;125  }126127  getModel(metricId: string): CounterModel | undefined {128    return this.modelMap.get(metricId);129  }130131  isStale(metricId: string): boolean {132    return !this.modelMap.has(metricId) || this.staleSinceMap.has(metricId);133  }134135  staleSince(metricId: string): string | undefined {136    return this.staleSinceMap.get(metricId);137  }138139  /**140   * Swap the model of a true-RT (event) metric and notify SSE subscribers.141   * Event-driven jumps are legitimate (no interpolation, no anti-teleportation142   * diff); every other model family MUST go through fitAllModels + validation.143   */144  updateRtModel(metricId: string, model: CounterModel): void {145    const entry = this.registry.byId.get(metricId);146    if (!entry) throw new Error(`updateRtModel: unknown metric '${metricId}'`);147    if (entry.level !== "rt") {148      throw new Error(`updateRtModel: metric '${metricId}' is not event-driven (level ${entry.level})`);149    }150    this.modelMap.set(metricId, model);151    this.staleSinceMap.delete(metricId);152    this.emit("model", model);153  }154155  /** Record when a metric's ingestion started failing (previous model keeps serving). */156  markStale(metricId: string, atIso: string = new Date().toISOString()): void {157    if (!this.staleSinceMap.has(metricId)) this.staleSinceMap.set(metricId, atIso);158  }159160  setQuakesSnapshot(snapshot: QuakesSnapshot): void {161    this.quakesSnapshot = snapshot;162  }163164  onModel(listener: ModelListener): void {165    this.on("model", listener);166  }167168  offModel(listener: ModelListener): void {169    this.off("model", listener);170  }171}172