"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 */