/** * SQLite-backed cache for rendered `github` issue/PR view output, plus a * generic cache-aware wrapper that the tool ops and the `issue://`/`pr://` * protocol handlers share. * * Storage: * One process-wide connection opens lazily on first hit and stays open. All * helpers swallow open/IO failures and degrade to "no cache" so a corrupt or * unreadable DB never blocks a `gh` call. * * TTL: * Soft TTL → return cached row directly. * Past soft TTL but within hard TTL → return cached row AND schedule a * background refresh (errors logged, never thrown). * Past hard TTL → treat as miss and fetch fresh. */ import { Database } from "bun:sqlite"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { getGithubCacheDbPath, logger } from "@oh-my-pi/pi-utils"; import type { Settings } from "../config/settings"; // ──────────────────────────────────────────────────────────────────────────── // Storage layer // ──────────────────────────────────────────────────────────────────────────── export type CacheKind = "issue" | "pr" | "pr-diff"; const DEFAULT_CACHE_AUTH_KEY = "default"; export interface CachedView { authKey: string; repo: string; kind: CacheKind; number: number; includeComments: boolean; fetchedAt: number; payload: T; rendered: string; sourceUrl: string | undefined; } interface Row { auth_key: string; repo: string; kind: CacheKind; number: number; include_comments: number; fetched_at: number; payload: string; rendered: string; source_url: string | null; } const DEFAULT_SOFT_TTL_SEC = 300; // 5 minutes const DEFAULT_HARD_TTL_SEC = 60 * 60 * 24 * 7; // 7 days let cachedDb: Database | null = null; let openAttempted = false; function ensureParentDir(filePath: string): void { try { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (err) { logger.debug("github cache: failed to create private parent dir", { err: String(err) }); } } function chmodIfExists(filePath: string, mode: number): void { try { fs.chmodSync(filePath, mode); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { logger.debug("github cache: chmod failed", { err: String(err), path: filePath }); } } } function protectDbFiles(dbPath: string): void { chmodIfExists(dbPath, 0o600); chmodIfExists(`${dbPath}-wal`, 0o600); chmodIfExists(`${dbPath}-shm`, 0o600); } export function openDb(): Database | null { if (cachedDb) return cachedDb; if (openAttempted) return null; openAttempted = true; try { const dbPath = getGithubCacheDbPath(); ensureParentDir(dbPath); const db = new Database(dbPath); db.run(` PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; `); // Migrate any pre-existing table whose key/check constraint predates // the current schema. The cache is regenerable, so we drop rows rather // than running an in-place ALTER dance. const userVersion = (db.prepare("PRAGMA user_version").get() as { user_version?: number } | undefined) ?.user_version; if (userVersion !== undefined && userVersion < 3) { db.run("DROP TABLE IF EXISTS github_view_cache"); } db.run(` CREATE TABLE IF NOT EXISTS github_view_cache ( auth_key TEXT NOT NULL, repo TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('issue','pr','pr-diff')), number INTEGER NOT NULL, include_comments INTEGER NOT NULL, fetched_at INTEGER NOT NULL, payload TEXT NOT NULL, rendered TEXT NOT NULL, source_url TEXT, PRIMARY KEY (auth_key, repo, kind, number, include_comments) ); CREATE INDEX IF NOT EXISTS idx_github_view_cache_fetched ON github_view_cache(fetched_at); PRAGMA user_version = 3; `); protectDbFiles(dbPath); cachedDb = db; // No eviction on open: the default `DEFAULT_HARD_TTL_SEC` is a coarse // backstop that runs before user settings load, so applying it here // would nuke rows still valid under a stricter-or-laxer configured // `github.cache.hardTtlSec`. The per-lookup `sweepIfDue()` in // `getOrFetchView()` enforces the *configured* retention instead. return db; } catch (err) { logger.warn("github cache: failed to open DB; cache disabled", { err: String(err) }); return null; } } function evictExpired(db: Database, hardTtlMs: number): void { try { const cutoff = Date.now() - hardTtlMs; db.prepare("DELETE FROM github_view_cache WHERE fetched_at < ?").run(cutoff); } catch (err) { logger.debug("github cache: eviction failed", { err: String(err) }); } } /** * Throttle for the per-lookup configured-TTL sweep. We don't want every * cached read to issue a DELETE; once per `SWEEP_INTERVAL_MS` is enough to * cap the on-disk exposure window at roughly `hardTtlMs + SWEEP_INTERVAL_MS`. */ const SWEEP_INTERVAL_MS = 60_000; let lastSweepAt = 0; function sweepIfDue(hardTtlMs: number): void { const now = Date.now(); if (now - lastSweepAt < SWEEP_INTERVAL_MS) return; const db = openDb(); if (!db) return; lastSweepAt = now; evictExpired(db, hardTtlMs); } function getGhConfigDir(): string { const override = process.env.GH_CONFIG_DIR; if (override) return override; const xdg = process.env.XDG_CONFIG_HOME; if (xdg) return path.join(xdg, "gh"); return path.join(os.homedir(), ".config", "gh"); } function hashCacheIdentity(parts: string[]): string { return Bun.hash(parts.map(part => `${part.length}:${part}`).join("|")).toString(36); } /** * Best-effort local fingerprint for the active GitHub CLI credentials. * * Cache hits must not cross account/token boundaries, but doing a `gh api user` * probe before every cached read would defeat the soft-TTL contract that cache * hits avoid a gh round-trip. Instead, key rows by credential material that the * GitHub CLI itself consumes: token environment variables and/or hosts.yml. * The DB stores only a hash, never the token or hosts.yml contents. If no * credential source is visible, callers should pass `null` to bypass caching. */ export function resolveGithubCacheAuthKey(host: string = process.env.GH_HOST || "github.com"): string | undefined { const parts: string[] = [`host:${host}`]; let hasCredentialMaterial = false; for (const name of ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]) { const value = process.env[name]; if (!value) continue; hasCredentialMaterial = true; parts.push(`${name}:${value}`); } try { const hostsPath = path.join(getGhConfigDir(), "hosts.yml"); const hosts = fs.readFileSync(hostsPath, "utf8"); hasCredentialMaterial = true; parts.push(`hosts:${hosts}`); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { logger.debug("github cache: failed to read gh hosts config for cache identity", { err: String(err) }); } } if (!hasCredentialMaterial) return undefined; return `${host}:${hashCacheIdentity(parts)}`; } function normalizeRepo(repo: string): string { return repo.toLowerCase(); } export function getCached( repo: string, kind: CacheKind, number: number, includeComments: boolean, authKey: string = DEFAULT_CACHE_AUTH_KEY, ): CachedView | null { const db = openDb(); if (!db) return null; try { const row = db .prepare( "SELECT auth_key, repo, kind, number, include_comments, fetched_at, payload, rendered, source_url FROM github_view_cache WHERE auth_key = ? AND repo = ? AND kind = ? AND number = ? AND include_comments = ?", ) .get(authKey, normalizeRepo(repo), kind, number, includeComments ? 1 : 0) as Row | undefined; if (!row) return null; let payload: T; try { payload = JSON.parse(row.payload) as T; } catch (err) { logger.debug("github cache: corrupt payload row, ignoring", { err: String(err), repo, kind, number }); return null; } return { authKey: row.auth_key, repo: row.repo, kind: row.kind, number: row.number, includeComments: row.include_comments === 1, fetchedAt: row.fetched_at, payload, rendered: row.rendered, sourceUrl: row.source_url ?? undefined, }; } catch (err) { logger.debug("github cache: read failed", { err: String(err) }); return null; } } export interface PutCachedInput { authKey?: string; repo: string; kind: CacheKind; number: number; includeComments: boolean; payload: T; rendered: string; sourceUrl?: string; fetchedAt?: number; } export function putCached(input: PutCachedInput): void { const db = openDb(); if (!db) return; try { const fetchedAt = input.fetchedAt ?? Date.now(); const payloadJson = JSON.stringify(input.payload); db.prepare( "INSERT OR REPLACE INTO github_view_cache (auth_key, repo, kind, number, include_comments, fetched_at, payload, rendered, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( input.authKey ?? DEFAULT_CACHE_AUTH_KEY, normalizeRepo(input.repo), input.kind, input.number, input.includeComments ? 1 : 0, fetchedAt, payloadJson, input.rendered, input.sourceUrl ?? null, ); protectDbFiles(getGithubCacheDbPath()); } catch (err) { logger.debug("github cache: write failed", { err: String(err) }); } } /** Drop a specific cache entry. */ export function invalidate( repo: string, kind: CacheKind, number: number, includeComments?: boolean, authKey: string = DEFAULT_CACHE_AUTH_KEY, ): void { const db = openDb(); if (!db) return; try { if (includeComments === undefined) { db.prepare("DELETE FROM github_view_cache WHERE auth_key = ? AND repo = ? AND kind = ? AND number = ?").run( authKey, normalizeRepo(repo), kind, number, ); } else { db.prepare( "DELETE FROM github_view_cache WHERE auth_key = ? AND repo = ? AND kind = ? AND number = ? AND include_comments = ?", ).run(authKey, normalizeRepo(repo), kind, number, includeComments ? 1 : 0); } } catch (err) { logger.debug("github cache: invalidate failed", { err: String(err) }); } } /** Drop every cached row. Test helper. */ export function clearAll(): void { const db = openDb(); if (!db) return; try { db.prepare("DELETE FROM github_view_cache").run(); } catch (err) { logger.debug("github cache: clear failed", { err: String(err) }); } } /** * Test/maintenance helper. Closes and forgets the cached connection so the * next access reopens against (possibly) a different DB path. */ export function resetForTests(): void { if (cachedDb) { try { cachedDb.close(); } catch { // Closing failures are non-fatal. } } cachedDb = null; openAttempted = false; lastSweepAt = 0; } // ──────────────────────────────────────────────────────────────────────────── // Cache-aware lookup wrapper // ──────────────────────────────────────────────────────────────────────────── export interface FreshResult { rendered: string; sourceUrl: string | undefined; payload: T; } export interface CacheLookupOptions { repo: string; kind: CacheKind; number: number; includeComments: boolean; /** * Auth/credential namespace for cache rows. Omit only in storage-layer * tests; pass `null` when production code cannot determine an identity and * must bypass persistent cache reads/writes. */ authKey?: string | null; fetchFresh: () => Promise>; settings?: Settings | undefined; now?: number; } export type CacheStatus = "miss" | "fresh" | "stale" | "disabled"; export interface CacheLookupResult { rendered: string; sourceUrl: string | undefined; payload: T; status: CacheStatus; fetchedAt: number; } function readNumberSetting(settings: Settings | undefined, key: string, fallback: number): number { if (!settings) return fallback; try { const value = (settings as unknown as { get(k: string): unknown }).get(key); if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value; } catch { // Unknown setting paths fall through to default; settings may be a // stripped test stub that doesn't expose every key. } return fallback; } function readBooleanSetting(settings: Settings | undefined, key: string, fallback: boolean): boolean { if (!settings) return fallback; try { const value = (settings as unknown as { get(k: string): unknown }).get(key); if (typeof value === "boolean") return value; } catch { // Same fallback rationale as readNumberSetting. } return fallback; } export interface CacheTtl { softMs: number; hardMs: number; enabled: boolean; } export function resolveCacheTtl(settings?: Settings): CacheTtl { const softSec = readNumberSetting(settings, "github.cache.softTtlSec", DEFAULT_SOFT_TTL_SEC); const hardSec = readNumberSetting(settings, "github.cache.hardTtlSec", DEFAULT_HARD_TTL_SEC); const enabled = readBooleanSetting(settings, "github.cache.enabled", true); return { softMs: Math.max(0, softSec) * 1000, hardMs: Math.max(0, hardSec) * 1000, enabled, }; } function storeResult( authKey: string, repo: string, kind: CacheKind, number: number, includeComments: boolean, result: FreshResult, fetchedAt: number, ): void { putCached({ authKey, repo, kind, number, includeComments, payload: result.payload, rendered: result.rendered, sourceUrl: result.sourceUrl, fetchedAt, }); } function scheduleBackgroundRefresh( authKey: string, repo: string, kind: CacheKind, number: number, includeComments: boolean, fetchFresh: () => Promise>, ): void { queueMicrotask(() => { const promise = fetchFresh(); promise .then(fresh => { storeResult(authKey, repo, kind, number, includeComments, fresh, Date.now()); }) .catch(err => { logger.debug("github cache: background refresh failed", { err: String(err), repo, kind, number, }); }); }); } export async function getOrFetchView(options: CacheLookupOptions): Promise> { const ttl = resolveCacheTtl(options.settings); const now = options.now ?? Date.now(); const authKey = options.authKey === undefined ? DEFAULT_CACHE_AUTH_KEY : options.authKey; if (!ttl.enabled || authKey === null) { const fresh = await options.fetchFresh(); return { ...fresh, status: "disabled", fetchedAt: now }; } // Enforce the *configured* hard TTL against on-disk rows. This is what // makes `github.cache.hardTtlSec` a real retention cap rather than a soft // suggestion the next `openDb()` call eventually honors. sweepIfDue(ttl.hardMs); const cached: CachedView | null = getCached( options.repo, options.kind, options.number, options.includeComments, authKey, ); if (cached) { const age = now - cached.fetchedAt; if (age > ttl.hardMs) { // Past hard TTL: drop the row eagerly so the on-disk exposure window // is bounded even if `fetchFresh()` then fails (network down, gh // auth lapse, etc.) and we never get to overwrite it. invalidate(options.repo, options.kind, options.number, options.includeComments, authKey); } else if (age <= ttl.softMs) { return { rendered: cached.rendered, sourceUrl: cached.sourceUrl, payload: cached.payload, status: "fresh", fetchedAt: cached.fetchedAt, }; } else { scheduleBackgroundRefresh( authKey, options.repo, options.kind, options.number, options.includeComments, options.fetchFresh, ); return { rendered: cached.rendered, sourceUrl: cached.sourceUrl, payload: cached.payload, status: "stale", fetchedAt: cached.fetchedAt, }; } } const fresh = await options.fetchFresh(); const fetchedAt = Date.now(); storeResult(authKey, options.repo, options.kind, options.number, options.includeComments, fresh, fetchedAt); return { ...fresh, status: "miss", fetchedAt }; } /** * Human-friendly freshness note for protocol-handler `notes[]` rendering. */ export function formatFreshnessNote(status: CacheStatus, fetchedAtMs: number, now: number = Date.now()): string { if (status === "miss") return "Fetched live"; if (status === "disabled") return "Cache disabled; fetched live"; const ageSec = Math.max(0, Math.round((now - fetchedAtMs) / 1000)); const human = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.round(ageSec / 60)}m ago` : `${Math.round(ageSec / 3600)}h ago`; if (status === "stale") return `Cached: ${human} (refreshing in background)`; return `Cached: ${human}`; }