/** * 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<string, CachedFile>; /** * 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<Record<string, SerializedFile>>;} /** 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<string, number>(); const sessionIndex = new Map<string, number>(); const intern = (table: string[], index: Map<string, number>, 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<string, SerializedFile> = {}; 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<SerializedCache>; 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<SerializedFile>; 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<string>; /** * 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<string>(); 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;}
/**
* 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<string, CachedFile>;
/**
* 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<Record<string, SerializedFile>>;
}
/** 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<string, number>();
const sessionIndex = new Map<string, number>();
const intern = (table: string[], index: Map<string, number>, 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<string, SerializedFile> = {};
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<SerializedCache>;
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<SerializedFile>;
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<string>;
/**
* 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<string>();
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;
}