UNPKG

@itwin/imodels-client-authoring

Version:

iModels API client wrapper for applications that author iModels.

236 lines 12.3 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import * as path from "path"; import { ChangesetState, IModelsErrorCode, IModelsErrorImpl, ChangesetOperations as ManagementChangesetOperations, assertLink, isIModelsApiError, } from "@itwin/imodels-client-management"; import { downloadFile } from "../FileDownload"; import { LimitedParallelQueue } from "./LimitedParallelQueue"; export class ChangesetOperations extends ManagementChangesetOperations { /** * Creates a Changeset. Wraps the * {@link https://developer.bentley.com/apis/imodels-v2/operations/create-imodel-changeset/ Create iModel Changeset} * operation from iModels API. Internally it creates a Changeset instance, uploads the Changeset * file and confirms Changeset file upload. The execution of this method depends on the Changeset file size - the larger * the file, the longer the upload will take. * @param {CreateChangesetParams} params parameters for this operation. See {@link CreateChangesetParams}. * @returns newly created Changeset. See {@link Changeset}. */ async create(params) { const changesetFileSize = await this._options.localFileSystem.getFileSize(params.changesetProperties.filePath); const createChangesetBody = this.getCreateChangesetRequestBody(params.changesetProperties, changesetFileSize); const createChangesetResponse = await this.sendPostRequest({ authorization: params.authorization, url: this._options.urlFormatter.getChangesetListUrl({ iModelId: params.iModelId, }), body: createChangesetBody, headers: params.headers, }); const uploadLink = createChangesetResponse.body.changeset._links.upload; assertLink(uploadLink); await this._options.cloudStorage.upload({ url: uploadLink.href, data: params.changesetProperties.filePath, storageType: uploadLink.storageType, }); const completeLink = createChangesetResponse.body.changeset._links.complete; assertLink(completeLink); const confirmUploadBody = this.getConfirmUploadRequestBody(params.changesetProperties); const confirmUploadResponse = await this.sendPatchRequest({ authorization: params.authorization, url: completeLink.href, body: confirmUploadBody, headers: params.headers, }); const result = this.appendRelatedEntityCallbacks(params.authorization, confirmUploadResponse.body.changeset, params.headers); return result; } /** * Downloads a single Changeset identified by either index or id. If an error occurs when downloading a Changeset * this operation queries the failed Changeset by id and retries the download once. If the Changeset file with * the expected name already exists in the target directory and the file size matches the one expected the Changeset * is not downloaded again. * @param {DownloadSingleChangesetParams} params parameters for this operation. See {@link DownloadSingleChangesetParams}. * @returns downloaded Changeset. See {@link DownloadedChangeset}. */ async downloadSingle(params) { await this._options.localFileSystem.createDirectory(params.targetDirectoryPath); const changeset = await this.querySingleInternal(params); return this.downloadChangeset({ ...params, changeset }); } /** * Downloads Changeset list. Internally the method uses {@link ChangesetOperations.getRepresentationList} to query the * Changeset collection so this operation supports most of the the same url parameters to specify what Changesets to * download. One of the most common properties used are `afterIndex` and `lastIndex` to download Changeset range. This * operation downloads Changesets in parallel. If an error occurs when downloading a Changeset this operation queries * the failed Changeset by id and retries the download once. If the Changeset file with the expected name already * exists in the target directory and the file size matches the one expected the Changeset is not downloaded again. * @param {DownloadChangesetListParams} params parameters for this operation. See {@link DownloadChangesetListParams}. * @returns downloaded Changeset metadata along with the downloaded file path. See {@link DownloadedChangeset}. */ async downloadList(params) { await this._options.localFileSystem.createDirectory(params.targetDirectoryPath); const [downloadCallback, downloadFailedCallback] = (await this.provideDownloadCallbacks(params)) ?? []; let result = []; for await (const changesetPage of this.getRepresentationList(params).byPage()) { const changesetsWithFilePath = changesetPage.map((changeset) => ({ ...changeset, filePath: path.join(params.targetDirectoryPath, this.createFileName(changeset.id)), })); result = result.concat(changesetsWithFilePath); // We sort the changesets by fileSize in descending order to download small // changesets first because their SAS tokens have a shorter lifespan. changesetsWithFilePath.sort((changeset1, changeset2) => changeset1.fileSize - changeset2.fileSize); const queue = new LimitedParallelQueue({ maxParallelPromises: 10 }); for (const changeset of changesetsWithFilePath) queue.push(async () => this.downloadChangesetWithRetries({ authorization: params.authorization, iModelId: params.iModelId, changeset, abortSignal: params.abortSignal, downloadCallback, downloadFailedCallback: downloadFailedCallback, maxRetries: params.maxRetries, headers: params.headers, })); await queue.waitAll(); } return result; } getCreateChangesetRequestBody(changesetProperties, changesetFileSize) { return { id: changesetProperties.id, description: changesetProperties.description, parentId: changesetProperties.parentId, briefcaseId: changesetProperties.briefcaseId, containingChanges: changesetProperties.containingChanges, fileSize: changesetFileSize, synchronizationInfo: changesetProperties.synchronizationInfo, groupId: changesetProperties.groupId, }; } getConfirmUploadRequestBody(changesetProperties) { return { state: ChangesetState.FileUploaded, briefcaseId: changesetProperties.briefcaseId, }; } async downloadChangeset(params) { const changesetWithPath = { ...params.changeset, filePath: path.join(params.targetDirectoryPath, this.createFileName(params.changeset.id)), }; const downloadCallback = params.progressCallback ? (bytes) => params.progressCallback?.(bytes, changesetWithPath.fileSize) : undefined; await this.downloadChangesetWithRetries({ authorization: params.authorization, iModelId: params.iModelId, changeset: changesetWithPath, abortSignal: params.abortSignal, downloadCallback, maxRetries: params.maxRetries, headers: params.headers, }); return changesetWithPath; } async downloadChangesetWithRetries(params) { const targetFilePath = params.changeset.filePath; if (await this.isChangesetAlreadyDownloaded(targetFilePath, params.changeset.fileSize)) return; const downloadParams = { storage: this._options.cloudStorage, localPath: targetFilePath, abortSignal: params.abortSignal, }; let firstError; for (let i = 0; i < (params.maxRetries ?? 4); i++) { let bytesDownloaded = 0; if (params.downloadCallback) { downloadParams.latestDownloadedChunkSizeCallback = (downloaded) => { bytesDownloaded += downloaded; params.downloadCallback?.(downloaded); }; } try { const downloadLink = await this.getDownloadLink(params, i > 0); assertLink(downloadLink); await downloadFile({ ...downloadParams, url: downloadLink.href, storageType: downloadLink.storageType, }); return; } catch (error) { this.throwIfAbortError(error, params.changeset, downloadParams.abortSignal); params.downloadFailedCallback?.(bytesDownloaded); if (error instanceof Error && firstError == null) firstError = error; } } throw new IModelsErrorImpl({ code: IModelsErrorCode.ChangesetDownloadFailed, message: `Failed to download changeset. Changeset id: ${params.changeset.id}, changeset index: ${params.changeset.index}, error: ${JSON.stringify(firstError)}.`, originalError: firstError, statusCode: undefined, details: undefined, }); } async getDownloadLink(params, refreshLink) { if (!refreshLink) return params.changeset._links.download; const changeset = await this.querySingleInternal({ authorization: params.authorization, iModelId: params.iModelId, changesetId: params.changeset.id, headers: params.headers, }); return changeset._links.download; } async isChangesetAlreadyDownloaded(targetFilePath, expectedFileSize) { const fileExists = await this._options.localFileSystem.fileExists(targetFilePath); if (!fileExists) return false; const existingFileSize = await this._options.localFileSystem.getFileSize(targetFilePath); if (existingFileSize === expectedFileSize) return true; await this._options.localFileSystem.deleteFile(targetFilePath); return false; } createFileName(changesetId) { return `${changesetId}.cs`; } async provideDownloadCallbacks(params) { if (!params.progressCallback) return; let totalSize = 0; let totalDownloaded = 0; for await (const changesetPage of this.getMinimalList(params).byPage()) { for (const changeset of changesetPage) { totalSize += changeset.fileSize; const filePath = path.join(params.targetDirectoryPath, this.createFileName(changeset.id)); if (await this.isChangesetAlreadyDownloaded(filePath, changeset.fileSize)) totalDownloaded += changeset.fileSize; } } const progressCallback = (downloaded) => { totalDownloaded += downloaded; params.progressCallback?.(totalDownloaded, totalSize); }; // We increase total size to prevent cases where downloaded size is larger than total size at the end of the download. const downloadFailedCallback = (downloadedBeforeFailure) => (totalSize += downloadedBeforeFailure); return [progressCallback, downloadFailedCallback]; } throwIfAbortError(error, changeset, abortSignal) { if (!isIModelsApiError(error) || error.code !== IModelsErrorCode.DownloadAborted || abortSignal?.aborted !== true) return; error.originalError = new Error(error.message); error.message = `Changeset(s) download was aborted. Changeset id: ${changeset.id}}.`; throw error; } } //# sourceMappingURL=ChangesetOperations.js.map