define([
"backbone",
"models/PersistentStorage",
"models/sysmeta/VersionTracker",
"models/resourceMap/ResourceMap",
"models/dataONEServices/ObjectService",
"common/EventLog",
"common/QueryService",
"common/ErrorUtilities",
"common/UrlUtilities",
"collections/ObjectFormats",
"common/ValueUtilities",
], (
Backbone,
PersistentStorage,
VersionTracker,
ResourceMap,
ObjectService,
EventLog,
QueryService,
ErrorUtilities,
UrlUtilities,
ObjectFormats,
ValueUtilities,
) => {
const OBJECT_FORMATS = new ObjectFormats();
// Index field names
const FIELDS = Object.freeze({
RM: "resourceMap",
FORMAT_ID: "formatId",
FORMAT_TYPE: "formatType",
IS_DOCUMENTED_BY: "isDocumentedBy",
OBSOLETED_BY: "obsoletedBy",
SERIESID: "seriesId",
ID: "id",
DATE_UPLOADED: "dateUploaded",
});
const QUERY_FIELDS = Object.values(FIELDS);
// Status messages for the resolution process
const STATUS = Object.freeze({
indexSearchComplete: "Index search complete",
seriesIdResolved: "Series ID resolved",
// matches
indexMatch: "Resource map pid found in index",
multiRMMatch:
"Multiple versions of resource map found in index and could resolve to the most recent",
storageMatch: "Resource map pid found in local storage",
smMatch: "Resource map pid found by walking sysmeta",
guessMatch: "Resource map pid guessed based on naming convention",
// misses
indexMiss: "Resource map pid not found in index",
storageMiss: "Resource map pid not found in local storage",
smMiss: "Resource map pid not found by walking sysmeta",
guessMiss: "Resource map pid not found by guessing",
multiRMMiss:
"Multiple resource maps found in index, but no single package could be selected.",
// special cases
pidIsSeriesId: "PID is a series ID, not an object PID",
noPidForSeriesId: "PID not found for series ID",
resolutionCycle: "Stopped resolution after finding a PID cycle",
allMiss: "Resource map pid not found by any strategy",
foundButNotValid:
"Resource map pid found but does not link to the given PID",
foundAndValid: "Resource map pid found and links to the given PID",
rmFetchError: "Error fetching resource map via object API",
unauthorized: "Stopped resolution: user not authorized to access sysmeta",
multiResultSamePid:
"Multiple documents were found in index with the same PID. This should not happen and indicates an issue with the index.",
});
const DEFAULT_MAX_STEPS = 200; // Default max steps to walk back in sysmeta
const DEFAULT_MAX_FETCH_TIME = 45 * 1000; // Default max time to fetch RM: 45s
// Default options for PersistentStorage
const DEFAULT_STORAGE_OPTIONS = {
ttlMs: 60 * 60 * 1000, // 1 hour
memory: true,
};
/**
* @class ResourceMapResolver
* @classdesc A multi-strategy resource map (RM) look-up tool. Searches for
* the RM associated with a given PID and allows finding RMs when data
* packages have not yet been indexed. Tries the following strategies in the
* order listed: queries index, checks client-side storage, walks the system
* metadata, and finally guesses the RM PID based on a naming convention. When
* a match is found, the class stores the Obj-RM PID pair in the client-side
* storage for future use. When a match is found from a source other than
* index, the RM is verified to ensure it links to the pid. The history of the
* resolution attempts is stored in an event log.
* @classcategory Common
* @since 2.34.0
*/
class ResourceMapResolver {
/**
* Requires `metaServiceUrl` and at least one object-read service URL.
* @param {object} options Options for the resolver
* @param {string} options.metaServiceUrl The base URL for service to get
* System Metadata
* @param {string} [options.resolveServiceUrl] Resolve service base URL
* @param {string} [options.objectServiceUrl] Object service base URL
* @param {ObjectService} [options.objectService] Object service used to
* download Resource Maps
* @param {PersistentStorage} [options.storage] An instance of
* PersistentStorage to use for storing obj:resMap PID pairs. If not
* provided, a new instance will be created.
* @param {PersistentStorage#PersistentStorageOptions} [options.storageOptions]
* Options for creating the PersistentStorage instance if one is not provided.
* See {@link PersistentStorage#PersistentStorageOptions}
* @param {object} [options.eventLog] An instance of EventLog to use for
* tracing the resolution process. If not provided, a new instance will be
* created.
* @param {number} [options.maxSteps] The maximum number of steps to walk
* back in the system metadata to find a resource map PID.
* @param {number} [options.maxFetchTime] The maximum time to wait for
* fetching the resource map PID from the system metadata. Defaults to 45s.
* @param {"info"|"warning"|"error"} [options.consoleLevel] The level at
* which to log messages to the console. Defaults to "warning". Set to false
* to disable console logging.
*/
constructor(options = {}) {
this.events = { ...Backbone.Events };
this.objectService = options.objectService || null;
const normalizedUrl = UrlUtilities.normalizeUrl(options.metaServiceUrl);
if (!normalizedUrl) {
throw new Error("ResourceMapResolver: metaServiceUrl is required");
}
const resolveServiceUrl = ValueUtilities.isNonEmptyString(
options.resolveServiceUrl,
)
? options.resolveServiceUrl
: null;
const objectServiceUrl = ValueUtilities.isNonEmptyString(
options.objectServiceUrl,
)
? options.objectServiceUrl
: null;
if (!resolveServiceUrl && !objectServiceUrl) {
throw new Error(
"ResourceMapResolver: resolveServiceUrl or objectServiceUrl is required",
);
}
this.metaServiceUrl = normalizedUrl;
this.resolveServiceUrl = resolveServiceUrl || objectServiceUrl;
this.objectServiceUrl = objectServiceUrl;
// Storage to store obj:ResMap pid pairs.
const storageOptions = {
...DEFAULT_STORAGE_OPTIONS,
...(options.storageOptions || {}),
};
storageOptions.instanceKeys = [
...(storageOptions.instanceKeys || []),
normalizedUrl,
"ResourceMapResolver",
];
this.storage = options.storage || PersistentStorage.get(storageOptions);
// Event log to trace the resolution process
this.eventLog = options.eventLog || new EventLog();
const consoleLevel =
options.consoleLevel === false
? false
: options.consoleLevel || "warning";
this.eventLog.setConsoleLogLevel(consoleLevel);
// Max steps to walk back in sysmeta
this.maxSteps =
Number.isInteger(options.maxSteps) && options.maxSteps > 0
? options.maxSteps
: DEFAULT_MAX_STEPS;
// Max time to fetch RM
this.maxFetchTime =
Number.isInteger(options.maxFetchTime) && options.maxFetchTime > 0
? options.maxFetchTime
: DEFAULT_MAX_FETCH_TIME;
// VersionTracker instance to walk sysmeta
this.versionTracker = new VersionTracker({
metaServiceUrl: normalizedUrl,
maxChainHops: this.maxSteps,
});
}
/**
* Extracts a useful error message or status code from an error object, if
* possible.
* @param {Error|object|string} error The error to extract the message from
* @returns {string|number} The extracted error message or status code, or
* the original error if no message or status code could be extracted
* @since 2.39.0
* @private
*/
static errorValue(error) {
return error?.status || error?.message || error;
}
/**
* Logs a warning message to the event log and console (if console logging
* is enabled).
* @param {string} message The warning message to log
* @param {Error|object|string} [error] An optional error object
* @since 2.39.0
*/
warn(message, error) {
this.eventLog.consoleLog(
message,
"ResourceMapResolver",
"warning",
error,
);
}
/**
* An object representing the result of the resolution process.
* @typedef {object} ResolveResult
* @property {boolean} success Whether the resolution was successful
* @property {string} pid The PID of the object to find a resource map for
* (generally an EML PID)
* @property {string} [rm] The resolved resource map PID if successful
* @property {Array} log The event log for the resolution process,
* including an array of events with timestamps, messages, and metadata.
* @property {boolean} [unauthorized] Set to true when the resolution
* process was stopped due to unauthorized access to the system metadata
* (possibly sysmeta for a previous version of the object).
* @property {boolean} [multipleRMs] Set to true when multiple resource
* maps were found in the index for the given PID, but no single RM could be
* attributed to the PID.
*/
/**
* The main method to resolve the resource map PID for a given PID. It will
* try multiple strategies in order to find the resource map associated with
* the PID.
* @param {string} pid The PID of the document to resolve
* @param {object} [options] Resolution options.
* @param {string[]} [options.fields] An array of index fields to query in
* addition to the default fields. This can be used to get additional
* metadata about the PID from the index to reduce queries by the consumer.
* @param {AbortSignal} [options.signal] Signal used to cancel resolver work
* @param {Set<string>} [visitedPids] PIDs already visited in this path
* @returns {Promise<ResolveResult>} The result of the resolution process
*/
async resolve(pid, options = {}, visitedPids = new Set()) {
if (visitedPids.has(pid)) {
return this.status(pid, STATUS.resolutionCycle, null, {
resolutionCycle: true,
cyclePid: pid,
});
}
const nextVisitedPids = new Set(visitedPids);
nextVisitedPids.add(pid);
// ---- INDEX ----
let indexResult = null;
try {
indexResult = await this.constructor.searchIndex(pid, options);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
indexResult = {
pid,
rm: null,
meta: {
indexError: true,
error: this.constructor.errorValue(error),
},
};
// Don't stop resolution process if index search fails for some reason
// (e.g. Solr is down).
this.warn(`Error searching index for PID ${pid}`, error);
}
this.status(
pid,
STATUS.indexSearchComplete,
null,
indexResult?.meta || {},
);
let resolutionMeta = { ...(indexResult?.meta || {}) };
if (indexResult?.rm) {
// -- Special case: We already have a stored Resource Map in local
// storage that is newer than the index result -- //
if (!indexResult.meta?.isResourceMap) {
let storedRM = null;
try {
storedRM = await this.checkStorage(pid);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
this.warn(`Error checking local storage for PID ${pid}`, error);
}
if (storedRM && storedRM !== indexResult.rm) {
const candidateResult = await this.multiRMCheck(
[indexResult.rm, storedRM],
options,
);
if (
candidateResult.rm === storedRM &&
!candidateResult.meta?.newerResourceMapUnavailable
) {
const { isMember } = await this.checkResourceMapMembership(
storedRM,
pid,
options,
);
if (isMember) {
return this.status(pid, STATUS.storageMatch, storedRM, {
...resolutionMeta,
source: "storage",
});
}
}
}
}
// No suitable stored Resource Map found, use successful index result
return this.status(pid, STATUS.indexMatch, indexResult.rm, {
...resolutionMeta,
source: "index",
});
}
// -- Special case: PID is a series ID --
if (indexResult?.meta?.isSid) {
// Either we find PID for SID and resolve with PID, or we fail here
// (don't continue to storage, sysmeta, guess using SID)
this.status(pid, STATUS.pidIsSeriesId, null, resolutionMeta);
return this.resolveFromSeriesId(pid, options, nextVisitedPids);
}
let failedVerificationResourceMapPid = null;
// -- Special case: PID a data object and has isDocumentedBy metadata PIDs --
if (
indexResult?.meta?.isData &&
indexResult.meta.isDocumentedBy?.length
) {
const metadataSearchResult = await this.resolveFromMetadataPids(
indexResult.meta.isDocumentedBy,
options,
nextVisitedPids,
);
const rms = ValueUtilities.normalizeStringList([
...(resolutionMeta.rms || []),
...(metadataSearchResult.meta?.rms || []),
]);
resolutionMeta = {
...resolutionMeta,
...metadataSearchResult.meta,
rms,
};
if (metadataSearchResult.rm) {
if (await this.verify(metadataSearchResult.rm, pid, options)) {
return this.status(
pid,
STATUS.indexMatch,
metadataSearchResult.rm,
{
...resolutionMeta,
source: "isDocumentedBy",
},
);
}
failedVerificationResourceMapPid = metadataSearchResult.rm;
}
}
// -- Special case: more than 1 resource map found in index --
const rmCandidates = ValueUtilities.normalizeStringList(
resolutionMeta.rms || [],
);
if (rmCandidates.length > 1) {
// Choose the newest accessible candidate from one Resource Map version
// chain, then confirm it contains the requested object.
const multiResult = await this.multiRMCheck(rmCandidates, options);
const multiMeta = {
...resolutionMeta,
...multiResult.meta,
rms: rmCandidates,
};
if (multiResult.rm) {
let winnerContainsPid =
multiResult.rm !== failedVerificationResourceMapPid;
if (winnerContainsPid) {
const membership = await this.checkResourceMapMembership(
multiResult.rm,
pid,
options,
);
winnerContainsPid = membership.isMember;
}
if (winnerContainsPid) {
return this.status(pid, STATUS.multiRMMatch, multiResult.rm, {
...multiMeta,
source: "index",
multipleRMsResolvedToSingleRoot: true,
});
}
}
// Do not let a later strategy silently pick one of several packages.
return this.status(pid, STATUS.multiRMMiss, null, multiMeta);
}
this.status(pid, STATUS.indexMiss, null, resolutionMeta);
// ---- STORAGE ----
let storedRM = null;
try {
storedRM = await this.checkStorage(pid);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
this.warn(`Error checking local storage for PID ${pid}`, error);
}
if (storedRM && (await this.verify(storedRM, pid, options))) {
return this.status(pid, STATUS.storageMatch, storedRM, {
...resolutionMeta,
source: "storage",
});
}
this.status(pid, STATUS.storageMiss, null);
// ---- SYS META ----
const smResult = await this.walkSysmeta(pid, options);
if (smResult.rm && (await this.verify(smResult.rm, pid, options))) {
return this.status(pid, STATUS.smMatch, smResult.rm, {
...resolutionMeta,
...(smResult.meta || {}),
source: "sysMeta",
});
}
if (smResult.meta?.unauthorized) {
// If we got a 401 error, stop the resolution process
return this.status(pid, STATUS.unauthorized, null, {
...resolutionMeta,
...smResult.meta,
source: "sysMeta",
});
}
// Otherwise, we record that this step failed and continue to guess
this.status(pid, STATUS.smMiss, null, smResult.meta);
// ---- GUESS BY NAMING CONVENTION ----
const guessedPid = await this.guessPid(pid, options);
if (guessedPid) {
// Already verified in guessPid, so just return the result
return this.status(pid, STATUS.guessMatch, guessedPid, {
...resolutionMeta,
source: "guess",
});
}
this.status(pid, STATUS.guessMiss, null, { guessedPid });
// ---- NOT FOUND ----
return this.status(pid, STATUS.allMiss, null, resolutionMeta);
}
/**
* Given a list of metadata PIDs, collapse them by version chain, then
* resolve a resource map for each unique version chain.
* @param {string[]} metadataPids An array of metadata PIDs, e.g. the
* documentedBy PIDs returned from the index for a data PID.
* @param {object} [options] Resolution options
* @param {AbortSignal} [options.signal] Signal used to cancel resolver work
* @param {Set<string>} [visitedPids] PIDs already visited in this path
* @returns {Promise<{rm: (string|null), meta: object}>} The resolved RM PID
* if found, and metadata about the resolution attempt
* @since 2.39.0
*/
async resolveFromMetadataPids(
metadataPids = [],
options = {},
visitedPids = new Set(),
) {
const inputMetadataPids =
ValueUtilities.normalizeStringList(metadataPids);
if (!inputMetadataPids.length) {
return {
rm: null,
meta: {
inputMetadataPids,
metadataCandidates: [],
rms: [],
},
};
}
// First, reduce the list of metadata PIDs to the latest reachable PIDs
// from each version chain. This avoids redundant resolution attempts for
// metadata that are just older versions of each other.
const metadataCandidates = await this.reducePidsToLatest(
inputMetadataPids,
options,
);
// Next, resolve the RM for each metadata PID candidate.
const metadataResults = await Promise.all(
metadataCandidates.map(async (metadataPid) => {
try {
return await this.resolve(metadataPid, options, visitedPids);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
if (Array.isArray(error?.issues) && error.issues.length) {
throw error;
}
this.warn(`Error resolving metadata PID ${metadataPid}`, error);
return null;
}
}),
);
const rmCandidates = ValueUtilities.normalizeStringList(
metadataResults.flatMap((metadataResult) => [
metadataResult?.rm,
...(metadataResult?.meta?.rms || []),
]),
);
return {
rm: rmCandidates.length === 1 ? rmCandidates[0] : null,
meta: {
inputMetadataPids,
metadataCandidates,
rms: rmCandidates,
},
};
}
/**
* Collapse a list of PIDs to only the most one for its version chain. For
* example, if given 3 PIDs that are all part of the same version chain, it
* will remove the two older versions. If given PIDs that are part of
* different version chains, it will return the most recent PID from each
* chain.
* @param {string[]} pids PIDs to group by version chain
* @param {object} [options] Version-chain lookup options
* @param {AbortSignal} [options.signal] Signal used to cancel resolver work
* @returns {Promise<string[]>} Latest PID from each discovered chain
* @throws {Error} When resolution is aborted
* @since 2.39.0
*/
async reducePidsToLatest(pids = [], options = {}) {
let remainingPids = ValueUtilities.normalizeStringList(pids);
const latestPids = [];
while (remainingPids.length) {
let chainDetails;
try {
// eslint-disable-next-line no-await-in-loop
chainDetails = await this.versionTracker.checkPidsInSameVersionChain(
remainingPids,
options,
);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
return ValueUtilities.normalizeStringList([
...latestPids,
...remainingPids,
]);
}
if (chainDetails.sameChain) {
latestPids.push(chainDetails.newestPid);
break;
}
const chain = new Set(chainDetails.chain || []);
latestPids.push(chainDetails.newestPid || remainingPids[0]);
remainingPids = remainingPids.filter((pid) => !chain.has(pid));
}
return ValueUtilities.normalizeStringList(latestPids);
}
/**
* Resolves the resource map PID from a series ID (SID). It first retrieves
* the system metadata for the series ID to get the most up-to-date PID,
* then starts the resolution process with the new PID. Called from
* `resolve` when the index search returns a series ID.
* @param {string} sid The series ID to resolve
* @param {object} [options] Resolution options
* @param {AbortSignal} [options.signal] Signal used to cancel resolver work
* @param {Set<string>} [visitedPids] PIDs already visited in this path
* @returns {Promise<ResolveResult>} The result of the resolution process
*/
async resolveFromSeriesId(sid, options = {}, visitedPids = new Set()) {
// Get sysmeta which will give the most up-to-date PID for a SID
const pid = await this.getPidForSid(sid, options);
if (!pid) return this.status(sid, STATUS.noPidForSeriesId, null);
if (visitedPids.has(pid)) {
return this.status(sid, STATUS.resolutionCycle, null, {
resolutionCycle: true,
cyclePid: pid,
});
}
// Listen to every status update for the PID so we can add it to the
// records for the SID (event log, local storage, other listeners, etc.)
const eventName = `update:${pid}`;
const sidStatusForwarder = (event) => {
// call status with the sid so we can add it to the event log
this.status(sid, event.status, event.rm, {
...event.meta,
sid,
});
};
this.events.on(eventName, sidStatusForwarder);
// Restart the resolution with the new PID
try {
return await this.resolve(pid, options, visitedPids);
} finally {
// Remove only this listener so existing subscribers are preserved.
this.events.off(eventName, sidStatusForwarder);
}
}
/**
* When 2 or more resource maps are found in the index for a PID, then this
* method is called to check if they are all versions of each other. If so,
* it returns the newest accessible candidate in that version chain.
* Membership in the requested object's package is checked by `resolve()`.
* @param {Array<string>} rms An array of resource map PIDs to check
* @param {object} [options] Version-chain lookup options
* @param {AbortSignal} [options.signal] Signal used to cancel resolver work
* @returns {Promise<object>} The resolved resource map PID, when found,
* and metadata about the search.
* @since 2.34.1
*/
async multiRMCheck(rms, options = {}) {
const result = { rm: null, meta: {} };
let candidateDetails;
try {
candidateDetails = await Promise.all(
rms.map(async (pid) => {
try {
return {
pid,
sysMeta: await this.getSysMeta(pid, options),
};
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
if ([401, 403, 404].includes(error?.status)) {
return { pid, status: error.status, sysMeta: null };
}
throw error;
}
}),
);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
result.meta.error = this.constructor.errorValue(error);
this.warn(`Error fetching system metadata for PID ${rms[0]}`, error);
return result;
}
const accessibleRms = candidateDetails
.filter(({ sysMeta }) => sysMeta)
.map(({ pid }) => pid);
const inaccessibleRms = rms.filter((pid) => !accessibleRms.includes(pid));
const resourceMapDates = Object.fromEntries(
candidateDetails.map(({ pid, sysMeta }) => [
pid,
sysMeta?.dateUploaded || null,
]),
);
result.meta.resourceMapDates = resourceMapDates;
if (!accessibleRms.length) {
result.meta.chainIncomplete = true;
if (
candidateDetails.some(({ status }) => [401, 403].includes(status))
) {
result.meta.unauthorized = true;
}
if (candidateDetails.some(({ status }) => status === 404)) {
result.meta.notFound = true;
}
return result;
}
let versionChain;
try {
versionChain = await this.versionTracker.checkPidsInSameVersionChain(
[...accessibleRms, ...inaccessibleRms],
options,
);
} catch (e) {
if (ErrorUtilities.isAbortError(e)) throw e;
result.meta.error = this.constructor.errorValue(e);
this.warn(`Error fetching version chain for PID ${rms[0]}`, e);
return result;
}
const { newestPid, sameChain } = versionChain;
// If the version history of one RM contains all of the others, then they
// are all versions of each other. If not, we cannot resolve to a single RM.
if (!sameChain) {
result.meta.multipleRMsNotVersions = true;
return result;
}
const accessibleSet = new Set(accessibleRms);
const newestRm = [...versionChain.chain]
.reverse()
.find((pid) => accessibleSet.has(pid));
if (newestPid !== newestRm) {
result.meta.newerResourceMapUnavailable = true;
}
result.rm = newestRm;
return result;
}
/**
* Gets the PID for a given series ID (SID) using sys metadata. Ensures that
* the most recent PID is returned, even if indexing is not complete.
* @param {string} sid The series ID to get the PID for
* @param {object} [options] System metadata lookup options
* @param {AbortSignal} [options.signal] Signal used to cancel resolver work
* @returns {Promise<string|null>} The PID associated with the series ID,
* or null if not found
*/
async getPidForSid(sid, options = {}) {
try {
const sysMeta = await this.getSysMeta(sid, options);
const resolvedPid = sysMeta?.identifier || null;
if (resolvedPid) {
this.status(sid, STATUS.seriesIdResolved, null, {
sid,
resolvedPid,
formatId: sysMeta.formatId || null,
});
}
return resolvedPid;
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
if (error?.status) {
this.warn(`Failed to resolve PID for SID ${sid}`, error);
}
return null;
}
}
/**
* Gets the system metadata for a given PID.
* @param {string} pid The PID to get the system metadata for
* @param {object} [options] Options to SysMeta service
* @returns {Promise<SystemMetadata>} The sysMeta for the PID
* @since 2.39.0
*/
async getSysMeta(pid, options = {}) {
return this.versionTracker.getSysMeta(pid, options);
}
/**
* Track a failed resource map resolution.
* @param {string} pid PID whose resource map could not be resolved
*/
trackMissingResourceMap(pid) {
if (!pid) return;
this.eventLog.analytics?.trackCustomEvent("resource_map_missing", {
pid,
});
}
/**
* Searches the index for a resource map associated with the given PID.
* Returns an object containing the PID and metadata about the search.
* @param {string} pid The PID to search for in the index
* @param {object} [options] Search options
* @param {string[]} [options.fields] An array of index fields to query in
* addition to the default fields.
* @param {AbortSignal} [options.signal] Signal used to cancel the index
* search.
* @returns {Promise<object|null>} An object containing the PID and metadata
* if a resource map is found, null otherwise
*/
static async searchIndex(pid, options) {
const meta = { numFound: 0 };
const result = { pid, rm: null, meta };
const response = await QueryService.queryWithFetch({
q: QueryService.buildIdQuery(pid),
fields: [...QUERY_FIELDS, ...(options?.fields || [])],
rows: 100,
signal: options?.signal,
});
const docs = QueryService.parseResponse(response);
const numFound = response?.response?.numFound || docs.length;
if (numFound === 0) return result;
meta.numFound = numFound;
meta.indexResults = docs;
// If we found one doc matching the incoming PID, then we can be fairly
// confident about the format type and use that to help with the
// resolution.
const idMatch = docs.filter((doc) => doc[FIELDS.ID] === pid);
if (idMatch.length > 1) throw new Error(STATUS.multiResultSamePid);
if (idMatch.length === 1) {
const doc = idMatch[0];
const formatProps = {
formatId: doc[FIELDS.FORMAT_ID],
formatType: doc[FIELDS.FORMAT_TYPE],
};
const formatType = OBJECT_FORMATS.getFormatType(formatProps);
meta.formatType = formatType;
meta.indexMatch = doc;
if (OBJECT_FORMATS.isData(formatProps)) {
meta.isData = true;
} else if (OBJECT_FORMATS.isMetadata(formatProps)) {
meta.isMetadata = true;
} else if (OBJECT_FORMATS.isResourceMap(formatProps)) {
meta.isResourceMap = true;
result.rm = doc[FIELDS.ID];
}
}
// Collect other metadata about the PID from the search results that may
// be helpful for the resolution process
meta.isSid = docs.some((doc) =>
ValueUtilities.listify(doc?.[FIELDS.SERIESID]).includes(pid),
);
meta.rms = ValueUtilities.normalizeStringList(
docs.flatMap((doc) => ValueUtilities.listify(doc?.[FIELDS.RM])),
);
meta.isDocumentedBy = ValueUtilities.normalizeStringList(
docs.flatMap((doc) =>
ValueUtilities.listify(doc?.[FIELDS.IS_DOCUMENTED_BY]),
),
);
// When the PID is a series ID, the caller must resolve the SID to a PID
// first, so never return an RM directly for a SID.
if (!result.rm && !meta.isSid && meta.rms.length === 1) {
[result.rm] = meta.rms;
}
return result;
}
/**
* Checks local storage / index DB for a resource map PID associated with
* the given PID. Uses PersistentStorage to access the local storage.
* @param {string} pid The PID of the document to check
* @returns {Promise<string|null>} The stored RM PID, when present
*/
async checkStorage(pid) {
return (await this.storage.getItem(pid)) || null;
}
/**
* Adds a resource map PID to the local storage for the given PID.
* @param {string} pid The PID of the document to add the RM for
* @param {string} rm The resource map PID to add
* @returns {Promise<string|null>} The PID of the resource map added to
* storage, or null if the addition failed
*/
async addToStorage(pid, rm) {
if (!pid || !rm) {
throw new Error("PID and RM are required to add to storage");
}
try {
return await this.storage.setItem(pid, rm);
} catch (err) {
if (PersistentStorage.isQuotaError(err)) {
await this.storage.clear();
try {
return await this.storage.setItem(pid, rm);
} catch (retryErr) {
this.warn(
"Retry failed: Unable to add RM to local storage",
retryErr,
);
return null;
}
}
this.warn("Unexpected error adding RM to local storage", err);
return null;
}
}
/**
* Walks the system metadata to find the resource map PID associated with
* the given PID. It starts from the given PID and walks backward
* through the version history to find an old resource map PID. Then,
* starting at the found RM pid, walks forward to find the current RM.
* @param {string} pid The PID of the document to walk sysmeta for
* @param {object} [options] System metadata lookup options
* @param {AbortSignal} [options.signal] Signal used to cancel resolver work
* @returns {Promise<{rm: string|null, meta: object}>} An object containing the
* resource map PID if found, and metadata about the walk
*/
async walkSysmeta(pid, options = {}) {
let currentPid = pid;
const pastPids = [];
let rm = null;
const meta = { stepsBack: 0, pastPids };
/* eslint-disable no-await-in-loop */
// The loop depends on the previous PID to find the next one,
// so the loop must be synchronous (must await for each)
while (pastPids.length < this.maxSteps && currentPid) {
let prevPid = null;
try {
prevPid = await this.versionTracker.getPrev(currentPid, options);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
if (error?.status === 401) meta.unauthorized = true;
if (error?.status) {
if (!meta.errors) meta.errors = [];
meta.errors.push(error.status);
}
break;
}
if (!prevPid) break;
currentPid = prevPid;
pastPids.push(currentPid);
let indexResult = null;
try {
indexResult = await this.constructor.searchIndex(currentPid, options);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) throw error;
meta.indexError = true;
meta.error = this.constructor.errorValue(error);
this.warn(`Error searching index for prior PID ${currentPid}`, error);
break;
}
if (indexResult.rm) {
rm = indexResult.rm;
break;
}
}
// Keep the meta/logs clean
if (!meta.pastPids.length) delete meta.pastPids;
/* eslint-enable no-await-in-loop */
meta.stepsBack = pastPids.length;
// If no prev. RM found, cannot walk forward to find current RM
if (!rm) return { rm, meta };
// Walk forward same # steps to find the current RM
let currentRM = rm;
if (pastPids.length) {
try {
currentRM = await this.versionTracker.getNth(
rm,
pastPids.length,
options,
);
} catch (error) {
// A real cancellation must still propagate.
if (ErrorUtilities.isAbortError(error)) throw error;
// A private version in the chain: stop and report unauthorized.
if (error?.status === 401) meta.unauthorized = true;
// Record any HTTP status for diagnostics, like the backward walk.
if (error?.status) {
if (!meta.errors) meta.errors = [];
meta.errors.push(error.status);
}
this.warn(`Error walking forward from resource map ${rm}`, error);
// Couldn't determine the current RM; fall through to the guess
// strategy rather than hard-rejecting the whole resolution.
return { rm: null, meta };
}
}
return { rm: currentRM, meta };
}
/**
* Guesses the resource map PID based on the PID. The guessed PID is
* constructed by appending the PID to a predefined prefix.
* @param {string} pid The PID of the document to guess the RM PID for
* @param {object} [options] Verification options
* @param {AbortSignal} [options.signal] Signal used to cancel resolver work
* @returns {Promise<string|null>} The guessed resource map PID if it exists
* and is linked to the PID, null otherwise
*/
async guessPid(pid, options = {}) {
const guessed = `${ResourceMap.RESOURCE_MAP_PID_PREFIX}${pid}`;
const isValid = await this.verify(guessed, pid, options);
return isValid ? guessed : null;
}
/**
* Verifies that the given resource map PID exists and contains the pid
* as a member.
* @param {string} rm The PID of the resource map to verify
* @param {string} pid The PID of the document to check
* @param {object} [options] Fetch options
* @param {number} [options.timeoutMs] The maximum time to wait for the
* fetch.
* @param {AbortSignal} [options.signal] Signal used to cancel the fetch
* @returns {Promise<boolean>} True if the RM is valid and contains the PID,
* false otherwise
*/
async verify(rm, pid, options = {}) {
const { isMember, memberPids, fetchStatus } =
await this.checkResourceMapMembership(rm, pid, options);
const meta = {};
let status = isMember ? STATUS.foundAndValid : STATUS.foundButNotValid;
if (fetchStatus !== 200) {
status = STATUS.rmFetchError;
meta.error = fetchStatus || "Unknown error";
} else {
meta.rmMembers = memberPids;
meta.matchedPid = isMember ? pid : null;
}
this.status(pid, status, isMember ? rm : null, meta);
return isMember;
}
/**
* Fetches a Resource Map and checks whether it contains a PID.
* @param {string} rm The Resource Map PID to fetch
* @param {string} pid The member PID to check
* @param {object} [options] Fetch options
* @param {number} [options.timeoutMs] The maximum time to wait for the fetch
* @param {AbortSignal} [options.signal] Signal used to cancel the fetch
* @returns {Promise<{isMember: boolean, memberPids: string[],
* fetchStatus: number}>} Membership result and fetch details
* @since 2.39.0
*/
async checkResourceMapMembership(rm, pid, options = {}) {
const { model, status: fetchStatus } = await this.fetchResourceMap(
rm,
options,
);
const memberPids = model?.getMemberPids?.() || [];
return {
isMember: !!pid && memberPids.includes(pid),
memberPids,
fetchStatus,
};
}
/**
* Fetches and parses the resource map for the given PID.
* @param {string} rm The PID of the resource map to fetch
* @param {object} [options] Fetch options
* @param {number} [options.timeoutMs] The maximum time to wait for the
* fetch.
* @param {AbortSignal} [options.signal] Signal used to cancel the fetch
* @returns {Promise<{model: ResourceMap|null, status: number}>} Parsed
* resource map and HTTP status.
*/
async fetchResourceMap(rm, options = {}) {
const timeout = options.timeoutMs ?? this.maxFetchTime;
try {
const objectService =
this.objectService ||
new ObjectService({
readBaseUrl: this.objectServiceUrl || this.resolveServiceUrl,
resolveServiceUrl: this.resolveServiceUrl,
metaServiceUrl: this.metaServiceUrl,
});
const xml = await objectService.download(rm, {
responseType: "text",
timeoutMs: timeout,
signal: options.signal,
});
const model = ResourceMap.fromXml(rm, xml, {
resolveServiceUrl: this.resolveServiceUrl,
objectServiceUrl: this.objectServiceUrl,
});
return { model, status: 200 };
} catch (e) {
if (ErrorUtilities.isAbortError(e)) throw e;
if (Array.isArray(e?.issues) && e.issues.length) throw e;
return { model: null, status: e?.status || 500 };
}
}
/**
* Logs an event for the resolution process.
* @param {string} pid The PID of the object being resolved
* @param {string} rm The resource map PID if found, null otherwise
* @param {string} status The human-readable status of the resolution
* @param {object} [meta] Additional metadata to include in the event
* @param {string} [level] The log level for the event
* @returns {object} The event log for the resolution process
*/
log(pid, rm, status, meta = {}, level = "info") {
const log = this.eventLog.getOrCreateLog(pid);
// Remove redundant info to prevent logs from growing too large
let info = { ...meta };
delete info.pid; // pid is already in the log name
info.rm = rm;
// Delete any pairs with no value
Object.keys(info).forEach((key) => {
if (info[key] === null || info[key] === undefined || info[key] === "") {
delete info[key];
}
});
// Don't send an empty info object
if (!Object.keys(info).length) info = null;
this.eventLog.log(log, level, `Status: ${status}`, info);
return log;
}
/**
* Records the status of the resolution process for a given PID and triggers
* Backbone events for the status update.
* @param {string} pid The PID of the object being resolved
* @param {string} status The human-readable status of the resolution
* @param {string} [rm] The resource map PID if found, null otherwise
* @param {object} [meta] Additional metadata to include in the status
* @returns {ResolveResult} An object with the result of the resolution
*/
status(pid, status, rm, meta) {
if (!pid) {
throw new Error("PID is required for status updates");
}
const log = this.log(pid, rm, status, meta);
this.events.trigger("update", { pid, rm, status, meta });
this.events.trigger(`update:${pid}`, { pid, rm, status, meta });
// Store the obj:rm pair in local storage if rm is found
if (rm) {
this.addToStorage(pid, rm).catch((error) => {
this.warn(`Failed to persist RM ${rm} for PID ${pid}`, error);
});
}
const result = { success: !!rm, pid, log };
if (rm) result.rm = rm;
if (meta) result.meta = meta;
// Event logs span retries, but result flags describe only this status.
if (!rm && meta?.unauthorized === true) result.unauthorized = true;
if (!rm && Array.isArray(meta?.rms) && meta.rms.length > 1)
result.multipleRMs = true;
return result;
}
}
ResourceMapResolver.STATUS = STATUS;
return ResourceMapResolver;
});