Skip to content

Commit

Permalink
fix: use MetadataApiDeploy instance methods (#343)
Browse files Browse the repository at this point in the history
* fix: use MetadataApiDeploy instance methods

* chore: migrate to instance methods

* chore: undo MetadataTransfer cancel

* chore: remove conditional around id in constructor

* chore: move checkStatus to transfer

* chore: move id validation to method instead of getter

* chore: deploy Id error

* chore: move deployid to transfer

* chore: correct jsdoc

* chore: update to use AsyncResult

* chore: add tests

* fix: cancel behavior and pattern

Co-authored-by: Bryan Powell <b.powell@salesforce.com>
  • Loading branch information
2 people authored and rcoringrato-sfdc committed Jun 11, 2021
1 parent 09e87a8 commit a563429
Show file tree
Hide file tree
Showing 10 changed files with 303 additions and 98 deletions.
105 changes: 82 additions & 23 deletions src/client/metadataApiDeploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
import { MetadataConverter } from '../convert';
import { DiagnosticUtil } from './diagnosticUtil';
import {
MetadataApiDeployStatus,
DeployMessage,
MetadataApiDeployOptions as ApiOptions,
AsyncResult,
ComponentStatus,
DeployMessage,
FileResponse,
MetadataApiDeployOptions as ApiOptions,
MetadataApiDeployStatus,
MetadataTransferResult,
} from './types';
import { MetadataTransfer, MetadataTransferOptions } from './metadataTransfer';
import { join, dirname, basename, extname } from 'path';
import { basename, dirname, extname, join } from 'path';
import { ComponentLike, SourceComponent } from '../resolve';
import { normalizeToArray } from '../utils/collections';
import { normalizeToArray } from '../utils';
import { ComponentSet } from '../collections';
import { registry } from '../registry';
import { isString } from '@salesforce/ts-types';
import { MissingJobIdError } from '../errors';

export class DeployResult implements MetadataTransferResult {
public readonly response: MetadataApiDeployStatus;
Expand Down Expand Up @@ -185,14 +188,87 @@ export class MetadataApiDeploy extends MetadataTransfer<MetadataApiDeployStatus,
},
};
private options: MetadataApiDeployOptions;
private deployId: string | undefined;

constructor(options: MetadataApiDeployOptions) {
super(options);
options.apiOptions = { ...MetadataApiDeploy.DEFAULT_OPTIONS.apiOptions, ...options.apiOptions };
this.options = Object.assign({}, options);
}

/**
* Deploy recently validated components without running Apex tests. Requires the operation to have been
* created with the `{ checkOnly: true }` API option.
*
* Ensure that the following requirements are met before deploying a recent validation:
* - The components have been validated successfully for the target environment within the last 10 days.
* - As part of the validation, Apex tests in the target org have passed.
* - Code coverage requirements are met.
* - If all tests in the org or all local tests are run, overall code coverage is at least 75%, and Apex triggers have some coverage.
* - If specific tests are run with the RunSpecifiedTests test level, each class and trigger that was deployed is covered by at least 75% individually.
*
* See [deployRecentValidation()](https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_deployRecentValidation.htm)
*
* @param rest - Set to `true` to use the REST API, otherwise defaults to using SOAP
* @returns The ID of the quick deployment
*/
public async deployRecentValidation(rest = false): Promise<string> {
if (!this.id) {
throw new MissingJobIdError('deploy');
}
const conn = await this.getConnection();
const response = ((await conn.deployRecentValidation({
id: this.id,
rest,
})) as unknown) as AsyncResult | string;
return isString(response) ? response : (response as AsyncResult).id;
}

/**
* Check the status of the deploy operation.
*
* @returns Status of the retrieve
*/
public async checkStatus(): Promise<MetadataApiDeployStatus> {
if (!this.id) {
throw new MissingJobIdError('deploy');
}
const connection = await this.getConnection();
// Recasting to use the project's version of the type
return (connection.metadata.checkDeployStatus(
this.id,
true
) as unknown) as MetadataApiDeployStatus;
}

/**
* Cancel the deploy operation.
*
* Deploys are asynchronously canceled. Once the cancel request is made to the org,
* check the status of the cancellation with `checkStatus`.
*/
public async cancel(): Promise<void> {
if (!this.id) {
throw new MissingJobIdError('deploy');
}

const connection = await this.getConnection();

return new Promise((resolve, reject) => {
connection.metadata
// @ts-ignore _invoke is private on the jsforce metadata object, and cancelDeploy is not an exposed method
._invoke('cancelDeploy', { id: this.id })
.thenCall((result: any) => {
// this does not return CancelDeployResult as documented in the API.
// a null result seems to indicate the request was successful
if (result) {
reject(result);
} else {
resolve(result);
}
});
});
}

protected async pre(): Promise<{ id: string }> {
const converter = new MetadataConverter();
const { zipBuffer } = await converter.convert(
Expand All @@ -203,27 +279,10 @@ export class MetadataApiDeploy extends MetadataTransfer<MetadataApiDeployStatus,
const connection = await this.getConnection();
await this.maybeSaveTempDirectory('metadata');
const result = await connection.metadata.deploy(zipBuffer, this.options.apiOptions);
this.deployId = result.id;
return result;
}

protected async checkStatus(id: string): Promise<MetadataApiDeployStatus> {
const connection = await this.getConnection();
// Recasting to use the project's version of the type
return (connection.metadata.checkDeployStatus(id, true) as unknown) as MetadataApiDeployStatus;
}

protected async post(result: MetadataApiDeployStatus): Promise<DeployResult> {
return new DeployResult(result, this.components);
}

protected async doCancel(): Promise<boolean> {
let done = true;
if (this.deployId) {
const connection = await this.getConnection();
// @ts-ignore _invoke is private on the jsforce metadata object, and cancelDeploy is not an exposed method
done = connection.metadata._invoke('cancelDeploy', { id: this.deployId }).done;
}
return done;
}
}
41 changes: 27 additions & 14 deletions src/client/metadataApiRetrieve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
RetrieveRequest,
} from './types';
import { MetadataTransfer, MetadataTransferOptions } from './metadataTransfer';
import { MetadataApiRetrieveError } from '../errors';
import { MetadataApiRetrieveError, MissingJobIdError } from '../errors';
import { normalizeToArray } from '../utils';
import { RegistryAccess } from '../registry';

Expand Down Expand Up @@ -100,6 +100,32 @@ export class MetadataApiRetrieve extends MetadataTransfer<
this.options = Object.assign({}, MetadataApiRetrieve.DEFAULT_OPTIONS, options);
}

/**
* Check the status of the retrieve operation.
*
* @returns Status of the retrieve
*/
public async checkStatus(): Promise<MetadataApiRetrieveStatus> {
if (!this.id) {
throw new MissingJobIdError('retrieve');
}
const connection = await this.getConnection();
// Recasting to use the project's RetrieveResult type
const status = await connection.metadata.checkRetrieveStatus(this.id);
status.fileProperties = normalizeToArray(status.fileProperties);
return status as MetadataApiRetrieveStatus;
}

/**
* Cancel the retrieve operation.
*
* Canceling a retrieve occurs immediately and requires no additional status
* checks to the org, unlike {@link MetadataApiDeploy.cancel}.
*/
public async cancel(): Promise<void> {
this.canceled = true;
}

protected async pre(): Promise<{ id: string }> {
const { packageNames } = this.options;

Expand All @@ -123,14 +149,6 @@ export class MetadataApiRetrieve extends MetadataTransfer<
return connection.metadata.retrieve(requestBody);
}

protected async checkStatus(id: string): Promise<MetadataApiRetrieveStatus> {
const connection = await this.getConnection();
// Recasting to use the project's RetrieveResult type
const status = await connection.metadata.checkRetrieveStatus(id);
status.fileProperties = normalizeToArray(status.fileProperties);
return status as MetadataApiRetrieveStatus;
}

protected async post(result: MetadataApiRetrieveStatus): Promise<RetrieveResult> {
let components: ComponentSet;
if (result.status === RequestStatus.Succeeded) {
Expand All @@ -144,11 +162,6 @@ export class MetadataApiRetrieve extends MetadataTransfer<
return new RetrieveResult(result, components);
}

protected async doCancel(): Promise<boolean> {
// retrieve doesn't require signaling to the server to stop
return true;
}

private async extract(zip: Buffer): Promise<ComponentSet> {
const converter = new MetadataConverter(this.options.registry);
const { merge, output } = this.options;
Expand Down
42 changes: 22 additions & 20 deletions src/client/metadataTransfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface MetadataTransferOptions {
usernameOrConnection: string | Connection;
components: ComponentSet;
apiVersion?: string;
id?: string;
}

export abstract class MetadataTransfer<
Expand All @@ -24,27 +25,35 @@ export abstract class MetadataTransfer<
> {
protected components: ComponentSet;
protected logger: Logger;
private signalCancel = false;
protected canceled = false;
private _id?: string;
private event = new EventEmitter();
private usernameOrConnection: string | Connection;
private apiVersion: string;

constructor({ usernameOrConnection, components, apiVersion }: MetadataTransferOptions) {
constructor({ usernameOrConnection, components, apiVersion, id }: MetadataTransferOptions) {
this.usernameOrConnection = usernameOrConnection;
this.components = components;
this.apiVersion = apiVersion;
this._id = id;
this.logger = Logger.childFromRoot(this.constructor.name);
}

get id(): string | undefined {
return this._id;
}

/**
* Start the metadata transfer.
*
* @param pollInterval Frequency in milliseconds to poll for operation status
*/
public async start(pollInterval = 100): Promise<Result | undefined> {
try {
this.canceled = false;
const { id } = await this.pre();
const apiResult = await this.pollStatus(id, pollInterval);
this._id = id;
const apiResult = await this.pollStatus(pollInterval);

if (!apiResult || apiResult.status === RequestStatus.Canceled) {
this.event.emit('cancel', apiResult);
Expand All @@ -63,10 +72,6 @@ export abstract class MetadataTransfer<
}
}

public cancel(): void {
this.signalCancel = true;
}

public onUpdate(subscriber: (result: Status) => void): void {
this.event.on('update', subscriber);
}
Expand Down Expand Up @@ -128,32 +133,29 @@ export abstract class MetadataTransfer<
return this.usernameOrConnection;
}

private async pollStatus(id: string, interval: number): Promise<Status> {
private async pollStatus(interval: number): Promise<Status> {
let result: Status;
let triedOnce = false;

try {
while (true) {
if (this.signalCancel) {
const shouldBreak = await this.doCancel();
if (shouldBreak) {
if (result) {
result.status = RequestStatus.Canceled;
}
return result;
if (this.canceled) {
if (result) {
result.status = RequestStatus.Canceled;
}
this.signalCancel = false;
return result;
}

if (triedOnce) {
await this.wait(interval);
}

result = await this.checkStatus(id);
result = await this.checkStatus();

switch (result.status) {
case RequestStatus.Succeeded:
case RequestStatus.Canceled:
this.canceled = true;
case RequestStatus.Succeeded:
case RequestStatus.Failed:
return result;
}
Expand All @@ -173,8 +175,8 @@ export abstract class MetadataTransfer<
});
}

public abstract checkStatus(): Promise<Status>;
public abstract cancel(): Promise<void>;
protected abstract pre(): Promise<{ id: string }>;
protected abstract checkStatus(id: string): Promise<Status>;
protected abstract post(result: Status): Promise<Result>;
protected abstract doCancel(): Promise<boolean>;
}
4 changes: 4 additions & 0 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export interface SourceApiResult {
success: boolean;
}

export interface AsyncResult {
id: RecordId;
}

export interface SourceDeployResult extends SourceApiResult {
id: RecordId;
components?: ComponentDeployment[];
Expand Down
6 changes: 6 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,9 @@ export class MetadataApiRetrieveError extends LibraryError {
super(messageKey, args);
}
}

export class MissingJobIdError extends LibraryError {
constructor(operation: string) {
super('error_no_job_id', [operation]);
}
}
2 changes: 2 additions & 0 deletions src/i18n/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export const messages = {
error_invalid_package: 'The metadata pacakge was not initialized properly',
error_static_resource_expected_archive_type:
'A StaticResource directory must have a content type of application/zip or application/jar - found %s for %s',
error_no_job_id:
'The %s operation is missing a job ID. Initialize an operation with an ID, or start a new job.',
tapi_deploy_component_limit_error:
'This deploy method only supports deploying one metadata component at a time',
warn_unresolved_source_for_components:
Expand Down
Loading

0 comments on commit a563429

Please sign in to comment.