Source: lib/core/trace/store.js

/**
 * @file store.js
 * @description On-disk storage for recorded traces.
 *
 * Traces live in `.avenx/traces/` under the project root, one JSON file per
 * recording. A plain directory of plain files is deliberate: a developer can
 * read one, diff two, attach one to an issue, or delete the lot, without a
 * database, an index file that can go stale, or a tool.
 *
 * Node-only, like everything else that touches the filesystem.
 * @module lib/core/trace/store
 */

import fs from 'fs';
import path from 'path';
import { validateTrace } from './schema.js';

/**
 * Where traces are kept, relative to the project root.
 * @type {string}
 */
export const TRACE_DIR = path.join('.avenx', 'traces');

/**
 * How many traces a project keeps before `prune` starts suggesting cleanup.
 * @type {number}
 */
export const DEFAULT_KEEP = 20;

/**
 * Resolves and creates the trace directory for a project.
 * @param {string} rootDir - The project root.
 * @returns {string} The absolute trace directory.
 */
export function traceDir(rootDir) {
  const dir = path.join(rootDir, TRACE_DIR);
  fs.mkdirSync(dir, { recursive: true });
  return dir;
}

/**
 * Rejects a trace id that could escape the trace directory.
 *
 * Ids arrive from the CLI and from the dev server's ingest endpoint, so they
 * are untrusted input on a path join.
 * @param {string} id - The candidate id.
 * @returns {boolean} Whether it is safe to use as a file name.
 */
export function isValidTraceId(id) {
  return typeof id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(id);
}

/**
 * Writes a trace to disk.
 * @param {string} rootDir - The project root.
 * @param {object} trace - The trace to save.
 * @returns {string} The path written.
 * @throws {Error} When the trace has no usable id.
 */
export function saveTrace(rootDir, trace) {
  if (!isValidTraceId(trace && trace.id)) {
    throw new Error(`Refusing to save a trace with an unusable id: ${JSON.stringify(trace && trace.id)}`);
  }
  const filePath = path.join(traceDir(rootDir), `${trace.id}.json`);
  fs.writeFileSync(filePath, JSON.stringify(trace, null, 2));
  return filePath;
}

/**
 * Reads one trace.
 * @param {string} rootDir - The project root.
 * @param {string} id - The trace id, or `latest` for the newest.
 * @returns {{trace: object, path: string}|null} The trace, or null when absent.
 * @throws {Error} When the file exists but is not a readable trace.
 */
export function loadTrace(rootDir, id) {
  if (id === 'latest') {
    const all = listTraces(rootDir);
    if (all.length === 0) {
      return null;
    }
    return loadTrace(rootDir, all[0].id);
  }

  if (!isValidTraceId(id)) {
    return null;
  }

  const filePath = path.join(rootDir, TRACE_DIR, `${id}.json`);
  if (!fs.existsSync(filePath)) {
    return null;
  }

  let parsed;
  try {
    parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
  } catch (error) {
    throw new Error(`${filePath} is not valid JSON: ${error.message}`, { cause: error });
  }

  const valid = validateTrace(parsed);
  if (!valid.ok) {
    throw new Error(`${filePath} cannot be read: ${valid.error}`);
  }

  return { trace: parsed, path: filePath };
}

/**
 * Lists stored traces, newest first.
 *
 * A file that cannot be parsed is listed with a `broken` flag rather than
 * skipped, so a corrupted recording is visible and can be pruned rather than
 * silently disappearing from the listing.
 * @param {string} rootDir - The project root.
 * @returns {Array<object>} Summaries, newest first.
 */
export function listTraces(rootDir) {
  const dir = path.join(rootDir, TRACE_DIR);
  if (!fs.existsSync(dir)) {
    return [];
  }

  const entries = [];
  for (const name of fs.readdirSync(dir)) {
    if (!name.endsWith('.json')) {
      continue;
    }
    const filePath = path.join(dir, name);
    const stat = fs.statSync(filePath);
    try {
      const trace = JSON.parse(fs.readFileSync(filePath, 'utf8'));
      entries.push({
        id: trace.id || path.basename(name, '.json'),
        path: filePath,
        mtime: stat.mtimeMs,
        size: stat.size,
        trace,
        broken: !validateTrace(trace).ok,
      });
    } catch {
      entries.push({
        id: path.basename(name, '.json'),
        path: filePath,
        mtime: stat.mtimeMs,
        size: stat.size,
        trace: null,
        broken: true,
      });
    }
  }

  entries.sort((a, b) => {
    if (b.mtime !== a.mtime) {
      return b.mtime - a.mtime;
    }
    const bCreated = b.trace && b.trace.createdAt ? Date.parse(b.trace.createdAt) || 0 : 0;
    const aCreated = a.trace && a.trace.createdAt ? Date.parse(a.trace.createdAt) || 0 : 0;
    if (bCreated !== aCreated) {
      return bCreated - aCreated;
    }
    return b.id.localeCompare(a.id);
  });
  return entries;
}

/**
 * Deletes stored traces.
 * @param {string} rootDir - The project root.
 * @param {object} [options] - What to remove.
 * @param {number} [options.keep] - Keep this many of the newest.
 * @param {boolean} [options.all] - Remove everything.
 * @param {string} [options.id] - Remove one trace.
 * @returns {string[]} The ids removed.
 */
export function pruneTraces(rootDir, options = {}) {
  const entries = listTraces(rootDir);
  let doomed;

  if (options.id) {
    doomed = entries.filter((entry) => entry.id === options.id);
  } else if (options.all) {
    doomed = entries;
  } else {
    const keep = typeof options.keep === 'number' ? options.keep : DEFAULT_KEEP;
    doomed = entries.slice(keep);
  }

  const removed = [];
  for (const entry of doomed) {
    try {
      fs.unlinkSync(entry.path);
      removed.push(entry.id);
    } catch {
      // A trace that cannot be removed is not worth failing a cleanup over.
    }
  }
  return removed;
}