Source: lib/core/runtime/atomic.js

/**
 * @file atomic.js
 * @description The `atomic()` marker for bridge actions.
 *
 * A component declares a transaction in its template — `<action name="inc"
 * atomic>` — because that is where its actions are declared. A bridge is an
 * ordinary ES module, so there is no attribute to hang the declaration on and
 * it takes a wrapper instead:
 *
 *   import { bridge, atomic } from 'avenx-core/runtime';
 *
 *   export default bridge({
 *     state: { items: [] },
 *     addQty: atomic(function (id, n) { ... }),
 *   });
 *
 * The wrapper does two jobs at once. At runtime it marks the function so the
 * Bridge factory knows to run it inside a transaction. At compile time it is
 * the syntax `BridgeParser` recognises, which is what puts the action's write
 * set into Atlas and its effects into the AVX_W43 report.
 *
 * It returns the same function rather than a new one, so a bridge action keeps
 * its name, its arity and its identity — a wrapper that changed any of those
 * would show up in stack traces and in `fn.length` checks for no reason.
 * @module lib/core/runtime/atomic
 */

import { AvenxError, AvenxErrorCodes } from './AvenxError.js';

/**
 * Marks a bridge action as transactional.
 *
 * A symbol keeps the mark out of the member namespace that `ownKeys` and
 * template scopes see.
 * @type {symbol}
 */
export const ATOMIC_MARK = Symbol.for('avenx.atomic');

/**
 * Declares a bridge action transactional.
 *
 * Every state write the action makes — its own and those of anything it calls
 * — is journaled. If the action throws, or returns a promise that rejects,
 * the journal is played backwards and the state is what it was before the
 * action ran.
 * @template {Function} T
 * @param {T} fn - The action implementation.
 * @param {object} [options] - Transaction options.
 * @param {'safe'|'force'|'abort'} [options.onConflict] - What a rewind does
 *   when it finds a value this transaction did not write. Defaults to the
 *   project's `rewind.onConflict`.
 * @returns {T} The same function, marked.
 * @throws {AvenxError} When given something that is not a function.
 */
export function atomic(fn, options = {}) {
  if (typeof fn !== 'function') {
    throw new AvenxError(
      AvenxErrorCodes.BRIDGE_INVALID_MEMBER,
      'atomic()',
      fn === null ? 'null' : typeof fn,
      'atomic',
    );
  }
  Object.defineProperty(fn, ATOMIC_MARK, {
    value: { onConflict: options.onConflict },
    enumerable: false,
    configurable: true,
    writable: false,
  });
  return fn;
}

/**
 * Reads the transaction options off a marked function.
 * @param {Function} fn - A possible atomic action.
 * @returns {{onConflict: string=}|null} Its options, or null when unmarked.
 */
export function atomicOptions(fn) {
  return (typeof fn === 'function' && fn[ATOMIC_MARK]) || null;
}

/**
 * Whether a function was declared atomic.
 * @param {Function} fn - A possible atomic action.
 * @returns {boolean} True when the function carries the mark.
 */
export function isAtomic(fn) {
  return atomicOptions(fn) !== null;
}