All files / lib/core/trace schema.js

100% Statements 229/229
95.45% Branches 21/22
100% Functions 5/5
100% Lines 229/229

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 75x 75x 75x 75x 75x 75x 75x 75x 75x 75x 75x 75x 75x 75x 75x 505x 505x 505x 505x 505x 505x 505x 51x 1x 1x 51x 1x 1x 51x 2x 2x 2x 2x 2x 47x 1x 1x 46x 51x 505x 505x 505x 505x 505x 505x 505x 72x 72x 415x 415x 72x 72x 505x 505x 505x 505x 505x 505x 505x 11x 11x 51x 51x 51x 37x 37x 37x 51x 51x 11x 11x 505x 505x 505x 505x 505x 505x 505x 505x 505x 13x 13x 13x  
/**
 * @file schema.js
 * @description The Avenx trace data model.
 *
 * A trace is a flat, ordered array of nodes. Causality is expressed with a
 * `parent` pointer rather than nesting, so a trace stays cheap to append to,
 * cheap to serialize, and stable to truncate: dropping the oldest nodes from a
 * ring buffer can never corrupt the array, only orphan a subtree.
 *
 * The node types mirror Avenx's own execution model — event, action, bridge
 * action, write, watcher, computed, DOM patch, resource, navigation — rather
 * than the browser's. A trace is meant to answer "why did this component
 * re-render", which is a question about the framework, not about the DOM.
 * @module lib/core/trace/schema
 */
 
/**
 * The trace format version.
 *
 * Bump this whenever the meaning of an existing field changes or a field the
 * reader depends on is removed. Adding a new optional field does not require a
 * bump; readers must tolerate unknown fields.
 * @type {number}
 */
export const TRACE_VERSION = 1;
 
/**
 * Node types that can appear in a trace.
 *
 * `ROOT_TYPES` below marks the subset that begins a causal chain, and
 * `INPUT_TYPES` marks the subset replay feeds back into the application.
 * @readonly
 * @enum {string}
 */
export const TraceNodeType = {
  /** A DOM event that reached an Avenx handler. Always a causal root. */
  EVENT: 'event',
  /** A component `<action>` body running. */
  ACTION: 'action',
  /** An action declared on a bridge running. */
  BRIDGE_ACTION: 'bridge-action',
  /** An event emitted by a bridge to its subscribers. */
  BRIDGE_EMIT: 'bridge-emit',
  /** A reactive state mutation, identified by its property path. */
  WRITE: 'write',
  /** A watcher waking because a dependency it read was written. */
  WATCHER: 'watcher',
  /** A computed property or bridge getter re-evaluating. */
  COMPUTED: 'computed',
  /** A DOM mutation the patcher applied. */
  DOM: 'dom',
  /** A resource request or its settlement. */
  RESOURCE: 'resource',
  /** A router navigation. */
  NAVIGATION: 'navigation',
  /**
   * A marker that application code read a non-deterministic global. The values
   * themselves travel in the trace's compact `globals` log rather than as one
   * node per read, which would swamp the causal graph.
   */
  GLOBAL: 'global',
  /** An error that escaped application code. */
  ERROR: 'error',
  /** A declared compiler contract that the running code did not honour. */
  CONTRACT: 'contract',
  /**
   * An Avenx Rewind transaction playing its journal backwards.
   *
   * The writes the rewind performs are recorded underneath it as ordinary
   * writes, because that is what they are — which is the point: a value that
   * changed back is otherwise the most confusing thing in a trace.
   */
  REWIND: 'rewind',
};
 
/**
 * Node types that begin a causal chain rather than continuing one.
 * @type {Set<string>}
 */
export const ROOT_TYPES = new Set([TraceNodeType.EVENT, TraceNodeType.NAVIGATION, TraceNodeType.RESOURCE]);
 
/**
 * Node types replay feeds back into the application to reproduce a session.
 *
 * Everything else in a trace is an *observation*: replay re-derives it by
 * running the real framework, and compares what it observed against what was
 * recorded. Driving a non-input node directly would make replay a puppet show
 * rather than a reproduction.
 * @type {Set<string>}
 */
export const INPUT_TYPES = new Set([TraceNodeType.EVENT, TraceNodeType.NAVIGATION, TraceNodeType.RESOURCE]);
 
/**
 * Node types replay compares against the recording to detect divergence.
 * @type {Set<string>}
 */
export const OBSERVATION_TYPES = new Set([TraceNodeType.WRITE, TraceNodeType.DOM, TraceNodeType.NAVIGATION]);
 
/**
 * Whether a recorded session can be faithfully replayed.
 * @readonly
 * @enum {string}
 */
export const Determinism = {
  /** Nothing was observed that replay cannot reproduce. */
  DETERMINISTIC: 'deterministic',
  /** Something escaped the recording boundary; replay may diverge. */
  BEST_EFFORT: 'best-effort',
};
 
/**
 * Stable reasons a trace was downgraded to {@link Determinism.BEST_EFFORT}.
 *
 * These are the *detectable* escapes. They are not exhaustive, which is why
 * replay independently verifies determinism by comparing observations rather
 * than trusting this list. See `replay.js`.
 * @readonly
 * @enum {string}
 */
export const NonDeterminismReason = {
  /** State changed without any recorded input to explain it (a stray timer, an outside listener). */
  UNATTRIBUTED_WRITE: 'unattributed-write',
  /** A resource polls on a timer, so its settlement order is wall-clock dependent. */
  POLLING_RESOURCE: 'polling-resource',
  /** A recorded value could not be represented in JSON, so replay cannot restore it. */
  UNSERIALIZABLE_VALUE: 'unserializable-value',
  /** A redaction rule removed a value that replay would need as an input. */
  REDACTED_INPUT: 'redacted-input',
  /** The ring buffer dropped nodes, so the recording is not a complete history. */
  TRUNCATED: 'truncated',
  /** Application code reached a non-deterministic global outside the sandbox boundary. */
  UNSANDBOXED_GLOBAL: 'unsandboxed-global',
};
 
/**
 * The placeholder substituted for a value a redaction rule matched.
 * @type {string}
 */
export const REDACTED = '[redacted]';
 
/**
 * Creates an empty trace envelope.
 * @param {object} [meta] - Free-form metadata describing where the trace came from.
 * @returns {object} A trace with no nodes.
 */
export function createTrace(meta = {}) {
  return {
    traceVersion: TRACE_VERSION,
    id: '',
    createdAt: '',
    determinism: { status: Determinism.DETERMINISTIC, reasons: [] },
    meta: { ...meta },
    /** Recorded non-deterministic global values, keyed by source (`now`, `random`). */
    globals: {},
    /** Property path patterns whose values were withheld from this trace. */
    redactions: [],
    /** How many nodes the ring buffer dropped, if any. */
    dropped: 0,
    nodes: [],
  };
}
 
/**
 * Validates that a value looks like a trace this build can read.
 * @param {any} trace - The candidate trace.
 * @returns {{ok: boolean, error: string}} Why it was rejected, when it was.
 */
export function validateTrace(trace) {
  if (!trace || typeof trace !== 'object') {
    return { ok: false, error: 'Trace is not an object.' };
  }
  if (typeof trace.traceVersion !== 'number') {
    return { ok: false, error: 'Trace is missing a numeric "traceVersion".' };
  }
  if (trace.traceVersion > TRACE_VERSION) {
    return {
      ok: false,
      error: `Trace format version ${trace.traceVersion} is newer than this build understands (${TRACE_VERSION}). Upgrade avenx-core.`,
    };
  }
  if (!Array.isArray(trace.nodes)) {
    return { ok: false, error: 'Trace is missing a "nodes" array.' };
  }
  return { ok: true };
}
 
/**
 * Indexes a trace's nodes by id, so callers can walk `parent` pointers.
 * @param {object} trace - A trace.
 * @returns {Map<number, object>} Nodes keyed by id.
 */
export function indexNodes(trace) {
  const byId = new Map();
  for (const node of trace.nodes) {
    byId.set(node.id, node);
  }
  return byId;
}
 
/**
 * Groups a trace's nodes into `parentId -> children` order-preserving lists.
 * @param {object} trace - A trace.
 * @returns {Map<number|null, object[]>} Children keyed by parent id.
 */
export function groupChildren(trace) {
  const children = new Map();
  for (const node of trace.nodes) {
    const key = node.parent === undefined ? null : node.parent;
    let bucket = children.get(key);
    if (!bucket) {
      bucket = [];
      children.set(key, bucket);
    }
    bucket.push(node);
  }
  return children;
}
 
/**
 * Returns the trace's causal roots: nodes whose parent is absent from the
 * trace, either because they started a chain or because truncation orphaned
 * them.
 * @param {object} trace - A trace.
 * @returns {object[]} Root nodes, in recorded order.
 */
export function rootNodes(trace) {
  const byId = indexNodes(trace);
  return trace.nodes.filter((node) => node.parent === null || node.parent === undefined || !byId.has(node.parent));
}