Source: lib/core/trace/capture.js

/**
 * @file capture.js
 * @description Bounded, redaction-aware value capture for traces.
 *
 * A trace has to hold *values* — the old and new side of every write, the body
 * of every resource response — and it has to hold them as JSON. Three things
 * make that harder than calling `JSON.stringify`:
 *
 * 1. The values are reactive proxies. Recording the proxy would re-enter the
 *    reactive system and track dependencies against whichever watcher happened
 *    to be running, silently changing application behaviour.
 * 2. Some values cannot survive the round trip (DOM nodes, class instances,
 *    functions, cycles). Recording a placeholder is fine; *claiming* the trace
 *    can be replayed afterwards is not, so capture reports when it was lossy.
 * 3. Some values must never be recorded at all. Redaction is applied here,
 *    during capture, so a secret never reaches the buffer.
 *
 * Capture is bounded in depth and breadth. A trace of a thousand-row list must
 * not clone the list a thousand times.
 * @module lib/core/trace/capture
 */

import { RAW_SYMBOL } from '../reactive/symbols.js';
import { NO_REDACTION } from './redact.js';
import { REDACTED } from './schema.js';

/**
 * How deep a captured value is walked before it is summarised.
 * @type {number}
 */
export const DEFAULT_MAX_DEPTH = 6;

/**
 * How many array entries or object keys are captured before the rest is
 * summarised as a count.
 * @type {number}
 */
export const DEFAULT_MAX_ITEMS = 50;

/**
 * How long a captured string may be before it is truncated.
 * @type {number}
 */
export const DEFAULT_MAX_STRING = 512;

/**
 * Set by {@link captureValue} when the most recent capture could not represent
 * a value exactly. Module-scoped rather than returned in a wrapper object
 * because capture runs on hot paths and this avoids an allocation per write.
 * Capture is synchronous, so there is no interleaving to worry about.
 * @type {boolean}
 */
let lastCaptureLossy = false;

/**
 * Whether the most recent {@link captureValue} call lost information.
 * @returns {boolean} True when the captured value is a summary, not the value.
 */
export function wasLossy() {
  return lastCaptureLossy;
}

/**
 * Unwraps a reactive proxy so capture reads the plain object underneath.
 *
 * `toRaw()` is not used here: it goes through the proxy's `get` trap, which
 * would call `track()` and attribute the read to whatever watcher is running.
 * Reading the symbol directly on a non-proxy is simply `undefined`.
 * @param {any} value - A possibly reactive value.
 * @returns {any} The raw value.
 */
function unwrap(value) {
  if (value !== null && typeof value === 'object') {
    const raw = value[RAW_SYMBOL];
    if (raw) {
      return raw;
    }
  }
  return value;
}

/**
 * Describes a value that cannot be represented in JSON.
 * @param {any} value - The value.
 * @returns {string} A short human-readable placeholder.
 */
function describeOpaque(value) {
  if (typeof value === 'function') {
    return `[Function ${value.name || 'anonymous'}]`;
  }
  if (typeof value === 'symbol') {
    return `[Symbol ${String(value.description || '')}]`;
  }
  if (typeof value === 'bigint') {
    return `[BigInt ${value.toString()}]`;
  }
  if (value && typeof value === 'object') {
    if (typeof Node !== 'undefined' && value instanceof Node) {
      return `[DOM ${value.nodeName || 'Node'}]`;
    }
    const name = value.constructor && value.constructor.name;
    return `[${name || 'Object'}]`;
  }
  return '[Unserializable]';
}

/**
 * Recursively captures a value within the configured bounds.
 * @param {any} value - The value to capture.
 * @param {object} options - Capture options.
 * @param {string} path - The property path this value sits at.
 * @param {number} depth - Remaining depth budget.
 * @param {WeakSet<object>} seen - Objects already on the current branch.
 * @returns {any} A JSON-representable capture.
 */
function walk(value, options, path, depth, seen) {
  // Checked with `matches` rather than by comparing `guard`'s return value:
  // `NaN !== NaN`, so a value-identity check here would treat every NaN as if
  // it had been redacted and skip the rest of the walk.
  if (!options.redactor.isEmpty && options.redactor.matches(path)) {
    options.redactor.markApplied(path, value);
    return REDACTED;
  }

  const raw = unwrap(value);

  if (raw === null || raw === undefined) {
    return raw === undefined ? null : null;
  }

  const type = typeof raw;

  if (type === 'string') {
    if (raw.length > options.maxString) {
      lastCaptureLossy = true;
      return `${raw.slice(0, options.maxString)}… (+${raw.length - options.maxString} chars)`;
    }
    return raw;
  }
  if (type === 'boolean') {
    return raw;
  }
  if (type === 'number') {
    // NaN and the infinities have no JSON representation; recording them as
    // null would silently turn a bug into a passing replay.
    if (Number.isFinite(raw)) {
      return raw;
    }
    lastCaptureLossy = true;
    return `[${String(raw)}]`;
  }
  if (type === 'function' || type === 'symbol' || type === 'bigint') {
    lastCaptureLossy = true;
    return describeOpaque(raw);
  }

  if (raw instanceof Date) {
    return { $date: raw.toISOString() };
  }
  if (raw instanceof Error) {
    return { $error: raw.name || 'Error', message: String(raw.message || '') };
  }

  if (seen.has(raw)) {
    lastCaptureLossy = true;
    return '[Circular]';
  }

  if (depth <= 0) {
    lastCaptureLossy = true;
    return describeOpaque(raw);
  }

  if (Array.isArray(raw)) {
    seen.add(raw);
    try {
      const limit = Math.min(raw.length, options.maxItems);
      const out = [];
      for (let i = 0; i < limit; i++) {
        out.push(walk(raw[i], options, path ? `${path}.${i}` : String(i), depth - 1, seen));
      }
      if (raw.length > limit) {
        lastCaptureLossy = true;
        out.push(`… (+${raw.length - limit} more)`);
      }
      return out;
    } finally {
      seen.delete(raw);
    }
  }

  if (raw instanceof Map) {
    lastCaptureLossy = true;
    return { $map: raw.size };
  }
  if (raw instanceof Set) {
    lastCaptureLossy = true;
    return { $set: raw.size };
  }

  const proto = Object.getPrototypeOf(raw);
  if (proto !== null && proto !== Object.prototype) {
    lastCaptureLossy = true;
    return describeOpaque(raw);
  }

  seen.add(raw);
  try {
    const out = {};
    const keys = Object.keys(raw);
    const limit = Math.min(keys.length, options.maxItems);
    for (let i = 0; i < limit; i++) {
      const key = keys[i];
      out[key] = walk(raw[key], options, path ? `${path}.${key}` : key, depth - 1, seen);
    }
    if (keys.length > limit) {
      lastCaptureLossy = true;
      out.$truncated = keys.length - limit;
    }
    return out;
  } finally {
    seen.delete(raw);
  }
}

/**
 * Captures a value for storage in a trace.
 *
 * Call {@link wasLossy} straight afterwards to learn whether the capture is
 * exact. A lossy capture is still worth recording — it explains the shape of
 * what happened — but it means replay cannot restore this value, and the
 * caller is responsible for downgrading the trace's determinism accordingly.
 * @param {any} value - The value to capture.
 * @param {object} [options] - Capture options.
 * @param {string} [options.path] - The property path the value sits at, used for redaction.
 * @param {object} [options.redactor] - Redaction rules.
 * @param {number} [options.maxDepth] - Depth budget.
 * @param {number} [options.maxItems] - Per-collection breadth budget.
 * @param {number} [options.maxString] - String length budget.
 * @returns {any} A JSON-representable capture of the value.
 */
export function captureValue(value, options = {}) {
  lastCaptureLossy = false;
  const resolved = {
    redactor: options.redactor || NO_REDACTION,
    maxDepth: options.maxDepth || DEFAULT_MAX_DEPTH,
    maxItems: options.maxItems || DEFAULT_MAX_ITEMS,
    maxString: options.maxString || DEFAULT_MAX_STRING,
  };
  try {
    return walk(value, resolved, options.path || '', resolved.maxDepth, new WeakSet());
  } catch {
    // A hostile getter can throw from anywhere in the walk. Losing the value is
    // acceptable; letting a recorder break the application is not.
    lastCaptureLossy = true;
    return '[Uncapturable]';
  }
}

/**
 * Formats a captured value for single-line display in `avenx trace view`.
 * @param {any} value - A captured value.
 * @returns {string} A compact rendering.
 */
export function formatCaptured(value) {
  if (value === null) {
    return 'null';
  }
  if (typeof value === 'string') {
    return JSON.stringify(value);
  }
  if (typeof value === 'number' || typeof value === 'boolean') {
    return String(value);
  }
  if (Array.isArray(value)) {
    return `[${value.length} item${value.length === 1 ? '' : 's'}]`;
  }
  if (typeof value === 'object') {
    if (value.$date) {
      return value.$date;
    }
    if (value.$error) {
      return `${value.$error}: ${value.message}`;
    }
    try {
      const json = JSON.stringify(value);
      return json.length > 60 ? `${json.slice(0, 57)}...` : json;
    } catch {
      return '{…}';
    }
  }
  return String(value);
}