/** * Durable per-file scan cache. * * Transcripts are append-only and a file that has not changed can never yield * different usage, so parsed records are keyed by `(size, mtime)` and reused. * Without this every server restart re-parses the whole window: roughly 3.5s * for a 30-day scan here, against ~11ms to reload this cache. * * Caching *per file* rather than per day is deliberate. It is timezone * independent, so changing the reporting zone does not invalidate anything, and * it keeps cross-file de-duplication exact: cached entries are de-duplicated * within their own file only, and the aggregator still applies the global * dedupe pass over the small surviving key set. * * @module usageScanCache */ import type { UsageProviderKind } from "@t3tools/contracts"; import type { UsageRecord } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. export const USAGE_SCAN_CACHE_VERSION = 2 as const; export interface CachedFile { readonly size: number; readonly mtimeMs: number; readonly provider: UsageProviderKind; readonly records: readonly UsageRecord[]; } export type ScanCache = Map; /** * Row layout for the serialised form. Positional and interned rather than * object-per-record: on a 30-day window that is the difference between a file * measured in tens of megabytes and one under six. */ type SerializedRecord = readonly [ timestampMs: number, modelIndex: number, sessionIndex: number, uncachedInputTokens: number, cachedInputTokens: number, cacheCreationTokens: number, outputTokens: number, reasoningTokens: number, dedupeKey: string | null, reportedCostUsd: number | null, ]; interface SerializedFile { readonly s: number; readonly m: number; readonly p: UsageProviderKind; readonly r: readonly SerializedRecord[]; } interface SerializedCache { readonly version: number; readonly models: readonly string[]; readonly sessions: readonly string[]; readonly files: Readonly>; } /** Serialises the cache, interning the repeated model and session strings. */ export function encodeScanCache(cache: ScanCache): SerializedCache { const models: string[] = []; const sessions: string[] = []; const modelIndex = new Map(); const sessionIndex = new Map(); const intern = (table: string[], index: Map, value: string): number => { const existing = index.get(value); if (existing !== undefined) return existing; const next = table.length; table.push(value); index.set(value, next); return next; }; const files: Record = {}; for (const [path, entry] of cache) { files[path] = { s: entry.size, m: entry.mtimeMs, p: entry.provider, r: entry.records.map((record) => [ record.timestampMs, intern(models, modelIndex, record.model), intern(sessions, sessionIndex, record.sessionId), record.totals.uncachedInputTokens, record.totals.cachedInputTokens, record.totals.cacheCreationTokens, record.totals.outputTokens, record.totals.reasoningTokens, record.dedupeKey, record.reportedCostUsd, ]), }; } return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; } function isRecordArray(value: unknown): value is readonly unknown[] { return Array.isArray(value); } /** * Rebuilds the cache from a parsed document. * * Anything malformed yields an empty cache rather than an error: a corrupt * cache should cost one cold scan, never a broken page. */ export function decodeScanCache(document: unknown): ScanCache { const cache: ScanCache = new Map(); if (typeof document !== "object" || document === null) return cache; const root = document as Partial; if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; if (typeof root.files !== "object" || root.files === null) return cache; // The intern tables must be all strings: a numeric entry would pass the // undefined guard below, land in a record's model, and crash the aggregate // at normalizeModelName. A corrupt table rejects the whole cache. if (!root.models.every((value) => typeof value === "string")) return cache; if (!root.sessions.every((value) => typeof value === "string")) return cache; const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; for (const [path, raw] of Object.entries(root.files)) { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; const records: UsageRecord[] = []; // Any corrupt row disqualifies the whole entry. Keeping the survivors // under the original (size, mtime) would read as a valid warm hit and the // file would never be re-parsed, silently losing the dropped rows' usage. let corrupt = false; for (const row of entry.r) { if (!isRecordArray(row) || row.length < 10) { corrupt = true; break; } const [ timestampMs, modelIndex, sessionIndex, uncached, cached, cacheCreation, output, reasoning, dedupeKey, reportedCostUsd, ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; if ( typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || model === undefined || !Number.isFinite(uncached) || !Number.isFinite(cached) || !Number.isFinite(cacheCreation) || !Number.isFinite(output) || !Number.isFinite(reasoning) ) { corrupt = true; break; } records.push({ provider, timestampMs, model, sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", totals: { uncachedInputTokens: uncached, cachedInputTokens: cached, cacheCreationTokens: cacheCreation, outputTokens: output, reasoningTokens: reasoning, }, reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } if (corrupt) continue; cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); } return cache; } export interface PruneOptions { /** Files the walk just saw. Only meaningful inside the walked window. */ readonly livePaths: ReadonlySet; /** * Roots the walk actually completed. Absence from `livePaths` only proves a * file is gone when its root was walked: a provider whose directory failed to * resolve this pass must not have its warm entries purged. */ readonly walkedRoots: readonly string[]; /** Start of the walked window; entries older than this were not looked for. */ readonly windowStartMs: number; /** Entries older than this are dropped regardless. */ readonly retentionCutoffMs: number; } /** * Drops aged-out entries, and entries for files that have disappeared. * * The walk only covers the requested window, so absence from `livePaths` only * proves deletion for entries *inside* that window. Pruning everything the walk * missed would evict the 30-day entries every time someone looked at 7 days. * * Replaces an earlier record cap that cleared the whole cache once exceeded, * which meant a large enough window never warmed up at all. */ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { let removed = 0; for (const [path, entry] of cache) { const agedOut = entry.mtimeMs < options.retentionCutoffMs; const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); const deleted = underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); if (agedOut || deleted) { cache.delete(path); removed += 1; } } return removed; } /** Within-file de-duplication, applied before an entry is cached. */ export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { const seen = new Set(); const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { if (seen.has(record.dedupeKey)) continue; seen.add(record.dedupeKey); } kept.push(record); } return kept; }