Source: lib/core/reactive/journal.js

/**
 * @file journal.js
 * @description The write journal behind Avenx Rewind.
 *
 * An action marked `atomic` runs inside a *transaction*. Every state write it
 * makes — its own, and those of any bridge action it calls — is journaled, and
 * if it fails the journal is played backwards until the state is what it was
 * before the action ran.
 *
 * ## Why this can live in the framework rather than in a library
 *
 * Every mutation of Avenx state passes through one of a handful of proxy traps
 * in `proxyHandler.js`, and each of those traps already knows the target, the
 * key, the value before and the value after. A journal placed there sees a
 * `state.busy = true`, a `cart.items[2].qty += 1` reached through a bridge, and
 * an `items.push(row)`, without the developer listing any of them.
 *
 * ## Cost when nothing is transactional
 *
 * `journal.active` is a plain own property, read once per write and compared
 * once. The pattern is copied deliberately from `tracer.on`: with no
 * transaction open, no descriptor is allocated and nothing else in the
 * reactive system changes shape.
 *
 * ## Why a rewind restores through the handler
 *
 * Putting a value back on the raw target would leave the UI showing the value
 * that was rolled back, because nothing would wake. Restoring through the same
 * `ProxyHandlerFactory` that recorded the write means a rewind is an ordinary
 * write as far as watchers, the scheduler and the patcher are concerned — the
 * DOM corrects itself with no special case anywhere.
 *
 * ## What it deliberately does not do
 *
 * The journal follows the *dynamic extent* of the action. A write made inside
 * a `.then()` continuation runs after the action has already handed back its
 * promise, so the journal never sees it. That is a real limit, it is reported
 * at build time as AVX_W42, and it is not papered over here.
 * @module lib/core/reactive/journal
 */

import { trigger } from './watcher.js';
import { RAW_SYMBOL } from './symbols.js';
import { logger } from '../runtime/AvenxLogger.js';
import { tracer } from '../trace/tracer.js';
import { traceRewindOutcome, traceRewindStart } from '../trace/transaction.js';
import { AvenxError, AvenxErrorCodes, formatMessage } from '../runtime/AvenxError.js';

/**
 * What a rewind does when it finds a value the transaction did not write.
 * @readonly
 * @enum {string}
 */
export const ConflictPolicy = {
  /** Leave the newer value alone and report it. The default. */
  SAFE: 'safe',
  /** Restore regardless. For a transaction that is the authority on the value. */
  FORCE: 'force',
  /** Restore what is safe, then throw. */
  ABORT: 'abort',
};

/**
 * How many entries a collection may hold before the journal stops snapshotting it.
 *
 * A `push` onto a ten-thousand row list would otherwise copy the list. The
 * limit is not a silent truncation: a collection over it marks the frame
 * partial, and the rewind reports what it could not restore.
 * @type {number}
 */
export const DEFAULT_MAX_SNAPSHOT_ITEMS = 10000;

/**
 * Marks the absence of a value, so that restoring a key that did not exist can
 * be told apart from restoring one whose value was `undefined`.
 * @type {symbol}
 */
const ABSENT = Symbol('avenx.journal.absent');

/**
 * Unwraps a reactive proxy without going through its `get` trap.
 *
 * `toRaw()` would call `track()` and attribute the read to whichever watcher is
 * running, which would make journaling change what the application depends on.
 * @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;
}

/**
 * The size of a journaled collection.
 * @param {object} target - An array, Map or Set.
 * @returns {number} Its entry count.
 */
function collectionSize(target) {
  if (Array.isArray(target)) return target.length;
  if (target instanceof Map || target instanceof Set) return target.size;
  return 0;
}

/**
 * One transaction's record of what it changed.
 *
 * Entries are kept in first-touch order and replayed in reverse, so a key
 * written three times is restored once, to the value it had before the
 * transaction started.
 */
export class JournalFrame {
  /**
   * @param {object} [options] - Frame options.
   * @param {string} [options.owner] - The component or bridge that owns the action.
   * @param {string} [options.name] - The action name, used in diagnostics.
   * @param {string} [options.onConflict] - The conflict policy for this frame.
   * @param {number} [options.maxSnapshotItems] - Collection snapshot ceiling.
   */
  constructor(options = {}) {
    /** @type {string} */
    this.owner = options.owner || '';
    /** @type {string} */
    this.name = options.name || 'anonymous';
    /** @type {string} */
    this.onConflict = options.onConflict || ConflictPolicy.SAFE;
    /** @type {number} */
    this.maxSnapshotItems =
      typeof options.maxSnapshotItems === 'number' && options.maxSnapshotItems > 0
        ? options.maxSnapshotItems
        : DEFAULT_MAX_SNAPSHOT_ITEMS;

    /**
     * Property writes, in first-touch order.
     * @type {Array<object>}
     */
    this.entries = [];
    /**
     * Target to key to entry, for deduplication.
     * @type {Map<object, Map<string|number, object>>}
     */
    this.index = new Map();
    /**
     * Collection savepoints, in first-touch order.
     * @type {Array<object>}
     */
    this.collections = [];
    /** @type {Map<object, object>} */
    this.collectionIndex = new Map();
    /**
     * Paths this frame knows it cannot restore, discovered while recording.
     * @type {string[]}
     */
    this.unrewindable = [];
    /** @type {boolean} */
    this.settled = false;
  }

  /**
   * Whether the frame recorded anything at all.
   * @returns {boolean} True when a rewind would have work to do.
   */
  get empty() {
    return this.entries.length === 0 && this.collections.length === 0;
  }

  /**
   * Records a property write.
   * @param {object} target - The raw object that was mutated.
   * @param {string|number} key - The mutated key.
   * @param {any} oldValue - The value before the mutation.
   * @param {any} newValue - The value after it.
   * @param {boolean} existed - Whether the key was an own property beforehand.
   * @param {object} handler - The ProxyHandlerFactory that owns this target.
   * @returns {void}
   */
  recordWrite(target, key, oldValue, newValue, existed, handler) {
    let keys = this.index.get(target);
    if (!keys) {
      keys = new Map();
      this.index.set(target, keys);
    }
    const existing = keys.get(key);
    if (existing) {
      // A key written twice keeps its original savepoint; only where it ended
      // up matters for conflict detection.
      existing.last = unwrap(newValue);
      return;
    }
    const entry = {
      target,
      key,
      handler,
      first: existed ? unwrap(oldValue) : ABSENT,
      last: unwrap(newValue),
    };
    keys.set(key, entry);
    this.entries.push(entry);
  }

  /**
   * Records a deletion.
   * @param {object} target - The raw object.
   * @param {string|number} key - The deleted key.
   * @param {any} oldValue - The value it held.
   * @param {boolean} existed - Whether the key was there to begin with.
   * @param {object} handler - The ProxyHandlerFactory that owns this target.
   * @returns {void}
   */
  recordDelete(target, key, oldValue, existed, handler) {
    let keys = this.index.get(target);
    if (!keys) {
      keys = new Map();
      this.index.set(target, keys);
    }
    const existing = keys.get(key);
    if (existing) {
      existing.last = ABSENT;
      return;
    }
    const entry = {
      target,
      key,
      handler,
      first: existed ? unwrap(oldValue) : ABSENT,
      last: ABSENT,
    };
    keys.set(key, entry);
    this.entries.push(entry);
  }

  /**
   * Takes a savepoint of a collection before it is mutated.
   *
   * An array method, a `Map.set` or a `Set.add` has no single before-and-after
   * pair to journal — `splice` can move every element at once — so the whole
   * container is copied, once, the first time the transaction touches it.
   * @param {object} target - The raw array, Map or Set.
   * @param {object} handler - The ProxyHandlerFactory that owns this target.
   * @param {string} fallback - What to call it when it has no resolvable path.
   * @returns {void}
   */
  recordCollection(target, handler, fallback) {
    if (this.collectionIndex.has(target)) return;

    // Naming costs a walk up the parent chain, so it happens here — once per
    // collection per transaction — rather than on every `push` that finds a
    // savepoint already taken.
    const label = describe(target, undefined) || fallback;
    const size = collectionSize(target);
    if (size > this.maxSnapshotItems) {
      this.collectionIndex.set(target, null);
      this.unrewindable.push(`${label} (${size} entries exceeds rewind.maxSnapshotItems=${this.maxSnapshotItems})`);
      return;
    }

    let snapshot;
    let kind;
    if (Array.isArray(target)) {
      kind = 'array';
      snapshot = target.slice();
    } else if (target instanceof Map) {
      kind = 'map';
      snapshot = new Map(target);
    } else if (target instanceof Set) {
      kind = 'set';
      snapshot = new Set(target);
    } else {
      return;
    }

    const record = { target, handler, kind, snapshot, label };
    this.collectionIndex.set(target, record);
    this.collections.push(record);
  }

  /**
   * Plays the frame backwards.
   *
   * Property writes are restored through the handler that recorded them, so a
   * rewind wakes watchers exactly as the original write did. Collections are
   * restored in place and then triggered, because there is no single key whose
   * assignment would describe the change.
   * @returns {{restored: number, conflicts: Array<{path: string, expected: any, found: any}>, unrewindable: string[]}}
   *   What the rewind managed to do.
   */
  rewind() {
    const conflicts = [];
    let restored = 0;
    const force = this.onConflict === ConflictPolicy.FORCE;

    for (let i = this.entries.length - 1; i >= 0; i--) {
      const entry = this.entries[i];
      const current = entry.target[entry.key];
      const present = Object.prototype.hasOwnProperty.call(entry.target, entry.key);
      const currentValue = present ? unwrap(current) : ABSENT;

      if (!force && currentValue !== entry.last) {
        conflicts.push({
          path: describe(entry.target, entry.key),
          expected: entry.last === ABSENT ? undefined : entry.last,
          found: currentValue === ABSENT ? undefined : currentValue,
        });
        continue;
      }

      try {
        if (entry.first === ABSENT) {
          entry.handler.deleteProperty(entry.target, entry.key);
        } else {
          entry.handler.set(entry.target, entry.key, entry.first);
        }
        restored++;
      } catch (error) {
        conflicts.push({
          path: describe(entry.target, entry.key),
          expected: entry.last === ABSENT ? undefined : entry.last,
          found: `restore threw: ${(error && error.message) || error}`,
        });
      }
    }

    for (let i = this.collections.length - 1; i >= 0; i--) {
      const record = this.collections[i];
      if (!record) continue;
      try {
        restoreCollection(record);
        restored++;
      } catch (error) {
        conflicts.push({
          path: record.label,
          expected: `${record.kind} savepoint`,
          found: `restore threw: ${(error && error.message) || error}`,
        });
      }
    }

    return { restored, conflicts, unrewindable: this.unrewindable };
  }
}

/**
 * Puts a collection back and wakes whatever depended on it.
 * @param {object} record - A collection savepoint.
 * @returns {void}
 */
function restoreCollection(record) {
  const { target, snapshot, kind, handler } = record;
  if (kind === 'array') {
    target.length = 0;
    for (let i = 0; i < snapshot.length; i++) target[i] = snapshot[i];
    trigger(target, 'length');
  } else if (kind === 'map') {
    const keys = new Set([...target.keys(), ...snapshot.keys()]);
    target.clear();
    for (const [key, value] of snapshot) target.set(key, value);
    trigger(target, [...keys, 'size']);
  } else if (kind === 'set') {
    const keys = new Set([...target.values(), ...snapshot.values()]);
    target.clear();
    for (const value of snapshot) target.add(value);
    trigger(target, [...keys, 'size']);
  }
  if (handler && typeof handler.notifyChange === 'function') {
    handler.notifyChange(target);
  }
}

/**
 * Names a target and key for a conflict report.
 *
 * The full property path is recovered from the parent chain when it is still
 * intact; a detached object falls back to the bare key rather than reporting a
 * path that no longer describes anything.
 * @param {object} target - The raw object.
 * @param {string|number} key - The key.
 * @returns {string} A readable path.
 */
function describe(target, key) {
  try {
    // Injected rather than imported, so the journal does not pull the
    // watcher's naming surface into its own import shape.
    const path = pathResolver ? pathResolver(target, key) : '';
    if (path) return path;
    return key === undefined ? '' : String(key);
  } catch {
    return key === undefined ? '' : String(key);
  }
}

/**
 * How the journal turns a target and key into a readable path.
 * @type {?function(object, *): string}
 */
let pathResolver = null;

/**
 * Installs the property-path resolver used in conflict reports.
 *
 * Set by `proxyHandler.js`, which already imports `getPropertyPath`. Keeping
 * it injected rather than imported means the journal does not have to reach
 * into the watcher's naming rules to produce a diagnostic string.
 * @param {function(object, *): string} resolver - The resolver.
 * @returns {void}
 */
export function setPathResolver(resolver) {
  pathResolver = typeof resolver === 'function' ? resolver : null;
}

/**
 * The transaction stack.
 *
 * A singleton for the same reason the tracer is one: the reactive system is
 * module-scoped, so a per-application journal would have to be threaded
 * through every trap.
 */
class Journal {
  /**
   * Constructs the idle journal installed at import time.
   */
  constructor() {
    /**
     * Hot-path flag. Read by every mutation site, so it is a plain own
     * property rather than a getter over `frames.length`.
     * @type {boolean}
     */
    this.active = false;

    /**
     * Frames whose dynamic extent is currently executing, innermost last.
     * @type {JournalFrame[]}
     */
    this.frames = [];

    /**
     * Project defaults, installed by the application at startup.
     * @type {{onConflict: string, maxSnapshotItems: number}}
     */
    this.defaults = {
      onConflict: ConflictPolicy.SAFE,
      maxSnapshotItems: DEFAULT_MAX_SNAPSHOT_ITEMS,
    };
  }

  /**
   * Applies project-level Rewind configuration.
   * @param {object} [options] - `rewind` from avenx.config.json.
   * @param {string} [options.onConflict] - Default conflict policy.
   * @param {number} [options.maxSnapshotItems] - Default collection ceiling.
   * @returns {void}
   */
  configure(options = {}) {
    if (options.onConflict && Object.values(ConflictPolicy).includes(options.onConflict)) {
      this.defaults.onConflict = options.onConflict;
    }
    if (typeof options.maxSnapshotItems === 'number' && options.maxSnapshotItems > 0) {
      this.defaults.maxSnapshotItems = options.maxSnapshotItems;
    }
  }

  /**
   * The frame currently recording, or null.
   * @returns {JournalFrame|null} The innermost open frame.
   */
  current() {
    return this.frames.length > 0 ? this.frames[this.frames.length - 1] : null;
  }

  /**
   * Records a property write into the open frame.
   * @param {object} target - The raw object that was mutated.
   * @param {string|number} key - The mutated key.
   * @param {any} oldValue - The value before the mutation.
   * @param {any} newValue - The value after it.
   * @param {boolean} existed - Whether the key was an own property beforehand.
   * @param {object} handler - The ProxyHandlerFactory that owns this target.
   * @returns {void}
   */
  recordWrite(target, key, oldValue, newValue, existed, handler) {
    const frame = this.current();
    if (frame) frame.recordWrite(target, key, oldValue, newValue, existed, handler);
  }

  /**
   * Records a deletion into the open frame.
   * @param {object} target - The raw object.
   * @param {string|number} key - The deleted key.
   * @param {any} oldValue - The value it held.
   * @param {boolean} existed - Whether the key was there to begin with.
   * @param {object} handler - The ProxyHandlerFactory that owns this target.
   * @returns {void}
   */
  recordDelete(target, key, oldValue, existed, handler) {
    const frame = this.current();
    if (frame) frame.recordDelete(target, key, oldValue, existed, handler);
  }

  /**
   * Takes a collection savepoint in the open frame.
   * @param {object} target - The raw array, Map or Set.
   * @param {object} handler - The ProxyHandlerFactory that owns this target.
   * @param {string} fallback - What to call it when it has no resolvable path.
   * @returns {void}
   */
  recordCollection(target, handler, fallback) {
    const frame = this.current();
    if (frame) frame.recordCollection(target, handler, fallback);
  }

  /**
   * Opens a frame and makes it the recording target.
   * @param {object} [options] - Frame options; see {@link JournalFrame}.
   * @returns {JournalFrame} The opened frame.
   */
  begin(options = {}) {
    const frame = new JournalFrame({
      onConflict: options.onConflict || this.defaults.onConflict,
      maxSnapshotItems: options.maxSnapshotItems || this.defaults.maxSnapshotItems,
      owner: options.owner,
      name: options.name,
    });
    this.frames.push(frame);
    this.active = true;
    return frame;
  }

  /**
   * Closes a frame's dynamic extent.
   *
   * Truncating to the frame's depth rather than popping blindly means an
   * exception that unwound past a `close` cannot leave the journal recording
   * into a frame that has already finished.
   * @param {JournalFrame} frame - The frame to close.
   * @returns {void}
   */
  close(frame) {
    const index = this.frames.lastIndexOf(frame);
    if (index !== -1) {
      this.frames.length = index;
    }
    this.active = this.frames.length > 0;
  }

  /**
   * Runs a function as a transaction.
   *
   * The outcome is the function's own: it commits by returning, and rewinds by
   * throwing or by handing back a promise that rejects. Nothing else is
   * introduced, because an action already has both of those.
   *
   * A transaction opened while another is recording joins it instead of
   * opening a second frame. That is what makes an atomic bridge action called
   * from an atomic component action undo exactly once.
   * @template T
   * @param {object} spec - `{owner, name, onConflict}`.
   * @param {function(): T} fn - The action body.
   * @returns {T} Whatever `fn` returned.
   */
  run(spec, fn) {
    if (this.frames.length > 0) {
      // Nested: the enclosing transaction already owns these writes.
      return fn();
    }

    const frame = this.begin(spec);
    let result;
    try {
      result = fn();
    } catch (error) {
      this.close(frame);
      this.#rewind(frame, error);
      throw error;
    }

    if (result && typeof result.then === 'function') {
      // The frame leaves the recording stack here — anything the continuation
      // writes is outside the action's dynamic extent — but stays open until
      // the promise says whether it committed.
      this.close(frame);
      return result.then(
        (value) => value,
        (error) => {
          this.#rewind(frame, error);
          throw error;
        },
      );
    }

    this.close(frame);
    return result;
  }

  /**
   * Rewinds a frame and reports whatever it could not restore.
   * @param {JournalFrame} frame - The frame to play backwards.
   * @param {any} [cause] - The failure that triggered the rewind, carried on an
   *   AVX_R29 raised under the `abort` policy so it is not lost.
   * @returns {void}
   */
  #rewind(frame, cause) {
    if (frame.settled) return;
    frame.settled = true;
    if (frame.empty && frame.unrewindable.length === 0) return;

    // A value that changes back on its own is the most confusing thing a trace
    // can contain. Opening a node around the rewind makes every restoring
    // write a child of the transaction that undid it.
    const scope = tracer.on ? traceRewindStart(frame) : { token: -1, id: null };
    let outcome;
    try {
      outcome = frame.rewind();
    } finally {
      if (scope.token >= 0) {
        traceRewindOutcome(scope.id, outcome || { restored: 0, conflicts: [], unrewindable: [] });
        tracer.leave(scope.token);
      }
    }
    const problems = [
      ...outcome.conflicts.map(
        (conflict) => `  ${conflict.path} — wrote ${format(conflict.expected)}, found ${format(conflict.found)}`,
      ),
      ...outcome.unrewindable.map((entry) => `  ${entry}`),
    ];

    if (problems.length === 0) return;

    const label = frame.owner ? `${frame.owner}.${frame.name}` : frame.name;
    const args = [label, problems.length, problems.join('\n'), frame.onConflict];

    if (frame.onConflict !== ConflictPolicy.ABORT) {
      logger.error(formatMessage(AvenxErrorCodes.TRANSACTION_REWIND_FAILED, ...args));
      return;
    }

    // "abort" means the incomplete rewind is the more important failure, so it
    // replaces the error that started it — but the original is what the
    // developer was debugging, so it travels along as the cause rather than
    // being dropped on the floor.
    const failure = new AvenxError(AvenxErrorCodes.TRANSACTION_REWIND_FAILED, ...args);
    if (cause !== undefined) {
      failure.cause = cause;
    }
    throw failure;
  }

  /**
   * Drops every open frame. Used by tests and by teardown.
   * @returns {void}
   */
  reset() {
    this.frames.length = 0;
    this.active = false;
  }
}

/**
 * Renders a value for a conflict report.
 * @param {any} value - The value.
 * @returns {string} A short, readable form.
 */
function format(value) {
  if (typeof value === 'string') return JSON.stringify(value);
  if (value === undefined) return 'undefined';
  if (value === null) return 'null';
  if (typeof value === 'object') {
    try {
      const text = JSON.stringify(value);
      return text && text.length > 80 ? `${text.slice(0, 77)}...` : text;
    } catch {
      return String(value);
    }
  }
  return String(value);
}

/**
 * The journal every mutation site records through.
 * @type {Journal}
 */
export const journal = new Journal();