recallnest 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * GB-3: Source Heartbeat Tracking
3
+ *
4
+ * Every connector ingest writes a heartbeat record to data/source-heartbeat.json.
5
+ * data_checkup and doctor --ci read heartbeats to detect stale data sources.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { resolve, dirname } from "node:path";
10
+ import { metaDir } from "./compat.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface SourceHeartbeat {
17
+ source: string;
18
+ lastIngest: string; // ISO 8601
19
+ recordsIngested: number;
20
+ errors: string[];
21
+ }
22
+
23
+ export type HeartbeatFile = Record<string, SourceHeartbeat>;
24
+
25
+ export interface StaleSource {
26
+ source: string;
27
+ lastIngest: string;
28
+ daysSince: number;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Path
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const HEARTBEAT_PATH = resolve(metaDir(import.meta), "../data/source-heartbeat.json");
36
+
37
+ /** Visible for testing — override the default path. */
38
+ export function heartbeatPath(): string {
39
+ return HEARTBEAT_PATH;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Read / Write
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export function readHeartbeats(path?: string): HeartbeatFile {
47
+ const p = path ?? HEARTBEAT_PATH;
48
+ if (!existsSync(p)) return {};
49
+ try {
50
+ return JSON.parse(readFileSync(p, "utf-8")) as HeartbeatFile;
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ export function writeHeartbeat(
57
+ source: string,
58
+ recordsIngested: number,
59
+ errors: string[],
60
+ path?: string,
61
+ ): void {
62
+ const p = path ?? HEARTBEAT_PATH;
63
+ const dir = dirname(p);
64
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
65
+
66
+ const existing = readHeartbeats(p);
67
+ existing[source] = {
68
+ source,
69
+ lastIngest: new Date().toISOString(),
70
+ recordsIngested,
71
+ errors: errors.slice(0, 10), // cap stored errors
72
+ };
73
+ writeFileSync(p, JSON.stringify(existing, null, 2) + "\n");
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Staleness check
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const MS_PER_DAY = 86_400_000;
81
+
82
+ export function checkSourceStaleness(maxDays = 7, path?: string): StaleSource[] {
83
+ const heartbeats = readHeartbeats(path);
84
+ const now = Date.now();
85
+ const stale: StaleSource[] = [];
86
+
87
+ for (const hb of Object.values(heartbeats)) {
88
+ const lastMs = Date.parse(hb.lastIngest);
89
+ if (Number.isNaN(lastMs)) continue;
90
+ const daysSince = Math.floor((now - lastMs) / MS_PER_DAY);
91
+ if (daysSince > maxDays) {
92
+ stale.push({ source: hb.source, lastIngest: hb.lastIngest, daysSince });
93
+ }
94
+ }
95
+
96
+ return stale.sort((a, b) => b.daysSince - a.daysSince);
97
+ }
98
+
99
+ /** Human-readable age string, e.g. "2h ago", "3d ago". */
100
+ export function formatAge(isoDate: string): string {
101
+ const ms = Date.now() - Date.parse(isoDate);
102
+ if (ms < 0) return "just now";
103
+ const hours = Math.floor(ms / 3_600_000);
104
+ if (hours < 1) return "just now";
105
+ if (hours < 24) return `${hours}h ago`;
106
+ const days = Math.floor(hours / 24);
107
+ return `${days}d ago`;
108
+ }