Source: src/js/models/resourceMap/ResourceMap.js

"use strict";

define([
  "common/DataONEXmlUtilities",
  "common/XMLUtilities",
  "common/DateUtilities",
  "common/UrlUtilities",
  "common/ValidationUtilities",
  "common/ValueUtilities",
  "models/resourceMap/RDFGraph",
  "models/resourceMap/ResourceMapNormalization",
  "models/resourceMap/ResourceMapCommon",
  "models/resourceMap/ResourceMapState",
  "models/resourceMap/ResourceMapStructureMutation",
  "models/resourceMap/ResourceMapValidation",
  "models/resourceMap/Provenance",
], (
  DataONEXML,
  XMLUtilities,
  DateUtilities,
  UrlUtilities,
  ValidationUtilities,
  ValueUtilities,
  RDFGraph,
  ResourceMapNormalization,
  ResourceMapCommon,
  ResourceMapState,
  ResourceMapStructureMutation,
  ResourceMapValidation,
  Provenance,
) => {
  const { createValidationException, createValidationReport } =
    ValidationUtilities;
  const { isNonEmptyString, normalizeText, requireNonEmptyString, makeUUID } =
    ValueUtilities;
  const { ResourceMapConflictError } = ResourceMapCommon;

  const { ParseError } = XMLUtilities;

  /**
   * Trim a service URL and ensure it ends with a slash.
   * @param {string} value Candidate base URL.
   * @returns {string|null} Normalized base URL or `null` when invalid.
   */
  function normalizeBase(value) {
    return (
      UrlUtilities.normalizeUrl(value, "", {
        trailingSlash: "ensure",
      }) || null
    );
  }

  /**
   * A relationship between metadata and the data it documents. RDF stores the
   * relationship in both directions, and both objects must be package members.
   * @typedef {object} ResMapDocLink
   * @property {string} metadataPid Documenting metadata PID
   * @property {string} dataPid Documented member PID
   */

  /**
   * One package member supplied when creating a new Resource Map.
   * @typedef {object} ResourceMapCreateMember
   * @property {string} pid DataONE PID for the member
   * @property {string[]} [atLocations] Original storage locations or paths
   */

  /**
   * @typedef {object} ResourceMapMember
   * @property {string} pid DataONE PID for the package member.
   * @property {string} uri Exact URI used for the member in the RDF graph.
   * @property {string[]} isDocumentedBy PIDs of package members that document
   * this object.
   * @property {string[]} documents PIDs of package members documented by this
   * object.
   * @property {string[]} atLocations Original storage locations or paths from
   * `prov:atLocation`, when present.
   */

  /**
   * Plain package summary used by callers and JSON output.
   * @typedef {object} ResourceMapSummary
   * @property {string} resourceMapPid DataONE PID for the resource map object.
   * @property {string} resourceMapUri Exact RDF URI for the Resource Map
   * document.
   * @property {string} aggregationUri Exact RDF URI for the package described by
   * the Resource Map.
   * @property {string} resolveServiceUrl CN resolve service base URL used for
   * new RDF nodes and for replacing matching endpoint URLs during import.
   * @property {ResourceMapMember[]} members Package members.
   * @property {ResMapDocLink[]} documentationLinks Links from metadata to the
   * data it documents.
   * @property {string|null} creatorName Creator name stored on the resource
   * map, when present.
   * @property {string|null} modified Modified timestamp stored on the resource
   * map, when present.
   * @property {object} provenance Package history and program relationships.
   */

  /**
   * Read, edit, validate, and serialize one DataONE Resource Map. A Resource Map
   * is an RDF document that lists a package's members and describes
   * relationships among them.
   *
   * This class stores the normalized RDF graph, including membership, links from
   * metadata to data, locations, creator and modified values, and package
   * history. Callers should edit through ResourceMap methods so cached package
   * summaries stay in sync. Imported graphs are repaired only when the graph
   * determines the intended statement without guessing. The graph remains the
   * source of truth. `getSummary()` gives the UI a simpler view that cannot
   * change the graph.
   *
   * Import converts configured endpoint URLs to CN resolver URLs when their
   * identifiers agree. Foreign URLs, conflicting destinations, and
   * `prov:atLocation` targets are preserved. Map and aggregation identities
   * change only when `setResourceMapPid()` assigns a new version.
   * @class
   * @classcategory Models/ResourceMap
   * @since 2.39.0
   */
  class ResourceMap {
    /**
     * @param {object} options Constructor options
     * @param {string} options.resourceMapPid DataONE PID for the resource map
     * object
     * @param {string} options.resolveServiceUrl CN resolve endpoint for new
     * nodes and URLs converted during import
     * @param {string} [options.objectServiceUrl] Object service base used to
     * recognize managed endpoint URLs during import
     * @param {IndexedFormula|RDFGraph} [options.graph] Existing RDF graph to
     * adopt. Used when constructing a ResourceMap from parsed RDF/XML
     */
    constructor({
      resourceMapPid,
      resolveServiceUrl,
      objectServiceUrl,
      graph,
    } = {}) {
      this.resourceMapPid = requireNonEmptyString(
        resourceMapPid,
        "resourceMapPid required",
      );

      this.resolveServiceUrl = requireNonEmptyString(
        normalizeBase(
          requireNonEmptyString(
            resolveServiceUrl,
            "resolveServiceUrl required",
          ),
        ),
        "resolveServiceUrl required",
      );
      this.objectServiceUrl = normalizeBase(objectServiceUrl);

      const hasSuppliedGraph = graph !== undefined && graph !== null;
      this.graph =
        graph instanceof RDFGraph ? graph : new RDFGraph(graph || undefined);

      this.ns = ResourceMapCommon.NS;

      const selectedRoot = hasSuppliedGraph
        ? ResourceMapNormalization.selectImportedRoot(
            this.graph,
            this.resourceMapPid,
            this,
          )
        : null;
      if (selectedRoot) {
        this.resourceMapUri = selectedRoot.resourceMapUri;
        this.aggregationUri = selectedRoot.aggregationUri;
      } else {
        // Only a new Resource Map may create its document and aggregation URIs.
        // An imported graph must already identify one exact pair.
        this.resourceMapUri = this.pidToUri(this.resourceMapPid);
        this.aggregationUri = `${this.resourceMapUri}#aggregation`;
      }
      this.graphState = new ResourceMapState({ resourceMap: this });
      this.graphMutationDepth = 0;
      /**
       * Identified endpoint nodes waiting for the grouped edit to finish.
       * @type {NamedNode[]}
       * @since 2.41.0
       */
      this.pendingEndpointNodes = [];
      this.unsavedChanges = false;

      this.provenance = new Provenance({ resourceMap: this });
      this.structureMutation = new ResourceMapStructureMutation({
        resourceMap: this,
      });
      this.normalization = new ResourceMapNormalization({
        resourceMap: this,
      });

      let identity = null;
      if (hasSuppliedGraph) {
        // Compare every imported PID claim before any repair or cached summary
        // can add the expected PID and hide a contradiction.
        identity = ResourceMapValidation.inspectRawIdentity(this);
        if (!identity.issues.length) {
          this.mutateGraph(
            () =>
              this.normalization.repairImportedGraph(
                identity,
                selectedRoot.recoveredFromInverse,
              ),
            { markDirty: false, rollbackOnError: true },
          );
        }
      }

      if (!hasSuppliedGraph || !identity.issues.length) {
        this.normalizeGraph();
      }
      this.unsavedChanges = false;
    }

    /**
     * Build the default upload filename for a resource map PID.
     * @param {string} pid Resource map PID
     * @returns {string} Resource map filename
     */
    static defaultFileName(pid) {
      return `${pid.replace(/[^a-zA-Z0-9]/g, "_")}.rdf.xml`;
    }

    /**
     * Create a new Resource Map from a list of members and package
     * relationships.
     * @param {object} [options] Structured package values used to seed the
     * graph.
     * @param {string} [options.resourceMapPid] PID assigned to the resource map
     * object. A generated PID is used when omitted.
     * @param {string} options.resolveServiceUrl Resolve service base URL used
     * to create member URIs
     * @param {string} [options.objectServiceUrl] Object service base used only
     * to recognize exact managed URLs
     * @param {Array.<ResourceMapCreateMember>} [options.members] Package members
     * and their optional original storage locations or paths.
     * @param {string[]} [options.memberPids] Convenience shorthand for a plain
     * list of member PIDs without `prov:atLocation` values. Any PID already
     * present in `members` is ignored.
     * @param {ResMapDocLink[]} [options.documentationLinks] Links from metadata
     * to documented data
     * @param {string} [options.creatorName] Creator name to write onto the map.
     * @param {string|Date} [options.modified] Modified timestamp to store in
     * the format of an xsd:dateTime literal, e.g. `2024-01-01T12:00:00Z`.
     * @returns {ResourceMap} Newly created resource map instance.
     */
    static create({
      resourceMapPid,
      resolveServiceUrl,
      objectServiceUrl,
      members = [],
      memberPids = [],
      documentationLinks = [],
      creatorName,
      modified,
    } = {}) {
      const resourceMap = new ResourceMap({
        resourceMapPid:
          resourceMapPid ||
          makeUUID({ prefix: ResourceMap.RESOURCE_MAP_PID_PREFIX }),
        resolveServiceUrl,
        objectServiceUrl,
      });

      if (isNonEmptyString(creatorName)) {
        const resourceMapNode = RDFGraph.createNamedNode(
          resourceMap.resourceMapUri,
        );
        const creatorNode = RDFGraph.createBlankNode();
        resourceMap.mutateGraph(() => {
          resourceMap.graph.addStatementIfMissing({
            subject: resourceMapNode,
            predicate: resourceMap.ns.DC("creator"),
            object: creatorNode,
          });
          resourceMap.graph.addStatementIfMissing({
            subject: creatorNode,
            predicate: resourceMap.ns.FOAF("name"),
            object: RDFGraph.createLiteral(
              requireNonEmptyString(creatorName, "Creator required"),
              undefined,
              resourceMap.ns.XSD("string"),
            ),
          });
          resourceMap.graph.addStatementIfMissing({
            subject: creatorNode,
            predicate: resourceMap.ns.RDF("type"),
            object: resourceMap.ns.DCTERMS("Agent"),
          });
        });
      }

      resourceMap.setModified(modified || new Date().toISOString());
      resourceMap.setPackageStructure(
        [...members.map((member) => member.pid), ...(memberPids || [])],
        documentationLinks,
      );

      resourceMap.setMemberLocations(
        members.filter((member) => member.atLocations !== undefined),
      );
      return resourceMap;
    }

    /**
     * Parse RDF/XML and repair unambiguous package statements and configured
     * endpoint URLs. Import repairs do not mark the model unsaved.
     * @param {string} resourceMapPid PID expected for the resource map object
     * @param {string} resourceMapXml RDF/XML serialization
     * @param {object} options Parse options
     * @param {string} options.resolveServiceUrl CN resolve endpoint for new
     * nodes and URLs converted during import
     * @param {string} [options.objectServiceUrl] Current object service base used
     * only to recognize exact managed URLs
     * @returns {ResourceMap} Parsed resource map instance
     * @throws {Error} When required input is missing or the RDF/XML is invalid
     */
    static fromXml(resourceMapPid, resourceMapXml, options = {}) {
      requireNonEmptyString(resourceMapPid, "resourceMapPid required");
      const resolveServiceUrl = requireNonEmptyString(
        options.resolveServiceUrl,
        "resolveServiceUrl required",
      );
      const normalizedXml = requireNonEmptyString(
        resourceMapXml,
        "resourceMapXml required",
      );

      DataONEXML.parseRequiredDocument(normalizedXml, "ResourceMap XML");

      const graph = new RDFGraph();

      try {
        // Do not use the deployment URL or fetch URL as the RDF base. An
        // explicit absolute xml:base still applies. Without one, relative
        // identities remain relative and validation blocks editing.
        graph.parseXml(normalizedXml, "");
      } catch (error) {
        throw new ParseError(
          `Parse failed: ${error?.message || "Unknown parse error"}`,
        );
      }

      return new ResourceMap({
        resourceMapPid,
        graph,
        resolveServiceUrl,
        objectServiceUrl: options.objectServiceUrl,
      });
    }

    /** @returns {boolean} Whether a grouped graph mutation is in progress. */
    isGraphMutating() {
      return this.graphMutationDepth > 0;
    }

    /** @returns {boolean} Whether the graph has changed since loading or save. */
    hasUnsavedChanges() {
      return this.unsavedChanges;
    }

    /** @returns {ResourceMap} Resource map with a cleared dirty flag. */
    markSaved() {
      this.unsavedChanges = false;
      return this;
    }

    /**
     * Add the DataONE PID identifier to an RDF resource if it is missing.
     * @param {string} uri RDF subject URI
     * @param {string} pid DataONE PID
     * @returns {ResourceMap} Updated resource map
     */
    ensureIdentifierForUri(uri, pid) {
      const node = RDFGraph.createNamedNode(
        requireNonEmptyString(uri, "URI required"),
      );
      const normalizedPid = requireNonEmptyString(pid, "PID required");
      this.graph.addStatementIfMissing({
        subject: node,
        predicate: this.ns.DCTERMS("identifier"),
        object: RDFGraph.createLiteral(
          normalizedPid,
          undefined,
          this.ns.XSD("string"),
        ),
      });
      return this;
    }

    /**
     * Add a DataONE PID identifier and remove specified obsolete identifiers
     * from an RDF resource.
     * @param {string} uri RDF subject URI
     * @param {string} pid DataONE PID
     * @param {object} [options] Identifier update options
     * @param {string[]} [options.removeValues] Identifier values to remove
     * @returns {ResourceMap} Updated resource map
     */
    setIdentifierForUri(uri, pid, { removeValues = [] } = {}) {
      const node = RDFGraph.createNamedNode(
        requireNonEmptyString(uri, "URI required"),
      );
      const normalizedPid = requireNonEmptyString(pid, "PID required");
      const valuesToRemove = new Set(
        removeValues
          .map(normalizeText)
          .filter(
            (value) => isNonEmptyString(value) && value !== normalizedPid,
          ),
      );

      if (valuesToRemove.size) {
        this.graph
          .findStatements({
            subject: node,
            predicate: this.ns.DCTERMS("identifier"),
          })
          .filter((statement) =>
            valuesToRemove.has(normalizeText(statement.object?.value)),
          )
          .forEach((statement) => {
            this.graph.removeStatement(statement);
          });
      }

      return this.ensureIdentifierForUri(uri, normalizedPid);
    }

    /**
     * Return a plain package summary built from the RDF graph.
     * @returns {ResourceMapSummary} Package summary
     */
    getSummary() {
      return this.graphState.getSummary();
    }

    /** @returns {string[]} Aggregated member PIDs. */
    getMemberPids() {
      return this.graphState.getMemberPids();
    }

    /**
     * Remove package members and managed relationships attached to their exact
     * RDF nodes.
     * @param {string[]} pids PIDs to remove
     * @returns {ResourceMap} Updated resource map
     */
    removeMembers(pids) {
      (Array.isArray(pids) ? pids : [pids]).forEach((pid) => {
        this.resolveMemberNode(pid);
      });
      return this.structureMutation.removeMembers(pids);
    }

    /**
     * Change a package member's PID wherever managed package relationships use
     * it.
     * @param {string} oldPid Existing member PID
     * @param {string} newPid Replacement member PID
     * @returns {ResourceMap} Updated resource map
     * @throws {ResourceMapConflictError} When the replacement PID is already
     * aggregated or its destination node has conflicting identity.
     */
    replaceMember(oldPid, newPid) {
      this.resolveMemberNode(oldPid);
      this.resolveMemberNode(newPid);
      return this.structureMutation.replaceMember(oldPid, newPid);
    }

    /**
     * Set all package members and metadata documentation links in one edit.
     * If any step fails, the whole edit is rolled back.
     * @param {string[]} pids Desired member PIDs
     * @param {ResMapDocLink[]} links Desired documentation links
     * @returns {ResourceMap} Updated resource map
     */
    setPackageStructure(pids, links) {
      this.getMemberPids().forEach((pid) => this.resolveMemberNode(pid));
      return this.structureMutation.setPackageStructure(pids, links);
    }

    /** @returns {ResMapDocLink[]} Links from metadata to documented data. */
    getDocumentationLinks() {
      return this.graphState.getDocumentationLinks();
    }

    /**
     * Record in both directions that a metadata member documents a data member.
     * @param {string} metadataPid Documenting metadata PID.
     * @param {string} dataPid Documented member PID.
     * @returns {ResourceMap} Updated resource map.
     */
    linkDocumentation(metadataPid, dataPid) {
      return this.structureMutation.linkDocumentation(metadataPid, dataPid);
    }

    /**
     * Remove both RDF directions of a link from metadata to documented data.
     * @param {string} metadataPid Documenting metadata PID.
     * @param {string} dataPid Documented member PID.
     * @returns {ResourceMap} Updated resource map.
     */
    unlinkDocumentation(metadataPid, dataPid) {
      return this.structureMutation.unlinkDocumentation(metadataPid, dataPid);
    }

    /**
     * Replace all links from metadata to documented data.
     * @param {ResMapDocLink[]} links Desired documentation links.
     * @returns {ResourceMap} Updated resource map.
     */
    setDocumentationLinks(links) {
      this.getMemberPids().forEach((pid) => this.resolveMemberNode(pid));
      return this.structureMutation.setDocumentationLinks(links);
    }

    /**
     * Replace the stored source locations or paths for multiple members.
     * @param {Array<{pid: string, atLocations: string[]}>} locationUpdates
     * Member location updates. An empty `atLocations` array clears locations
     * @returns {ResourceMap} Updated resource map
     */
    setMemberLocations(locationUpdates) {
      const updatesByPid = new Map();
      (Array.isArray(locationUpdates) ? locationUpdates : []).forEach(
        ({ pid, atLocations }) => {
          const member = this.resolveMemberNode(pid, {
            required: true,
            message: "Member PID required",
          });
          if (!Array.isArray(atLocations)) {
            throw new Error("atLocations must be an array");
          }
          updatesByPid.set(member.pid, {
            node: member.node,
            atLocations: atLocations.map((path) => {
              requireNonEmptyString(path, "Path required");
              return path;
            }),
          });
        },
      );
      if (!updatesByPid.size) {
        return this;
      }

      return this.mutateGraph(
        () => {
          updatesByPid.forEach(({ node, atLocations }) => {
            this.graph.removeStatementsMatching({
              subject: node,
              predicate: this.ns.PROV("atLocation"),
            });
            atLocations.forEach((rawPath) => {
              this.graph.addStatementIfMissing({
                subject: node,
                predicate: this.ns.PROV("atLocation"),
                object: RDFGraph.createLiteral(rawPath),
              });
            });
          });
        },
        { rollbackOnError: true },
      );
    }

    /**
     * Replace the stored source location or path for one member.
     * @param {string} pid Aggregated member PID
     * @param {string} path Raw location value
     * @returns {ResourceMap} Updated resource map
     */
    setLocation(pid, path) {
      return this.setMemberLocations([
        {
          pid,
          atLocations: [path],
        },
      ]);
    }

    /**
     * Add one source location or path to a package member.
     * @param {string} pid Aggregated member PID
     * @param {string} path Raw location value
     * @returns {ResourceMap} Updated resource map
     */
    addLocation(pid, path) {
      const member = this.resolveMemberNode(pid, {
        required: true,
        message: "Member PID required",
      });
      requireNonEmptyString(path, "Path required");

      return this.mutateGraph(() => {
        this.ensureIdentifierForUri(member.uri, member.pid);
        this.graph.addStatementIfMissing({
          subject: member.node,
          predicate: this.ns.PROV("atLocation"),
          object: RDFGraph.createLiteral(path),
        });
      });
    }

    /**
     * Remove one matching location, or every location when no path is given.
     * @param {string} pid Member PID.
     * @param {string} [path] Specific raw location value to remove.
     * @returns {ResourceMap} Updated resource map.
     */
    removeLocation(pid, path) {
      const { node: memberNode } = this.resolveMemberNode(pid, {
        required: true,
        message: "Member PID required",
      });
      const rawPath = path ?? null;
      if (rawPath !== null) {
        requireNonEmptyString(rawPath, "Path required");
      }

      return this.mutateGraph(() => {
        if (rawPath !== null) {
          this.graph
            .findStatements({
              subject: memberNode,
              predicate: this.ns.PROV("atLocation"),
            })
            .filter(
              (statement) =>
                RDFGraph.getLiteralValue(statement.object) === rawPath,
            )
            .forEach((statement) => {
              this.graph.removeStatement(statement);
            });
          return;
        }

        this.graph.removeStatementsMatching({
          subject: memberNode,
          predicate: this.ns.PROV("atLocation"),
        });
      });
    }

    /**
     * Change the Resource Map PID and update the map and package URIs that
     * depend on it.
     * @param {string} pid Replacement PID for the resource map object.
     * @returns {ResourceMap} Updated resource map instance.
     */
    setResourceMapPid(pid) {
      const normalizedPid = requireNonEmptyString(pid, "PID required");
      if (normalizedPid === this.resourceMapPid) return this;

      return this.mutateGraph(() => {
        const oldResourceMapPid = this.resourceMapPid;
        const oldResourceMapUri = this.resourceMapUri;
        const oldAggregationUri = this.aggregationUri;

        this.resourceMapPid = normalizedPid;
        this.resourceMapUri = this.pidToUri(normalizedPid);
        this.aggregationUri = `${this.resourceMapUri}#aggregation`;

        this.graph.replaceNodeValues(
          new Map([
            [oldResourceMapUri, this.resourceMapUri],
            [oldAggregationUri, this.aggregationUri],
          ]),
        );
        this.setIdentifierForUri(this.resourceMapUri, normalizedPid, {
          removeValues: [oldResourceMapPid],
        });
      });
    }

    /**
     * Set the resource map modified timestamp.
     * @param {string|Date} modified ISO timestamp or `Date`.
     * @returns {ResourceMap} Updated resource map instance.
     */
    setModified(modified) {
      const normalizedModified =
        modified instanceof Date
          ? DateUtilities.toISOString(modified)
          : normalizeText(modified);

      requireNonEmptyString(normalizedModified, "Modified required");

      return this.mutateGraph(() => {
        const resourceMapNode = RDFGraph.createNamedNode(this.resourceMapUri);
        this.graph.removeStatementsMatching({
          subject: resourceMapNode,
          predicate: this.ns.DCTERMS("modified"),
        });
        this.graph.removeStatementsMatching({
          subject: resourceMapNode,
          predicate: this.ns.DC("modified"),
        });
        this.graph.addStatementIfMissing({
          subject: resourceMapNode,
          predicate: this.ns.DCTERMS("modified"),
          object: RDFGraph.createLiteral(
            normalizedModified,
            this.ns.XSD("dateTime"),
          ),
        });
      });
    }

    /**
     * Check whether the Resource Map can be edited and published safely.
     * @returns {object[]} Validation issues.
     */
    validate() {
      return ResourceMapValidation.validateResourceMap(this);
    }

    /**
     * Return only validation errors that block editing and validated output.
     * @returns {object[]} Error severity validation issues
     */
    getEditBlockers() {
      return createValidationReport(this.validate()).errors;
    }

    /**
     * Repair missing or duplicate package statements only when the graph makes
     * the intended value certain.
     * @param {object} [options] Normalization options.
     * @param {boolean} [options.markDirty] Mark normalization changes unsaved.
     * @returns {ResourceMap} Updated resource map instance.
     * @throws {ResourceMapConflictError} When the graph no longer contains the
     * same Resource Map document and aggregation selected during construction.
     */
    normalize({ markDirty = false } = {}) {
      const selectedRoot = ResourceMapNormalization.selectImportedRoot(
        this.graph,
        this.resourceMapPid,
        this,
      );
      if (
        selectedRoot.resourceMapUri !== this.resourceMapUri ||
        selectedRoot.aggregationUri !== this.aggregationUri
      ) {
        throw new ResourceMapConflictError(
          "Cannot normalize because the Resource Map no longer describes the same aggregation.",
          {
            code: "ambiguousResourceMapRoot",
            details: {
              resourceMapPid: this.resourceMapPid,
              expectedResourceMapUri: this.resourceMapUri,
              expectedAggregationUri: this.aggregationUri,
              selectedResourceMapUri: selectedRoot.resourceMapUri,
              selectedAggregationUri: selectedRoot.aggregationUri,
            },
          },
        );
      }

      const identity = ResourceMapValidation.inspectRawIdentity(this);
      if (identity.issues.length) {
        throw createValidationException(
          "ResourceMap identity validation failed",
          identity.issues,
        );
      }
      // Build the cached graph index before editing. ResourceMapState refuses
      // to build a new index during an edit because it would describe a partly
      // updated graph.
      this.graphState.getIndex();
      return this.mutateGraph(() => this.normalizeGraph(), {
        markDirty,
        rollbackOnError: true,
      });
    }

    /**
     * Convert the current graph to RDF/XML, optionally rejecting validation
     * errors first.
     * @param {object} [options] Serialization options.
     * @param {boolean} [options.validate] Throw when validation errors remain
     * in the current graph.
     * @returns {string} RDF/XML serialization of the current graph.
     * @throws {Error} When validation is requested and the graph is invalid
     */
    serialize({ validate = true } = {}) {
      if (validate) {
        const validationErrors = this.validate();
        if (!createValidationReport(validationErrors).valid) {
          throw createValidationException(
            "ResourceMap validation failed",
            validationErrors,
          );
        }
      }

      // Data and Program types may be explicit in imported RDF or implied by
      // provenance relationships. Add implied types only to serialized output
      // so the DataONE indexer and other ProvONE tools can read them without
      // changing the graph being edited.
      // Keep ore:Aggregation typing because it is part of both the validated
      // graph and DataONE's documented Resource Map shape.
      const statements = this.graph.getStatements({ copy: true });

      const memberDescriptors = this.graphState.getMemberDescriptors();
      if (validate && memberDescriptors.length === 1) {
        const memberNode = RDFGraph.createNamedNode(memberDescriptors[0].uri);
        // DataONE's Solr indexer expects a singleton package to say that its
        // member documents itself, even when the user added no documentation
        // links. This is an indexer compatibility requirement. Add either
        // missing direction only to validated output, without changing the
        // graph being edited.
        [this.ns.CITO("documents"), this.ns.CITO("isDocumentedBy")].forEach(
          (predicate) => {
            if (
              !this.graph.hasStatement({
                subject: memberNode,
                predicate,
                object: memberNode,
              })
            ) {
              statements.push(
                RDFGraph.createStatement(memberNode, predicate, memberNode),
              );
            }
          },
        );
      }

      statements.push(...this.provenance.deriveRoleTypeStatements());
      return this.graph.serializeStatementsToXml(statements);
    }

    /**
     * Convert a PID into its URI under the configured DataONE resolve service.
     * @param {string} pid PID to encode as a resolve service URI.
     * @returns {string} Canonical resolve URI for the PID.
     */
    pidToUri(pid) {
      const normalizedPid = requireNonEmptyString(pid, "PID required");
      const resolveServiceUrl = requireNonEmptyString(
        this.resolveServiceUrl,
        "resolveServiceUrl required",
      );
      return UrlUtilities.buildUrl(
        resolveServiceUrl,
        UrlUtilities.encodeDataONEPidForPath(normalizedPid),
        { encodePath: false },
      );
    }

    /**
     * Return the URI already used for a PID, or create its configured resolve
     * URI when the graph does not contain one.
     * @param {string} pid PID whose current RDF subject URI should be resolved.
     * @returns {string} Existing graph URI or configured resolve URI.
     */
    getNodeUriForPid(pid) {
      const normalizedPid = requireNonEmptyString(pid, "PID required");
      return (
        this.graphState.findNodeUriForPid(normalizedPid) ||
        this.pidToUri(normalizedPid)
      );
    }

    /**
     * Find the single exact RDF node representing a package member. Return
     * `null` when the PID is not a member, and reject multiple matching member
     * nodes instead of guessing.
     * @param {string} pid Member PID to inspect
     * @param {object} [options] Resolution options
     * @param {boolean} [options.required] Throw when the PID is not a package
     * member
     * @param {string} [options.message] Missing/invalid PID error prefix
     * @returns {{pid: string, uri: string, node: NamedNode}|null} Sole exact
     * membership node, when present
     * @throws {ResourceMapConflictError} When multiple exact member URIs claim
     * the PID
     */
    resolveMemberNode(
      pid,
      { required = false, message = "Member PID required" } = {},
    ) {
      const normalizedPid = requireNonEmptyString(pid, message);
      const memberUris = this.graphState.getMemberUris(normalizedPid);
      if (memberUris.length > 1) {
        throw new ResourceMapConflictError(
          `Cannot edit member "${normalizedPid}" because more than one RDF resource is aggregated with that PID.`,
          {
            code: "ambiguousMemberPid",
            details: { pid: normalizedPid, memberUris },
          },
        );
      }
      const [uri] = memberUris;
      if (!uri) {
        if (required) {
          throw new Error(`${message}: "${normalizedPid}" is not aggregated`);
        }
        return null;
      }
      return { pid: normalizedPid, uri, node: RDFGraph.createNamedNode(uri) };
    }

    /**
     * Apply one grouped RDF edit while keeping cached summaries, change
     * tracking, and optional rollback consistent. Use this only when the public
     * ResourceMap methods cannot express the edit.
     * @param {Function} mutator Graph mutation callback.
     * @param {object} [options] Mutation bookkeeping options.
     * Graph mutations that affect derived JSON reads should flow through this
     * method or other `ResourceMap` APIs so the cached summary stays coherent
     * @param {boolean} [options.markDirty] Mark a successful outer mutation
     * unsaved. When false, preserve the previous dirty state.
     * @param {boolean} [options.rollbackOnError] Restore the graph if the
     * outermost mutation throws.
     * @returns {ResourceMap} Updated resource map instance.
     * @throws {Error} When the mutation callback fails
     */
    mutateGraph(mutator, { markDirty = true, rollbackOnError = false } = {}) {
      // Only the outer mutation copies state, rolls back failures, and records
      // unsaved changes for the full nested edit.
      const isOuterMutation = !this.isGraphMutating();
      const shouldRollback = isOuterMutation && rollbackOnError;
      const previous = {
        statements: shouldRollback
          ? this.graph.getStatements({ copy: true })
          : null,
        resourceMapPid: this.resourceMapPid,
        resourceMapUri: this.resourceMapUri,
        aggregationUri: this.aggregationUri,
        unsavedChanges: this.unsavedChanges,
      };

      this.graphMutationDepth += 1;
      try {
        mutator();

        if (isOuterMutation) {
          // Normalize endpoints once after the group finishes. A later edit
          // may have removed or renamed a queued node.
          this.normalization.normalizeEndpointUrls(
            this.pendingEndpointNodes.filter((subject) =>
              this.graph.hasStatement({
                subject,
                predicate: this.ns.DCTERMS("identifier"),
              }),
            ),
          );
          this.unsavedChanges = markDirty ? true : previous.unsavedChanges;
        }
      } catch (error) {
        if (shouldRollback) {
          this.graph.restoreStatements(previous.statements);
          this.resourceMapPid = previous.resourceMapPid;
          this.resourceMapUri = previous.resourceMapUri;
          this.aggregationUri = previous.aggregationUri;
          this.unsavedChanges = previous.unsavedChanges;
        } else if (isOuterMutation) {
          this.unsavedChanges = true;
        }
        throw error;
      } finally {
        if (isOuterMutation) {
          this.pendingEndpointNodes = [];
          this.graphState.invalidate();
        }
        this.graphMutationDepth -= 1;
      }

      return this;
    }

    /**
     * Ensure the graph contains the required package/member statements, remove
     * duplicate statements, and add only unambiguous provenance details.
     * @private
     */
    normalizeGraph() {
      // Keep each member's current RDF URI. Rebuilding every URI from
      // its PID would move valid members that came from another Coordinating
      // Node. pidToUri() creates URIs only for new members.
      const memberDescriptors = this.graphState.getMemberDescriptors();
      this.mutateGraph(
        () => {
          this.normalization.synchronizeCoreGraph(memberDescriptors);
          this.graph.dedupeStatements();
        },
        { markDirty: false, rollbackOnError: true },
      );
      this.provenance.normalize();
    }
  }

  ResourceMap.RESOURCE_MAP_PID_PREFIX = "resource_map_";

  return ResourceMap;
});