Separate provisional upload success from persistently confirmed cleanup state, invalidate prior confirmations before retries, and promote completion only inside the final queue persistence handshake. Preserve source files when history or queue persistence fails and add regression coverage for restart, retry, rollback, and real Electron finalization paths.
310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
(function (root) {
|
|
'use strict';
|
|
|
|
const pathApi = typeof require === 'function' ? require('path') : null;
|
|
const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']);
|
|
const metadataVersion = 2;
|
|
|
|
function normalizeFile(file, platform) {
|
|
const value = typeof file === 'string' ? file.trim() : '';
|
|
if (!value) return '';
|
|
if (platform === 'win32') {
|
|
if (pathApi) return pathApi.win32.resolve(value);
|
|
return value.replace(/\//g, '\\');
|
|
}
|
|
if (pathApi) return pathApi.resolve(value);
|
|
return value;
|
|
}
|
|
|
|
function normalizeHoster(hoster) {
|
|
return typeof hoster === 'string' ? hoster.trim().toLowerCase() : '';
|
|
}
|
|
|
|
function uniqueHosters(values) {
|
|
const hosters = [];
|
|
const seen = new Set();
|
|
for (const value of values) {
|
|
const hoster = normalizeHoster(value);
|
|
if (!hoster || seen.has(hoster)) continue;
|
|
seen.add(hoster);
|
|
hosters.push(hoster);
|
|
}
|
|
return hosters;
|
|
}
|
|
|
|
function cloneFingerprint(fingerprint) {
|
|
if (!fingerprint || typeof fingerprint !== 'object' || Array.isArray(fingerprint)) return null;
|
|
return { ...fingerprint };
|
|
}
|
|
|
|
function relatedJobs(queueJobs, job, platform) {
|
|
if (!Array.isArray(queueJobs) || !job) return [];
|
|
const file = normalizeFile(job.file, platform);
|
|
const token = typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken
|
|
? job.sourceCleanupToken
|
|
: null;
|
|
return queueJobs.filter((candidate) => {
|
|
if (!candidate) return false;
|
|
if (file && normalizeFile(candidate.file, platform) === file) return true;
|
|
return token !== null && candidate.sourceCleanupToken === token;
|
|
});
|
|
}
|
|
|
|
function storedRequiredHosters(jobs) {
|
|
const values = [];
|
|
for (const job of jobs) {
|
|
if (Array.isArray(job.sourceCleanupRequiredHosters)) {
|
|
values.push(...job.sourceCleanupRequiredHosters);
|
|
}
|
|
}
|
|
return uniqueHosters(values);
|
|
}
|
|
|
|
function confirmedHosters(jobs, requiredHosters) {
|
|
const values = [];
|
|
for (const job of jobs) {
|
|
if (job.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(job.sourceCleanupConfirmedHosters)) {
|
|
values.push(...job.sourceCleanupConfirmedHosters);
|
|
}
|
|
}
|
|
const confirmed = new Set(uniqueHosters(values));
|
|
return requiredHosters.filter((hoster) => confirmed.has(hoster));
|
|
}
|
|
|
|
function provisionalHosters(jobs, requiredHosters) {
|
|
const values = [];
|
|
for (const job of jobs) {
|
|
if (job.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(job.sourceCleanupProvisionalHosters)) {
|
|
values.push(...job.sourceCleanupProvisionalHosters);
|
|
}
|
|
}
|
|
const provisional = new Set(uniqueHosters(values));
|
|
return requiredHosters.filter((hoster) => provisional.has(hoster));
|
|
}
|
|
|
|
function storedToken(jobs) {
|
|
for (const job of jobs) {
|
|
if (typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken) {
|
|
return job.sourceCleanupToken;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function storedFingerprint(jobs) {
|
|
for (const job of jobs) {
|
|
const fingerprint = cloneFingerprint(job.sourceCleanupFingerprint);
|
|
if (fingerprint) return fingerprint;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function assignMetadata(jobs, token, requiredHosters, confirmed, provisional, fingerprint, touchedJobs, touchedSet) {
|
|
for (const job of jobs) {
|
|
job.sourceCleanupMetadataVersion = metadataVersion;
|
|
job.sourceCleanupToken = token;
|
|
job.sourceCleanupRequiredHosters = [...requiredHosters];
|
|
job.sourceCleanupConfirmedHosters = [...confirmed];
|
|
job.sourceCleanupProvisionalHosters = [...provisional];
|
|
job.sourceCleanupFingerprint = cloneFingerprint(fingerprint);
|
|
delete job.sourceCleanupCompletedHosters;
|
|
if (!touchedSet.has(job)) {
|
|
touchedSet.add(job);
|
|
touchedJobs.push(job);
|
|
}
|
|
}
|
|
}
|
|
|
|
function prepareGroups(queueJobs, jobsToStart, createToken, platform) {
|
|
const groups = [];
|
|
const touchedJobs = [];
|
|
const touchedSet = new Set();
|
|
const preparedFiles = new Set();
|
|
const revokedHosters = [];
|
|
const revokedSet = new Set();
|
|
if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs, revokedHosters };
|
|
const currentRoundJobs = new Set(jobsToStart);
|
|
|
|
for (const selectedJob of jobsToStart) {
|
|
const file = normalizeFile(selectedJob && selectedJob.file, platform);
|
|
if (!file || preparedFiles.has(file)) continue;
|
|
const siblings = relatedJobs(queueJobs, selectedJob, platform);
|
|
if (siblings.length === 0) continue;
|
|
preparedFiles.add(file);
|
|
|
|
const token = storedToken(siblings) || (typeof createToken === 'function' ? createToken(file) : null);
|
|
if (typeof token !== 'string' || !token) continue;
|
|
const persistedRequired = storedRequiredHosters(siblings);
|
|
const requiredHosters = uniqueHosters([
|
|
...persistedRequired,
|
|
...siblings.map((job) => job.hoster)
|
|
]);
|
|
const startedHosters = new Set(uniqueHosters(
|
|
siblings.filter((job) => currentRoundJobs.has(job)).map((job) => job.hoster)
|
|
));
|
|
const storedConfirmed = confirmedHosters(siblings, requiredHosters);
|
|
const confirmed = storedConfirmed.filter((hoster) => !startedHosters.has(hoster));
|
|
const provisional = provisionalHosters(siblings, requiredHosters)
|
|
.filter((hoster) => !startedHosters.has(hoster));
|
|
for (const hoster of storedConfirmed) {
|
|
if (!startedHosters.has(hoster) || revokedSet.has(hoster)) continue;
|
|
revokedSet.add(hoster);
|
|
revokedHosters.push(hoster);
|
|
}
|
|
const fingerprint = storedFingerprint(siblings);
|
|
|
|
assignMetadata(
|
|
siblings,
|
|
token,
|
|
requiredHosters,
|
|
confirmed,
|
|
provisional,
|
|
fingerprint,
|
|
touchedJobs,
|
|
touchedSet
|
|
);
|
|
|
|
groups.push({
|
|
token,
|
|
file: selectedJob.file,
|
|
requiredHosters: [...requiredHosters],
|
|
confirmedHosters: [...confirmed],
|
|
fingerprint: cloneFingerprint(fingerprint),
|
|
jobs: siblings.map((job) => ({
|
|
jobId: job.id,
|
|
hoster: normalizeHoster(job.hoster),
|
|
status: job.status,
|
|
currentRound: currentRoundJobs.has(job)
|
|
}))
|
|
});
|
|
}
|
|
|
|
return { groups, touchedJobs, revokedHosters };
|
|
}
|
|
|
|
function markCompleted(queueJobs, job, platform) {
|
|
const siblings = relatedJobs(queueJobs, job, platform);
|
|
if (siblings.length === 0) return [];
|
|
const requiredHosters = storedRequiredHosters(siblings);
|
|
const provisional = new Set(provisionalHosters(siblings, requiredHosters));
|
|
const hoster = normalizeHoster(job.hoster);
|
|
if (requiredHosters.includes(hoster)) provisional.add(hoster);
|
|
const orderedConfirmed = confirmedHosters(siblings, requiredHosters);
|
|
const orderedProvisional = requiredHosters.filter((required) => provisional.has(required));
|
|
for (const sibling of siblings) {
|
|
sibling.sourceCleanupMetadataVersion = metadataVersion;
|
|
sibling.sourceCleanupConfirmedHosters = [...orderedConfirmed];
|
|
sibling.sourceCleanupProvisionalHosters = [...orderedProvisional];
|
|
delete sibling.sourceCleanupCompletedHosters;
|
|
}
|
|
return siblings;
|
|
}
|
|
|
|
async function persistRoundCompletions(queueJobs, options = {}) {
|
|
if (!Array.isArray(queueJobs) || typeof options.persist !== 'function') return false;
|
|
const historyPersisted = options.historyPersisted === true;
|
|
const groupsByToken = new Map();
|
|
for (const job of queueJobs) {
|
|
if (!job || typeof job.sourceCleanupToken !== 'string' || !job.sourceCleanupToken) continue;
|
|
if (!groupsByToken.has(job.sourceCleanupToken)) groupsByToken.set(job.sourceCleanupToken, []);
|
|
groupsByToken.get(job.sourceCleanupToken).push(job);
|
|
}
|
|
const snapshots = [];
|
|
for (const jobs of groupsByToken.values()) {
|
|
const requiredHosters = storedRequiredHosters(jobs);
|
|
const confirmed = confirmedHosters(jobs, requiredHosters);
|
|
const provisional = provisionalHosters(jobs, requiredHosters);
|
|
const promoted = historyPersisted
|
|
? uniqueHosters([...confirmed, ...provisional])
|
|
: confirmed;
|
|
const orderedPromoted = requiredHosters.filter((hoster) => promoted.includes(hoster));
|
|
for (const job of jobs) {
|
|
snapshots.push({
|
|
job,
|
|
confirmedHosters: job.sourceCleanupMetadataVersion === metadataVersion
|
|
? uniqueHosters(job.sourceCleanupConfirmedHosters)
|
|
: []
|
|
});
|
|
job.sourceCleanupMetadataVersion = metadataVersion;
|
|
job.sourceCleanupConfirmedHosters = [...orderedPromoted];
|
|
job.sourceCleanupProvisionalHosters = [];
|
|
delete job.sourceCleanupCompletedHosters;
|
|
}
|
|
}
|
|
let persisted = false;
|
|
try {
|
|
persisted = (await options.persist()) === true;
|
|
} catch {}
|
|
if (!persisted) {
|
|
for (const snapshot of snapshots) {
|
|
snapshot.job.sourceCleanupMetadataVersion = metadataVersion;
|
|
snapshot.job.sourceCleanupConfirmedHosters = [...snapshot.confirmedHosters];
|
|
snapshot.job.sourceCleanupProvisionalHosters = [];
|
|
delete snapshot.job.sourceCleanupCompletedHosters;
|
|
}
|
|
}
|
|
return persisted;
|
|
}
|
|
|
|
function removeRequirement(queueJobs, job, platform) {
|
|
if (!job || protectedStatuses.has(job.status)) return [];
|
|
const siblings = relatedJobs(queueJobs, job, platform);
|
|
const removedHoster = normalizeHoster(job.hoster);
|
|
for (const sibling of siblings) {
|
|
const required = Array.isArray(sibling.sourceCleanupRequiredHosters)
|
|
? sibling.sourceCleanupRequiredHosters
|
|
: [];
|
|
const confirmed = sibling.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(sibling.sourceCleanupConfirmedHosters)
|
|
? sibling.sourceCleanupConfirmedHosters
|
|
: [];
|
|
const provisional = sibling.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(sibling.sourceCleanupProvisionalHosters)
|
|
? sibling.sourceCleanupProvisionalHosters
|
|
: [];
|
|
sibling.sourceCleanupMetadataVersion = metadataVersion;
|
|
sibling.sourceCleanupRequiredHosters = uniqueHosters(required)
|
|
.filter((hoster) => hoster !== removedHoster);
|
|
sibling.sourceCleanupConfirmedHosters = uniqueHosters(confirmed)
|
|
.filter((hoster) => hoster !== removedHoster);
|
|
sibling.sourceCleanupProvisionalHosters = uniqueHosters(provisional)
|
|
.filter((hoster) => hoster !== removedHoster);
|
|
delete sibling.sourceCleanupCompletedHosters;
|
|
}
|
|
return siblings;
|
|
}
|
|
|
|
function fingerprintFor(fingerprints, token) {
|
|
if (fingerprints instanceof Map) return fingerprints.get(token);
|
|
if (!fingerprints || typeof fingerprints !== 'object') return undefined;
|
|
return Object.prototype.hasOwnProperty.call(fingerprints, token)
|
|
? fingerprints[token]
|
|
: undefined;
|
|
}
|
|
|
|
function applyFingerprints(queueJobs, fingerprints) {
|
|
if (!Array.isArray(queueJobs)) return [];
|
|
const touchedJobs = [];
|
|
for (const job of queueJobs) {
|
|
if (!job || typeof job.sourceCleanupToken !== 'string' || !job.sourceCleanupToken) continue;
|
|
const fingerprint = fingerprintFor(fingerprints, job.sourceCleanupToken);
|
|
if (fingerprint === undefined) continue;
|
|
job.sourceCleanupFingerprint = cloneFingerprint(fingerprint);
|
|
touchedJobs.push(job);
|
|
}
|
|
return touchedJobs;
|
|
}
|
|
|
|
const api = {
|
|
prepareGroups,
|
|
markCompleted,
|
|
persistRoundCompletions,
|
|
removeRequirement,
|
|
applyFingerprints
|
|
};
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = api;
|
|
} else if (root) {
|
|
root.SourceCleanupPolicy = api;
|
|
}
|
|
})(typeof window !== 'undefined' ? window : this);
|