// lib/refresh.ts — fetch + cache orchestration for sources. // A source's latest Snapshot is the cache. We refresh when forced or stale. import type { SourceRow, SnapshotRow } from "@/db/schema"; import type { SourceKind } from "@/lib/schemas/source"; import { getSourceModule, isSourceConfigured } from "@/lib/sources/registry"; import { insertSnapshot, latestSnapshot } from "@/lib/store"; const FETCH_TIMEOUT_MS = 15_000; export interface SourceState { source: SourceRow; hasModule: boolean; configured: boolean; snapshot: SnapshotRow | null; } function isStale(snapshot: SnapshotRow | null, refreshSeconds: number): boolean { if (!snapshot) return true; const age = (Date.now() - new Date(snapshot.fetchedAt).getTime()) / 1000; return age >= refreshSeconds; } // Run the source's fetcher once and store the result as a snapshot. export async function refreshSource(source: SourceRow): Promise { const mod = getSourceModule(source.kind as SourceKind); if (!mod) { return insertSnapshot({ sourceId: source.id, payload: null, ok: false, error: `No fetcher for "${source.kind}"`, }); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const config = mod.configSchema.parse(source.config ?? {}); const payload = await mod.fetch({ config, env: process.env, signal: controller.signal }); const validated = mod.payloadSchema.parse(payload); return insertSnapshot({ sourceId: source.id, payload: validated, ok: true, error: null }); } catch (err) { const message = err instanceof Error ? err.message : "Fetch failed"; return insertSnapshot({ sourceId: source.id, payload: null, ok: false, error: message }); } finally { clearTimeout(timer); } } // Cached state only — no network. Used for instant dashboard paint. export function currentState(source: SourceRow): SourceState { return { source, hasModule: Boolean(getSourceModule(source.kind as SourceKind)), configured: isSourceConfigured(source.kind as SourceKind, source.config), snapshot: latestSnapshot(source.id) ?? null, }; } export function isSourceStale(source: SourceRow): boolean { return isStale(latestSnapshot(source.id) ?? null, source.refreshSeconds); } // Return current state, refreshing first when forced or stale (and refreshable). export async function ensureFresh( source: SourceRow, opts: { force?: boolean } = {}, ): Promise { const mod = getSourceModule(source.kind as SourceKind); const hasModule = Boolean(mod); const configured = isSourceConfigured(source.kind as SourceKind, source.config); let snapshot = latestSnapshot(source.id) ?? null; const refreshable = hasModule && configured && source.enabled; if (refreshable && (opts.force || isStale(snapshot, source.refreshSeconds))) { snapshot = await refreshSource(source); } return { source, hasModule, configured, snapshot }; }