"use strict";
define(["models/PersistentStorage", "common/UrlUtilities"], (
PersistentStorage,
UrlUtilities,
) => {
const STORE_NAMESPACE = "UploadRecovery";
// localforage's IndexedDB driver name. Recovery records carry serialized
// ResourceMap XML (hundreds of KB at hundreds of members — a few ms to
// write), so do not fall back to WebSQL/localStorage.
const INDEXEDDB_DRIVER = "asyncStorage";
// Recovery records never expire on their own: an orphaned metadata document
// stays broken until it is repaired, however long that takes. They are
// cleared explicitly once the ResourceMap write is confirmed.
const RECOVERY_TTL_MS = null;
/**
* Durable, per server store for upload recovery records. Each record captures
* everything needed to finish the exact ResourceMap write for one metadata
* document, keyed by that metadata PID, so a crash between "metadata
* committed" and "ResourceMap committed" can be replayed on the next load.
*
* Backed by {@link PersistentStorage} (localforage/IndexedDB), so records
* survive a tab crash. Each record stores the exact serialized ResourceMap
* bytes the interrupted upload had prepared. Limited server side
* reconstruction is a separate, explicit recovery choice when no record can
* be retrieved.
* @class UploadRecoveryStore
* @classcategory Models/DataPackage
* @since 2.39.0
*/
class UploadRecoveryStore {
/**
* Create an upload recovery store.
* @param {object} [options] Store options
* @param {PersistentStorage} [options.storage] Storage instance to use
* When omitted, a per server singleton is created
* @param {string} [options.baseUrl] Service base URL used to namespace the
* store. Defaults to the app's Member Node service URL
*/
constructor({ storage, baseUrl } = {}) {
const url =
baseUrl ||
globalThis.MetacatUI?.appModel?.get?.("metaServiceUrl") ||
"";
this.storage =
storage ||
PersistentStorage.get({
instanceKeys: [UrlUtilities.normalizeUrl(url), STORE_NAMESPACE],
ttlMs: RECOVERY_TTL_MS,
memory: false,
localforageConfig: { driver: INDEXEDDB_DRIVER },
});
}
/**
* Persist a recovery record for a metadata PID.
* @param {string} metadataPid Metadata document PID the record recovers
* @param {object} record Recovery record payload
* @returns {Promise<void>} Resolves after attempting the storage write
*/
async save(metadataPid, record) {
if (!metadataPid || !record) return;
const stored = { ...record, metadataPid };
try {
await this.storage.setItem(metadataPid, stored, {
ttlMs: RECOVERY_TTL_MS,
});
} catch {
// Recovery is best-effort; a storage failure must never fail an upload.
}
}
/**
* Read a recovery record for a metadata PID.
* @param {string} metadataPid Metadata document PID
* @returns {Promise<object|null>} The stored record, or null when absent or
* unavailable after one retry
*/
async get(metadataPid) {
if (!metadataPid) return null;
try {
return (await this.storage.getItem(metadataPid)) || null;
} catch {
// Retry once, in case the first failure was a transient IndexedDB error
// (cheap to retry and IndexedDB errors vary across browsers)
try {
return (await this.storage.getItem(metadataPid)) || null;
} catch {
// If a record exists but cannot be read, then it's essentially lost.
// Return null so the caller can attempt server-side reconstruction
// instead of failing.
return null;
}
}
}
/**
* Remove a recovery record for a metadata PID.
* @param {string} metadataPid Metadata document PID
* @returns {Promise<void>} Resolves when removal has been attempted
*/
async remove(metadataPid) {
if (!metadataPid) return;
try {
await this.storage.removeItem(metadataPid);
} catch {
// Ignore: a stale record left behind is harmless (it is re-validated
// against the server before any repair acts on it).
}
}
}
return UploadRecoveryStore;
});