/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/lib/cache.mjs * Purpose : Filesystem cache keyed by @ — busted on push * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync, statSync, renameSync, } from 'node:fs'; import { join, dirname } from 'node:path'; import { randomBytes } from 'node:crypto'; import { safeJoin } from './util.mjs'; /** * Content cache under `cacheDir`. Layout: * /repos/// per-commit artifacts * /global/ cross-repo artifacts (search index, heatmap) * Push hooks call {@link Cache#bustRepo} which nukes the repo subtree. */ export class Cache { /** @param {string} cacheDir */ constructor(cacheDir) { this.root = cacheDir; } /** @param {...string} segments @returns {string} */ path(...segments) { const cleaned = segments.map((s) => String(s).replaceAll('/', '_')); return safeJoin(this.root, ...cleaned); } /** Per-repo, per-commit key. @returns {string} */ repoPath(repo, sha, kind) { return safeJoin(this.root, 'repos', repo, sha, kind.replaceAll('/', '_')); } /** @returns {Buffer|null} */ getBuffer(path) { try { return readFileSync(path); } catch { return null; } } /** @returns {any|null} */ getJSON(path) { const buf = this.getBuffer(path); if (buf === null) return null; try { return JSON.parse(buf.toString('utf8')); } catch { return null; } } /** Atomic write (tmp + rename). */ set(path, data) { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${randomBytes(4).toString('hex')}.tmp`; writeFileSync(tmp, data); renameSync(tmp, path); } setJSON(path, value) { this.set(path, JSON.stringify(value)); } /** * Read-through helper for JSON artifacts. * @param {string} path * @param {() => Promise|any} compute */ async remember(path, compute) { const hit = this.getJSON(path); if (hit !== null) return hit; const value = await compute(); if (value !== undefined) this.setJSON(path, value); return value; } /** Drop every cached artifact for a repo (all shas). @param {string} repo */ bustRepo(repo) { rmSync(safeJoin(this.root, 'repos', repo), { recursive: true, force: true }); rmSync(safeJoin(this.root, 'global'), { recursive: true, force: true }); } /** @returns {{files: number, bytes: number}} recursive cache footprint */ footprint() { let files = 0; let bytes = 0; const walk = (dir) => { let entries; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { const full = join(dir, entry); let st; try { st = statSync(full); } catch { continue; } if (st.isDirectory()) walk(full); else { files += 1; bytes += st.size; } } }; if (existsSync(this.root)) walk(this.root); return { files, bytes }; } }