▍ humdrum codex / soft
2.9 KB raw
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// 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<SnapshotRow> {
  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<SourceState> {
  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 };
}