JSON Model-to-Model Projection (JM2MP) Documentation

JSON Model-to-Model Projection (JM2MP) Documentation

source

modules/resolver.js

The file resolver.js contains the module [normaresolverlizer]{@link module:jm2mp/modules/resolver}, which implements the standardization process over the JM2MP projection modules content in the stage of resolution, before it will be evaluated.

Author

  • Luis Maria CAMARA ROSSI

Copyright

  • Universidad Nacional de Educación a Distancia (U.N.E.D.) 2026

License

  • BSD-3-Clause
/**
 * @author Luis Maria CAMARA ROSSI
 * @copyright Universidad Nacional de Educación a Distancia (U.N.E.D.) 2026
 * @license BSD-3-Clause
 * @file
 * The file `resolver.js` contains the module
 * [normaresolverlizer]{@link module:jm2mp/modules/resolver}, which
 * implements the standardization process over the `JM2MP` _projection modules_
 * content in the stage of resolution, before it will be evaluated.
**/

/**
 * @module module:jm2mp/modules/resolver
 * @description
 * Resolución de módulos: carga, detección de ciclos, normalización por
 * módulo, fusión por importación.
 * 
 * PROTECCIÓN CONTRA CARGA EXCESIVA:
 *  - Detección de ciclos (DFS con conjunto 'visiting').
 *  - Cache de módulos ya cargados (clave normalizada a minúsculas).
 *  - Parámetro `maxModules` que limita el número total de módulos
 *    cargables en una resolución (protección contra DoS y errores
 *    de configuración).
 *
 * NORMALIZACIÓN DE LA CACHÉ DE MÓDULOS:
 * La caché interna usa el nombre del módulo en MINÚSCULAS como clave,
 * para evitar duplicación accidental por diferencias de mayúsculas.
 * El loader, sin embargo, recibe siempre el nombre ORIGINAL tal y como
 * apareció en $depends-on (no normalizado), para que loaders sensibles
 * a mayúsculas (como sistemas de ficheros UNIX) funcionen correctamente
 * en la primera carga. Si dos referencias difieren solo en mayúsculas,
 * la primera carga sí ocurre con su forma original; las siguientes
 * usan la caché.
 *
 * NORMALIZACIÓN POR MÓDULO:
 * Tras cargar cada módulo, se normaliza (se añade $syntax a los $get que
 * lo omitan) según el $default-query-language de ESE módulo, ANTES de fusionar.
 * Esto preserva la sintaxis original de cada plantilla incluso al combinar
 * módulos con sintaxis distintas.
**/

/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */

import { ResolutionError } from "../errors.js";
import { isModule, ROOT_TEMPLATE_NAME } from "./helpers.js";
import { normalizeModule, MODULE_METADATA_KEYS } from "./normalizer.js";

/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */

/**
 * @constant {integer}
 * @description
 * Default value for `maxModules`: `1000`.
**/
export const DEFAULT_MAX_MODULES = 1000;

/* ------------------------------------------------------------------ */

/**
 * @description
 * It imports an `m2` _projection module_ into the accumulated
 * `m1` _projection module.
 *
 * All keys from `m2` overwrites keys from `m1`.
 * 
 * All meta-data properties ($options and $schema) are discarded
 * from the resultant module, so only named templates are preserved.
 * @param {object} dependencies
 * The _accumulated module_ (lower dependency, to the leafs).
 * @param {object} dependant
 * The new _module_ to import (higher dependency, to the root).
 * @returns {object}
 * Resultant _projection module_ with only _named templates_ and
 * no meta-data.
**/
function importInto(dependencies, dependant)
{
  // It combines both modules, with right-to-left precedence.
  const result = { ...dependencies, ...dependant };
  // It ensures that meta-data keys are deleted.
  MODULE_METADATA_KEYS.forEach( (key) => { delete result[key] ; } ) ;
  // It returns the (just created) combined module.
  return result;
}

/* ------------------------------------------------------------------ */

/**
 * *typedef {(name: string) => Promise<object>} LoaderFunction
 * @typedef {Function} LoaderFunction
**/

/* ------------------------------------------------------------------ */

/**
 * @description
 * It resolves a _projection module_ since its root name using `loader`.
 * @param {string} rootName
 * The name of the root _projection module_.
 * @param {LoaderFunction} loader
 * The function that loads a module by its name.
 * The loader receives the original (not normalized) name, as declared
 * in `$.$options.$depends-on` (path) clause.
 * @param {object} [options]
 * An optional configuration object.
 * @param {number} [options.maxModules=1000]
 * Maximum number of unique _projection modules_ that must be loaded by
 * `loader` in this resolution stage (threshold).
 *
 * Its purpose is to protect against extremely long dependency chains,
 * whether accidental or malicious.
 * @returns {Promise<object>}
 * The final resultant _projection module_ already normalized and
 * resolved.
 * @throws {ResolutionError}
 * Whenever a cycle, not loadable modules or undefined root template
 * is found during this resolution stage.
**/
export async function resolve(rootName, loader, options = {})
{
  // Maximum loadable modules (threshold).
  const maxModules = options.maxModules ?? DEFAULT_MAX_MODULES;
  // Loaded modules cache (with names normalized to lowercase).
  const loadedCache = new Map();
  // Sets used to detect cycles and avoid reprocess several times
  // the same module (with name normalized to lowercase).
  const visiting = new Set();
  const visited = new Set();
  // The resultant module.
  let result = {};
  // It starts resolution from the root module.
  await visit(rootName);
  // It validates that the final resultant projection document
  // must contain the root template.
  if (!Object.hasOwn(result, ROOT_TEMPLATE_NAME))
  {
    throw new ResolutionError(
      `resolve: the projection document must contain the root template ` +
      `'${ROOT_TEMPLATE_NAME}', but '${rootName}' does not contain it.`
    );
  }
  return result;

  /**
   * @description
   * Carga un módulo a través del loader, con cache, y lo normaliza inmediatamente.
   * @param {string} name
   * Nombre tal como aparece en $depends-on (sin normalizar).
   * @returns {Promise<object>}
   * .
   * @throws {ResolutionError}
   * - Whenever module `name` cannot be loaded.
   * - Whenever ...
   */
  async function load(name)
  {
    // The resultant module.
    let load_result ;
    // It normalizes the module's name.
    // Must, can or should all names be normalized to, for example, lowercase?
    // This could cause conflicts when the only distinctions are: uppercase, lowercase, and accents.
    // Some file systems distinguish between uppercase and lowercase letters; others do not.
    // What about URLs?
    // What about plain text?
    //// const cacheKey = name;
    const cacheKey = name.toLowerCase();
    // It tests if the module is cached.
    if (loadedCache.has(cacheKey))
    {
      load_result = loadedCache.get(cacheKey);
    }
    // It tests the threshold before loading any other dependency (module).
    else if (loadedCache.size >= maxModules)
    {
      throw new ResolutionError(
        `resolver~load: loaded modules threshold exceeded (maxModules='${maxModules}').`
      );
    }
    else
    {
      // It tries to load the dependency (module).
      let loaded_module;
      try
      {
        loaded_module = await loader(name);
      }
      catch (cause)
      {
        throw new ResolutionError(
          `resolver~load: unable to load module '${name}'.`,
          { cause }
        );
      }
      // It test if the loaded modules is a valid one.
      if ( ! isModule(loaded_module) )
      {
        throw new ResolutionError(
          `resolver~load: named value '${name}' is not a valid module.`
        );
      }
      else
      {
        // It normalizes every loaded module (get.$syntax vs $default-query-language).
        load_result = normalizeModule(loaded_module);
        // It saves the normalized version of each module in the cache.
        loadedCache.set(cacheKey, load_result);
      }
    }
    // It returns the result.
    return load_result;
  }

  /**
   * @description
   * It traverses the dependency tree, visiting recursively every module
   * and all its dependencies.
   * @param {string} name
   * The `name` of the _projection module_.
  **/
  async function visit(name)
  {
    // It normalizes its module's name.
    const normalized_name = name.toLowerCase();
    // It detects cycles in the dependency tree.
    if (visiting.has(normalized_name))
    {
      throw new ResolutionError(
        `resolver~visit: dependency cycle detected on '${name}'.`
      );
    }
    // If already processed, this module is just ommited...
    if (visited.has(normalized_name))
    {
      return;
    }
    // ...otherwise, it is marked as "visiting".
    visiting.add(normalized_name);
    // Then, it loads the module (using the cache) that we are visiting right now.
    const visiting_module = await load(name);
    // It detects the declaration of the full list of dependencies
    // ($.$options.$depends-on) and extracts them.
    const opts = visiting_module.$options;
    const deps = (opts && Array.isArray(opts["$depends-on"]))
                  ? opts["$depends-on"]
                  : [];
    // It validates every dependency declared (as non-empty strings).
    for (const dep of deps)
    {
      if (typeof dep !== "string" || dep.length === 0)
      {
        throw new ResolutionError(
          `resolve~visit: invalid dependency in module '${name}'.`
        );
      }
    }
    // It visits every dependency in same declaration order (left to right).
    for (const dep of deps)
    {
      await visit(dep);
    }
    // After all the dependencies, the module itself is imported over its own
    // dependency (sub)tree, as root (with the highest priority for now).
    /*outer(resolver).*/result = importInto(result, visiting_module);
    // It marks this module as "visited", so unchecking it from "visiting".
    visiting.delete(normalized_name);
    visited.add(normalized_name);
  }

}

/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */
/* End of file: ${JM2MP.JS}/src/modules/resolver.js                   */