Source: src/js/models/dataPackage/DataPackageLoader.js

"use strict";

/* eslint-disable no-param-reassign */
// Disable because we are purposefully mutating the dataPackage passed in to these methods.

define([
  "common/QueryService",
  "common/ErrorUtilities",
  "common/ValueUtilities",
  "models/resourceMap/ResourceMapResolver",
  "collections/ObjectFormats",
], (
  QueryService,
  ErrorUtilities,
  Values,
  ResourceMapResolver,
  ObjectFormats,
) => {
  const DEFAULT_ROWS = 1000;
  const LOAD_PHASES = Object.freeze({
    RESOLVE: "resolve",
    RESOURCE_MAP_MEMBERSHIP: "resourceMapMembership",
    RESOURCE_MAP_DOWNLOAD: "resourceMapDownload",
    RESOURCE_MAP_SUMMARY: "resourceMapSummary",
    INDEX_MANIFEST: "indexManifest",
    MISSING_SYSTEM_METADATA: "missingSystemMetadata",
    EDITABLE_BASELINE: "editableBaseline",
    EDITABLE_METADATA: "editableMetadata",
  });
  const ERROR_MSGS = Object.freeze({
    MISSING_PID: "DataPackage resolution requires a PID",
  });
  const SOLR_MANIFEST_FIELDS = Object.freeze([
    "id",
    "seriesId",
    "resourceMap",
    "documents",
    "isDocumentedBy",
    "fileName",
    "obsoletes",
    "obsoletedBy",
    "size",
    "formatType",
    "formatId",
    "datasource",
    "rightsHolder",
    "pubDate",
    "dateUploaded",
    "archived",
    "isPublic",
    "title",
    "origin",
    "prov_instanceOfClass",
    "isPlaceHolder_b",
  ]);
  const { FORMAT_IDS, FORMAT_TYPES } = ObjectFormats.prototype;
  const RESOURCE_MAP_FORMAT_ID = FORMAT_IDS.RESOURCE_MAP;
  const { isAbortError, throwIfAborted } = ErrorUtilities;
  let DataPackageLoader;

  /**
   * Classify a ResourceMap resolver result without changing package state.
   * @param {object} resolverResult ResourceMap resolver result
   * @param {string} inputId Input PID or SID
   * @returns {object} Structured DataPackage resolution result
   */
  function classifyResolverResult(resolverResult, inputId) {
    const meta = resolverResult.meta || {};
    const candidateMetadataPids = meta.metadataCandidates || [];
    const resolvedPid = resolverResult.pid || inputId;
    const result = {
      success: false,
      type: meta.formatType || null,
      isData: meta.isData || false,
      isMetadata: meta.isMetadata || false,
      isResourceMap: meta.isResourceMap || false,
      resolvedPid,
    };

    if (resolverResult.multipleRMs) {
      result.multipleRMs = true;
      result.candidateResourceMapPids = meta.rms || [];
      result.candidateResourceMapDates = meta.resourceMapDates || {};
      result.candidateMetadataPids = candidateMetadataPids;
    }
    if (meta.newerResourceMapUnavailable) {
      result.newerResourceMapUnavailable = true;
    }
    if (resolverResult.unauthorized) result.unauthorized = true;

    return result;
  }

  /**
   * Seed package members and identity fields from a resolver result.
   * @param {DataPackage} dataPackage Package being loaded
   * @param {object} resolverResult ResourceMap resolver result
   * @param {object} result Structured DataPackage resolution result
   * @returns {void}
   */
  function seedResolvedPackageIdentity(dataPackage, resolverResult, result) {
    const meta = resolverResult.meta || {};
    const candidateMetadataPids = meta.metadataCandidates || [];
    const { indexMatch } = meta;
    const idMatch = indexMatch?.id;
    const { resolvedPid } = result;
    const addOptions = { merge: true, sources: ["resourceMapResolver"] };

    if (indexMatch) {
      dataPackage.members.add({ pid: idMatch, ...indexMatch }, addOptions);
    }
    if (idMatch !== resolvedPid) {
      dataPackage.members.add({ pid: resolvedPid }, addOptions);
    }
    if (result.isMetadata && resolvedPid) {
      dataPackage.primaryMetadataPid = resolvedPid;
    } else if (candidateMetadataPids.length === 1) {
      const [candidateMetadataPid] = candidateMetadataPids;
      dataPackage.primaryMetadataPid = candidateMetadataPid;
    }

    if (resolverResult.rm) {
      dataPackage.members.add(
        {
          pid: resolverResult.rm,
          formatId: RESOURCE_MAP_FORMAT_ID,
          formatType: FORMAT_TYPES.RESOURCE,
        },
        addOptions,
      );
      dataPackage.rootResourceMapPid = resolverResult.rm;
      result.success = true;
    }
  }

  /**
   * Seed and announce metadata that is safe to display during resolution.
   * @param {DataPackage} dataPackage Package being loaded
   * @param {object} properties Metadata member properties
   */
  function seedProgressiveMetadata(dataPackage, properties) {
    const pid = properties.id || properties.pid;
    dataPackage.members.add(
      { ...properties, pid },
      { merge: true, sources: ["resourceMapResolver"] },
    );
    const metadata = dataPackage.members.get(pid);
    dataPackage.primaryMetadataPid = pid;
    dataPackage.events.trigger("load:metadata", { metadata, pid });
  }

  /**
   * Classify an unresolved object from its system metadata.
   * @param {DataPackage} dataPackage Package being loaded
   * @param {ResourceMapResolver} resolver Resolver used for the initial lookup
   * @param {object} result Structured DataPackage resolution result
   * @param {object} [options] Fallback options
   * @param {AbortSignal} [options.signal] Abort signal
   * @returns {Promise<void>} Resolves after the fallback attempt
   * @throws {Error} When loading is aborted
   */
  async function applySystemMetadataFallback(
    dataPackage,
    resolver,
    result,
    { signal } = {},
  ) {
    const { resolvedPid } = result;
    try {
      const sysMeta = await resolver.getSysMeta(resolvedPid, { signal });
      throwIfAborted(signal, "Data package resolution cancelled");
      if (!sysMeta) {
        result.notFound = true;
        return;
      }

      dataPackage.members.add(sysMeta, {
        merge: true,
        sources: ["sysMeta"],
      });
      const { objectFormats } = dataPackage;
      let formatType = objectFormats.getFormatType(sysMeta);
      if (!formatType && objectFormats.fetchingPromise) {
        await objectFormats.fetchingPromise.catch(() => objectFormats);
        throwIfAborted(signal, "Data package resolution cancelled");
        formatType = objectFormats.getFormatType(sysMeta);
      }
      result.type = formatType || null;
      result.isData = formatType === FORMAT_TYPES.DATA;
      result.isMetadata = formatType === FORMAT_TYPES.METADATA;
      result.isResourceMap = sysMeta.formatId
        ? objectFormats.isResourceMap(sysMeta)
        : formatType === FORMAT_TYPES.RESOURCE;
      if (formatType) {
        dataPackage.members.add(
          {
            pid: resolvedPid,
            formatId: sysMeta.formatId,
            formatType,
          },
          { merge: true, sources: ["sysMeta"] },
        );
      }
      result.isIndexing = true;
      if (result.isResourceMap) {
        result.success = true;
        dataPackage.rootResourceMapPid = resolvedPid;
      } else if (result.isMetadata) {
        dataPackage.primaryMetadataPid = resolvedPid;
      }
    } catch (error) {
      if (isAbortError(error)) throw error;
      if (error?.status === 401 || error?.status === 403) {
        result.isPrivate = true;
      } else if (error?.status === 404) {
        result.notFound = true;
      } else {
        // eslint-disable-next-line no-console
        console.error(`Error fetching sysMeta for PID ${resolvedPid}:`, error);
        result.error = error;
      }
    }
  }

  /**
   * Resolve and load authoritative ResourceMap membership for editing.
   * @param {DataPackage} dataPackage Package being loaded
   * @param {string} pid Input PID or SID for any package member
   * @param {object} [options] Load options
   * @param {AbortSignal} [options.signal] Abort signal
   * @returns {Promise<DataPackageMember>} Root ResourceMap member
   * @throws {Error} When authoritative membership cannot be loaded
   */
  async function loadEditableResourceMapMembership(
    dataPackage,
    pid,
    options = {},
  ) {
    const { signal } = options;
    try {
      await dataPackage.resolveFromPid(pid, options);
    } catch (error) {
      if (isAbortError(error)) throw error;
      if (Array.isArray(error?.issues) && error.issues.length) {
        throw DataPackageLoader.resourceMapNotEditableError({
          inputId: dataPackage.inputId || pid,
          rootResourceMapPid:
            error?.details?.resourceMapPid || error?.resourceMapPid || null,
          issues: error.issues,
          cause: error,
        });
      }
      throw error;
    }

    let resourceMapMember = dataPackage.getRootResourceMapMember();
    if (!resourceMapMember) {
      const resolution = dataPackage.resolutionResult || {};
      // Only a clean, unambiguous miss is safe to repair as orphaned metadata.
      let reason = "missing";
      if (resolution.multipleRMs) {
        reason = null;
      } else if (resolution.unauthorized || resolution.isPrivate) {
        reason = "unauthorized";
      } else if (resolution.error) {
        reason = "error";
      }
      throw DataPackageLoader.resourceMapUnavailableError({
        inputId: dataPackage.inputId,
        reason,
        httpStatus: resolution.error?.status ?? null,
        cause: resolution.error || null,
      });
    }

    try {
      const [currentResourceMapPid] = await dataPackage
        .getVersionTracker()
        .getLatestVersions([resourceMapMember.pid], { signal });
      if (!currentResourceMapPid) {
        throw new Error(
          `Cannot determine the latest version of "${resourceMapMember.pid}"`,
        );
      }
      if (currentResourceMapPid !== resourceMapMember.pid) {
        dataPackage.members.add(
          {
            pid: currentResourceMapPid,
            formatId: RESOURCE_MAP_FORMAT_ID,
            formatType: FORMAT_TYPES.RESOURCE,
          },
          { merge: true, sources: ["resourceMapResolver"] },
        );
        dataPackage.rootResourceMapPid = currentResourceMapPid;
        resourceMapMember = dataPackage.getRootResourceMapMember();
      }
    } catch (error) {
      if (isAbortError(error)) throw error;
      throw DataPackageLoader.resourceMapUnavailableError({
        inputId: dataPackage.inputId || pid,
        rootResourceMapPid: resourceMapMember.pid,
        reason: "error",
        httpStatus: error?.status ?? null,
        cause: error,
      });
    }

    let resourceMapResult;
    try {
      await DataPackageLoader.reportLoadProgress(
        dataPackage,
        LOAD_PHASES.RESOURCE_MAP_MEMBERSHIP,
        {
          inputId: dataPackage.inputId,
        },
      );
      resourceMapResult = await dataPackage.getManifestFromResourceMap({
        requireEditable: true,
        signal,
      });
    } catch (error) {
      if (isAbortError(error)) throw error;
      if (error?.code === "resource_map_not_editable") throw error;
      resourceMapResult = {
        ok: false,
        reason: "error",
        httpStatus: error?.status ?? null,
        error,
      };
    }
    if (!resourceMapResult?.ok) {
      throw DataPackageLoader.resourceMapUnavailableError({
        inputId: dataPackage.inputId,
        rootResourceMapPid: resourceMapMember.pid,
        reason: resourceMapResult?.reason || null,
        httpStatus: resourceMapResult?.httpStatus ?? null,
        cause: resourceMapResult?.error || null,
      });
    }

    const resourceMapPids = dataPackage.members
      .getFromSource("resourceMap")
      .map((member) => member.pid);
    // Metadata and ResourceMap histories can diverge: EML A may be most recent
    // while its ResMap R1 has newer version R2. R2 contains EML B, not A. So if
    // the user has navigated to submit/A, we must reject R2. Since we cannot
    // edit an older ResMap, this package can't be edited. Can happen when A and
    // B are actually versions of eachother but their obsolesnce chain is
    // broken, for example.
    const requestedMetadataPid = dataPackage.resolutionResult?.isMetadata
      ? dataPackage.resolutionResult.resolvedPid
      : null;
    if (
      requestedMetadataPid &&
      !resourceMapPids.includes(requestedMetadataPid)
    ) {
      throw DataPackageLoader.resourceMapUnavailableError({
        inputId: dataPackage.inputId,
        rootResourceMapPid: resourceMapMember.pid,
        reason: "metadata_not_aggregated",
      });
    }

    dataPackage.members.retain(resourceMapPids);
    return resourceMapMember;
  }

  /**
   * Enrich authoritative members and require primary metadata.
   * @param {DataPackage} dataPackage Package being loaded
   * @param {DataPackageMember} resourceMapMember Root ResourceMap member
   * @param {object} [options] Enrichment options
   * @param {number|null} [options.maxMembers] Maximum editable member count,
   * excluding the root ResourceMap
   * @param {AbortSignal} [options.signal] Abort signal
   * @returns {Promise<DataPackageMember>} Primary metadata member
   * @throws {Error} When the member limit or baseline requirement fails
   */
  async function enrichEditableMembers(
    dataPackage,
    resourceMapMember,
    { maxMembers = null, signal } = {},
  ) {
    const memberCount = dataPackage.getContentMembers().length;
    if (maxMembers && memberCount > maxMembers) {
      throw DataPackageLoader.memberLimitExceededError({
        inputId: dataPackage.inputId,
        rootResourceMapPid: resourceMapMember.pid,
        memberCount,
        maxMembers,
      });
    }

    await dataPackage.getManifestFromIndex({
      onlyExisting: true,
      // The index query also returns the root ResourceMap.
      rows: maxMembers ? maxMembers + 1 : DEFAULT_ROWS,
      signal,
    });

    const unclassifiedPids = dataPackage.members
      .getActiveMembers()
      .filter((member) => !member.getFormatType())
      .map((member) => member.pid);
    if (unclassifiedPids.length) {
      await DataPackageLoader.reportLoadProgress(
        dataPackage,
        LOAD_PHASES.MISSING_SYSTEM_METADATA,
        {
          count: unclassifiedPids.length,
        },
      );
      const failures = await dataPackage.fetchSysMeta(unclassifiedPids, {
        signal,
      });
      const abortFailure = failures.find(({ error }) => isAbortError(error));
      if (abortFailure) throw abortFailure.error;
      throwIfAborted(signal, "Editable package loading cancelled");
    }

    const primaryMetadata = dataPackage.getPrimaryMetadataMember();
    if (!primaryMetadata) {
      throw DataPackageLoader.editableBaselineUnavailableError({
        inputId: dataPackage.inputId,
        rootResourceMapPid: resourceMapMember.pid,
        missingMembers: ["primaryMetadata"],
      });
    }
    return primaryMetadata;
  }

  /**
   * Load baseline system metadata and initialize editable member state.
   * @param {DataPackage} dataPackage Package being loaded
   * @param {DataPackageMember} resourceMapMember Root ResourceMap member
   * @param {DataPackageMember} primaryMetadata Primary metadata member
   * @param {object} [options] Initialization options
   * @param {AbortSignal} [options.signal] Abort signal
   * @param {boolean} [options.fetchPrimaryMetadata] Whether to fetch metadata
   * content
   * @returns {Promise<void>} Resolves when editable state is ready
   * @throws {Error} When required system metadata is unavailable
   */
  async function initializeEditableBaseline(
    dataPackage,
    resourceMapMember,
    primaryMetadata,
    { signal, fetchPrimaryMetadata = false } = {},
  ) {
    const baselineMembers = [resourceMapMember, primaryMetadata];
    const baselinePids = baselineMembers.map((member) => member.pid);
    await DataPackageLoader.reportLoadProgress(
      dataPackage,
      LOAD_PHASES.EDITABLE_BASELINE,
      { count: baselinePids.length },
    );
    const failures = await dataPackage.fetchSysMeta(baselinePids, { signal });
    const abortFailure = failures.find(({ error }) => isAbortError(error));
    if (abortFailure) throw abortFailure.error;
    throwIfAborted(signal, "Editable package loading cancelled");
    const failedPids = Values.dedupeStrings([
      ...failures.map(({ pid: failedPid }) => failedPid),
      ...baselineMembers
        .filter((member) => !member.sysMeta)
        .map((member) => member.pid),
    ]);
    if (failedPids.length) {
      throw DataPackageLoader.editableBaselineUnavailableError({
        inputId: dataPackage.inputId,
        rootResourceMapPid: resourceMapMember.pid,
        failedPids,
        causes: failures.map(({ error }) => error).filter(Boolean),
      });
    }

    dataPackage.members.toArray().forEach((member) => {
      member.initializeEditableState({
        remotePid: member.pid,
        aggregatedPid: member.pid,
        sysMeta: member.sysMeta,
        remoteSysMeta: member.sysMeta,
      });
    });

    if (fetchPrimaryMetadata && !primaryMetadata.objectModel) {
      await DataPackageLoader.reportLoadProgress(
        dataPackage,
        LOAD_PHASES.EDITABLE_METADATA,
      );
      await primaryMetadata.fetchObject({ signal });
    }
  }

  /**
   * Load and enrich data packages from resource maps and the Solr index.
   * @namespace DataPackageLoader
   * @classcategory Models/DataPackage
   * @since 2.39.0
   */
  DataPackageLoader = {
    LoadPhases: LOAD_PHASES,

    /**
     * Publish a package loading phase and give listeners a chance to paint
     * before the next expensive synchronous step.
     * @param {DataPackage} dataPackage Package being loaded
     * @param {string} phase Loading phase from
     * {@link DataPackageLoader.LoadPhases}
     * @param {object} [details] Additional progress details
     * @returns {Promise<void>} Resolves after the next animation frame, when
     * available
     */
    async reportLoadProgress(dataPackage, phase, details = {}) {
      if (!phase) return;
      dataPackage.events.trigger("load:progress", { ...details, phase });
      if (
        typeof window !== "undefined" &&
        typeof window.requestAnimationFrame === "function"
      ) {
        await new Promise((resolve) => {
          window.requestAnimationFrame(resolve);
        });
      }
    },

    /**
     * Resolve the single root resource map for an input PID. This seeds only
     * the input object and resolved resource map shell; package manifests are
     * loaded separately.
     * @param {DataPackage} dataPackage Package being loaded
     * @param {string} pid Input PID or SID
     * @param {object} [options] Resolver options
     * @returns {Promise<object>} Structured resolution result
     * @throws {Error} When resource map resolution fails
     */
    async resolveFromPid(dataPackage, pid, options = {}) {
      const { signal } = options;
      const inputId = Values.requireNonEmptyString(pid, ERROR_MSGS.MISSING_PID);
      dataPackage.inputId = inputId;
      await DataPackageLoader.reportLoadProgress(
        dataPackage,
        LOAD_PHASES.RESOLVE,
        { inputId },
      );
      const resolverOptions = { ...(options.resolverOptions || {}) };
      if (
        !resolverOptions.objectService &&
        (dataPackage.objectService ||
          dataPackage.objectServiceOptions?.readBaseUrl)
      ) {
        resolverOptions.objectService = dataPackage.getObjectService();
      }
      if (
        !resolverOptions.metaServiceUrl &&
        (dataPackage.sysMetaService ||
          dataPackage.sysMetaServiceOptions?.readBaseUrl)
      ) {
        resolverOptions.metaServiceUrl =
          dataPackage.getSysMetaService().readBaseUrl;
      }
      const resolver = new ResourceMapResolver(resolverOptions);
      let sidIndexResults;
      let eligibleMetadataPid = inputId;
      resolver.events.on(`update:${inputId}`, ({ status, meta = {} }) => {
        DataPackageLoader.reportLoadProgress(dataPackage, LOAD_PHASES.RESOLVE, {
          inputId,
        });

        if (status === ResourceMapResolver.STATUS.indexSearchComplete) {
          if (meta.isSid) sidIndexResults = meta.indexResults;
          if (
            meta.isMetadata &&
            !meta.isSid &&
            meta.indexMatch.id === eligibleMetadataPid
          ) {
            seedProgressiveMetadata(dataPackage, meta.indexMatch);
          }
        } else if (
          status === ResourceMapResolver.STATUS.seriesIdResolved &&
          dataPackage.objectFormats.isMetadata({ formatId: meta.formatId })
        ) {
          eligibleMetadataPid = meta.resolvedPid;
          const indexMatch = sidIndexResults.find(
            (doc) => doc.id === meta.resolvedPid,
          );
          seedProgressiveMetadata(dataPackage, {
            ...indexMatch,
            pid: meta.resolvedPid,
            formatId: indexMatch?.formatId || meta.formatId,
            formatType: indexMatch?.formatType || FORMAT_TYPES.METADATA,
            seriesId: indexMatch?.seriesId || meta.sid,
          });
        }
      });
      const resolveOptions = { fields: SOLR_MANIFEST_FIELDS };
      if (signal) resolveOptions.signal = signal;
      const resMapResult = await resolver.resolve(inputId, resolveOptions);
      throwIfAborted(signal, "Data package resolution cancelled");

      const result = classifyResolverResult(resMapResult, inputId);
      result.inputId = inputId;
      seedResolvedPackageIdentity(dataPackage, resMapResult, result);

      if (!result.success && !resMapResult.meta?.indexMatch) {
        await applySystemMetadataFallback(dataPackage, resolver, result, {
          signal,
        });
      }

      if (!result.success) {
        resolver.trackMissingResourceMap(inputId);
      }
      dataPackage.resolutionResult = result;
      return result;
    },

    /**
     * Load an existing package into a deterministic editable baseline.
     * @param {DataPackage} dataPackage Package being loaded
     * @param {string} pid Input PID or SID for any package member
     * @param {object} [options] Load options
     * @returns {Promise<DataPackage>} Package once the editable baseline is
     * ready
     * @throws {Error} When an editable package baseline cannot be built
     */
    async loadEditablePackage(dataPackage, pid, options = {}) {
      const { signal } = options;
      const maxMembers = Values.normalizePositiveInteger(
        options.maxMembers,
        null,
      );

      const resourceMapMember = await loadEditableResourceMapMembership(
        dataPackage,
        pid,
        options,
      );
      const primaryMetadata = await enrichEditableMembers(
        dataPackage,
        resourceMapMember,
        { maxMembers, signal },
      );
      await initializeEditableBaseline(
        dataPackage,
        resourceMapMember,
        primaryMetadata,
        {
          signal,
          fetchPrimaryMetadata: options.fetchPrimaryMetadata,
        },
      );

      return dataPackage;
    },

    /**
     * Build the error thrown when an authoritative ResourceMap cannot be
     * loaded for editing.
     * @param {object} [details] Error details
     * @param {string} [details.inputId] Input PID being loaded
     * @param {string} [details.rootResourceMapPid] Root ResourceMap PID
     * @param {string} [details.reason] Failure reason
     * @param {number} [details.httpStatus] HTTP status, when known
     * @param {Error} [details.cause] Underlying error
     * @returns {Error} Error with `code === "resource_map_unavailable"`
     */
    resourceMapUnavailableError({
      inputId = null,
      rootResourceMapPid = null,
      reason = null,
      httpStatus = null,
      cause = null,
    } = {}) {
      const detail = reason || (httpStatus != null ? `HTTP ${httpStatus}` : "");
      const error = new Error(
        `Resource map unavailable for editable loading${
          inputId ? ` of "${inputId}"` : ""
        }${detail ? ` (${detail})` : ""}`,
      );
      error.code = "resource_map_unavailable";
      error.inputId = inputId;
      error.rootResourceMapPid = rootResourceMapPid;
      if (reason) error.reason = reason;
      if (httpStatus != null) error.httpStatus = httpStatus;
      if (cause) error.cause = cause;
      return error;
    },

    /**
     * Build the error thrown when a ResourceMap is available but its structured
     * RDF conflicts make normal editing unsafe.
     * @param {object} [details] Error details
     * @param {string} [details.inputId] Input PID being loaded
     * @param {string} [details.rootResourceMapPid] Root ResourceMap PID
     * @param {object[]} [details.issues] Blocking ResourceMap issues
     * @param {Error} [details.cause] Underlying ownership error
     * @returns {Error} Error with `code === "resource_map_not_editable"`
     */
    resourceMapNotEditableError({
      inputId = null,
      rootResourceMapPid = null,
      issues = [],
      cause = null,
    } = {}) {
      const error = new Error(
        `Resource map is not safe to edit${
          rootResourceMapPid ? ` ("${rootResourceMapPid}")` : ""
        }`,
      );
      error.code = "resource_map_not_editable";
      error.inputId = inputId;
      error.rootResourceMapPid = rootResourceMapPid;
      error.issues = issues
        .filter((issue) => issue && typeof issue === "object")
        .map((issue) => ({ ...issue }));
      if (cause) error.cause = cause;
      return error;
    },

    /**
     * Build the error thrown when package membership exceeds the configured
     * editable limit.
     * @param {object} [details] Error details
     * @param {string} [details.inputId] Input PID being loaded
     * @param {string} [details.rootResourceMapPid] Root ResourceMap PID
     * @param {number} [details.memberCount] Actual member count
     * @param {number} [details.maxMembers] Configured editable member limit
     * @returns {Error} Error with `code === "package_member_limit_exceeded"`
     */
    memberLimitExceededError({
      inputId = null,
      rootResourceMapPid = null,
      memberCount = 0,
      maxMembers = null,
    } = {}) {
      const error = new Error(
        `Package member count ${memberCount} exceeds editable limit ${
          maxMembers || "unknown"
        }${inputId ? ` for "${inputId}"` : ""}`,
      );
      error.code = "package_member_limit_exceeded";
      error.inputId = inputId;
      error.rootResourceMapPid = rootResourceMapPid;
      error.memberCount = memberCount;
      error.maxMembers = maxMembers;
      return error;
    },

    /**
     * Build the error thrown when required editable sysmeta is unavailable.
     * @param {object} [details] Error details
     * @param {string} [details.inputId] Input PID being loaded
     * @param {string} [details.rootResourceMapPid] Root ResourceMap PID
     * @param {string[]} [details.failedPids] PIDs whose sysmeta failed
     * @param {Error[]} [details.causes] Underlying sysmeta errors
     * @param {string[]} [details.missingMembers] Required missing member roles
     * @returns {Error} Error with `code === "editable_baseline_unavailable"`
     */
    editableBaselineUnavailableError({
      inputId = null,
      rootResourceMapPid = null,
      failedPids = [],
      causes = [],
      missingMembers = [],
    } = {}) {
      const error = new Error(
        `Required system metadata unavailable for editable loading${
          inputId ? ` of "${inputId}"` : ""
        }`,
      );
      error.code = "editable_baseline_unavailable";
      error.inputId = inputId;
      error.rootResourceMapPid = rootResourceMapPid;
      error.failedPids = [...failedPids];
      error.causes = [...causes];
      error.missingMembers = [...missingMembers];
      if (causes.length) [error.cause] = causes;
      return error;
    },

    /**
     * Enrich package members from the Solr index.
     * @param {DataPackage} dataPackage Package being loaded
     * @param {object} [options] Index manifest options
     * @param {boolean} [options.onlyExisting] Skip members not already present
     * @param {number} [options.rows] Solr row limit
     * @param {AbortSignal} [options.signal] Abort signal
     * @returns {Promise<object>} Load result details
     */
    async getManifestFromIndex(
      dataPackage,
      { onlyExisting = false, rows = DEFAULT_ROWS, signal } = {},
    ) {
      const resourceMapPid =
        dataPackage.rootResourceMapPid ||
        dataPackage.getRootResourceMapMember()?.pid;

      if (!resourceMapPid) {
        return {
          ok: false,
          details: {
            inputId: dataPackage.inputId,
            rootResourceMapPid: null,
          },
        };
      }

      await DataPackageLoader.reportLoadProgress(
        dataPackage,
        LOAD_PHASES.INDEX_MANIFEST,
        { rootResourceMapPid: resourceMapPid },
      );
      throwIfAborted(signal, "Package index manifest load cancelled");
      const query = [
        QueryService.getQueryPart("resourceMap", resourceMapPid),
        QueryService.getQueryPart("id", resourceMapPid),
      ].join(" OR ");
      const response = await QueryService.queryWithFetch({
        q: query,
        fields: SOLR_MANIFEST_FIELDS,
        rows: rows ?? DEFAULT_ROWS,
        archived: true,
        usePost: true,
        signal,
      });
      throwIfAborted(signal, "Package index manifest load cancelled");
      const results = QueryService.parseResponse(response);
      dataPackage.members.add(results, {
        merge: true,
        onlyExisting,
        sources: ["index"],
      });
      const total = response?.response?.numFound ?? results.length;
      return {
        ok: true,
        details: {
          inputId: dataPackage.inputId,
          rootResourceMapPid: resourceMapPid,
          count: results.length,
          total,
          source: "index",
        },
      };
    },

    /**
     * Load titles for nested ResourceMaps from their metadata documents.
     * @param {DataPackage} dataPackage Package being enriched
     * @param {object} [options] Fetch options
     * @param {AbortSignal} [options.signal] Abort signal
     * @returns {Promise<boolean>} Whether any member title changed
     */
    async loadNestedPackageTitles(dataPackage, { signal } = {}) {
      const nestedPackages = dataPackage
        .getNestedResourceMapMembers()
        .filter((member) => !member.title);
      if (!nestedPackages.length) return false;

      const nestedPackagesByPid = new Map(
        nestedPackages.map((member) => [member.pid, member]),
      );
      const resourceMapQuery = nestedPackages
        .map((member) => QueryService.getQueryPart("resourceMap", member.pid))
        .join(" OR ");
      const metadataQuery = QueryService.getQueryPart("formatType", "METADATA");

      throwIfAborted(signal, "Nested package title load cancelled");
      const response = await QueryService.queryWithFetch({
        q: `${metadataQuery} AND (${resourceMapQuery})`,
        fields: ["id", "resourceMap", "title"],
        rows: DEFAULT_ROWS,
        archived: true,
        usePost: true,
        signal,
      });
      throwIfAborted(signal, "Nested package title load cancelled");

      let changed = false;
      QueryService.parseResponse(response).forEach((doc) => {
        if (!doc.title) return;
        (doc.resourceMap || []).forEach((resourceMapPid) => {
          const member = nestedPackagesByPid.get(resourceMapPid);
          if (!member || member.title) return;
          member.title = doc.title;
          changed = true;
        });
      });
      return changed;
    },

    /**
     * Load and parse the authoritative ResourceMap manifest.
     * @param {DataPackage} dataPackage Package being loaded
     * @param {object} [options] ResourceMap manifest options
     * @param {boolean} [options.requireEditable] Reject ResourceMaps with
     * blocking identity or relationship issues before projecting package
     * membership
     * @param {AbortSignal} [options.signal] Abort signal
     * @returns {Promise<object>} Load result details
     * @throws {Error} When loading is aborted or editable validation fails
     */
    async getManifestFromResourceMap(
      dataPackage,
      { requireEditable = false, signal } = {},
    ) {
      const resourceMapMember = dataPackage.getRootResourceMapMember();
      const resourceMapPid =
        dataPackage.rootResourceMapPid || resourceMapMember?.pid;
      if (!resourceMapMember) {
        return {
          ok: false,
          details: {
            inputId: dataPackage.inputId,
            rootResourceMapPid: null,
          },
        };
      }
      await DataPackageLoader.reportLoadProgress(
        dataPackage,
        LOAD_PHASES.RESOURCE_MAP_DOWNLOAD,
        { rootResourceMapPid: resourceMapPid },
      );
      throwIfAborted(signal, "Resource map manifest load cancelled");
      let resourceMap = resourceMapMember.objectModel;
      if (!resourceMap) {
        try {
          resourceMap = await resourceMapMember.fetchObject({
            signal,
            objectService: dataPackage.getObjectService(),
            resolveServiceUrl: dataPackage.resolverOptions.resolveServiceUrl,
          });
          throwIfAborted(signal, "Resource map manifest load cancelled");
        } catch (error) {
          if (isAbortError(error)) throw error;
          if (Array.isArray(error?.issues) && error.issues.length) {
            throw DataPackageLoader.resourceMapNotEditableError({
              inputId: dataPackage.inputId,
              rootResourceMapPid: resourceMapPid,
              issues: error.issues,
              cause: error,
            });
          }
          const httpStatus = error?.status ?? null;
          const unauthorized = httpStatus === 401 || httpStatus === 403;
          // A 404 confirms absence; other failures must never trigger repair.
          let reason = "error";
          if (httpStatus === 404) reason = "missing";
          else if (unauthorized) reason = "unauthorized";
          const result = {
            ok: false,
            reason,
            httpStatus,
            details: {
              inputId: dataPackage.inputId,
              rootResourceMapPid: resourceMapPid,
            },
          };
          if (!unauthorized) result.error = error;
          return result;
        }
      }
      await DataPackageLoader.reportLoadProgress(
        dataPackage,
        LOAD_PHASES.RESOURCE_MAP_SUMMARY,
        { rootResourceMapPid: resourceMapPid },
      );
      throwIfAborted(signal, "Resource map manifest load cancelled");
      if (requireEditable) {
        const blockers = resourceMap.getEditBlockers?.() || [];
        if (blockers.length) {
          throw DataPackageLoader.resourceMapNotEditableError({
            inputId: dataPackage.inputId,
            rootResourceMapPid: resourceMapPid,
            issues: blockers,
          });
        }
      }
      const summary = resourceMap.getSummary();
      const rootResourceMapPid = summary.resourceMapPid || resourceMapPid;
      dataPackage.rootResourceMapPid = rootResourceMapPid;
      const resourceMapMembers = summary.members.map((member) => {
        // Metadata can itself be documented, so known formats take precedence.
        let formatType = dataPackage.getMember(member.pid)?.getFormatType();
        if (!formatType) {
          if (member.documents?.length) {
            formatType = FORMAT_TYPES.METADATA;
          } else if (member.isDocumentedBy?.length) {
            formatType = FORMAT_TYPES.DATA;
          }
        }
        return {
          ...member,
          resourceMap: [rootResourceMapPid],
          formatType: formatType || member.formatType,
        };
      });
      const metadataPids = resourceMapMembers
        .filter((member) => member.formatType === FORMAT_TYPES.METADATA)
        .map((member) => member.pid)
        .filter(Boolean);
      if (metadataPids.length === 1) {
        const [metadataPid] = metadataPids;
        dataPackage.primaryMetadataPid = metadataPid;
      } else if (!metadataPids.includes(dataPackage.primaryMetadataPid)) {
        dataPackage.primaryMetadataPid = null;
      }
      const members = [
        ...resourceMapMembers,
        {
          pid: rootResourceMapPid,
          formatId: RESOURCE_MAP_FORMAT_ID,
          formatType: FORMAT_TYPES.RESOURCE,
          resourceMapUri: summary.resourceMapUri,
          modified: summary.modified,
          creatorName: summary.creatorName,
        },
      ];

      dataPackage.members.add(members, {
        merge: true,
        sources: ["resourceMap"],
      });
      dataPackage.refreshMemberGraphFields(resourceMap);
      dataPackage.resourceManifestIsFetched = true;
      return {
        ok: true,
        details: {
          inputId: dataPackage.inputId,
          rootResourceMapPid,
          count: members.length,
          source: "resourceMap",
        },
      };
    },
  };

  return DataPackageLoader;
});

/* eslint-enable no-param-reassign */