From 8c6a031bd4930f76e7fe0076389e11b3dfd7c573 Mon Sep 17 00:00:00 2001 From: Peter van Gulik Date: Thu, 29 Sep 2022 15:27:07 +0200 Subject: [PATCH] feat: support tooling API for deployment of LWC components --- .../vlocity-deploy/src/datapackDeployer.ts | 13 ++++- .../vlocity-deploy/src/datapackDeployment.ts | 14 ++++-- .../src/deploymentSpecs/omniScript.ts | 42 +++++++++++++---- .../src/omniScript/omniScriptActivator.ts | 47 +++++++++++++++++-- .../src/omniScript/omniScriptLwcCompiler.ts | 41 ++++++++++++++++ 5 files changed, 136 insertions(+), 21 deletions(-) diff --git a/packages/vlocity-deploy/src/datapackDeployer.ts b/packages/vlocity-deploy/src/datapackDeployer.ts index a43c765b..a2525186 100644 --- a/packages/vlocity-deploy/src/datapackDeployer.ts +++ b/packages/vlocity-deploy/src/datapackDeployer.ts @@ -93,10 +93,19 @@ export interface DatapackDeploymentOptions extends RecordBatchOptions { */ strictDependencies?: boolean; /** - * When enabled LWC enabled OmniScripts will get compiled into native LWC components and be deployed to the target org during deployment. - * @default true; + * When enabled LWC enabled OmniScripts will not get compiled into native LWC components and be deployed to the target org during deployment. + * + * Use this if you want to manually compile OmniScripts into LWC or have a batch process ot activate OmniScript LWCs in bulk. + * @default false; */ skipLwcActivation?: boolean; + /** + * When true LWC components are deployed using the tooling API instead of the metadata API. The tooling API is usually faster and thus the proffered way to compiled deploy LWC components. + * + * Disable this if you need to use the metadata API to deploy LWC components. + * @default true; + */ + useToolingApi?: boolean; } @injectable.transient() diff --git a/packages/vlocity-deploy/src/datapackDeployment.ts b/packages/vlocity-deploy/src/datapackDeployment.ts index b0e61c0a..74384d12 100644 --- a/packages/vlocity-deploy/src/datapackDeployment.ts +++ b/packages/vlocity-deploy/src/datapackDeployment.ts @@ -50,7 +50,9 @@ const datapackDeploymentDefaultOptions = { purgeMatchingDependencies: false, purgeLookupOptimization: true, bulkDependencyResolution: true, - deltaCheck: false + deltaCheck: false, + skipLwcActivation: false, + useToolingApi: true }; /** @@ -140,7 +142,7 @@ export class DatapackDeployment extends AsyncEventEmitter record.isPending)) { - throw new Error('Unable to deploy records; circular dependency detected'); + if (records.size == 0) { + for (const record of Iterable.filter(this.records.values(), record => record.isPending)) { + const unsatisfiedDependencies = record.getUnresolvedDependencies().map(d => d.dependency.VlocityLookupRecordSourceKey ?? d.dependency.VlocityMatchingRecordSourceKey).join(', '); + this.logger.error(`Unable to deploy ${record.sourceKey} due to unsatisfied dependencies: ${unsatisfiedDependencies}`); + record.updateStatus(DeploymentStatus.Failed, `Missing dependencies: ${unsatisfiedDependencies}`); + } } return records.size > 0 ? records : undefined; diff --git a/packages/vlocity-deploy/src/deploymentSpecs/omniScript.ts b/packages/vlocity-deploy/src/deploymentSpecs/omniScript.ts index 2433501b..9b6e3f9c 100644 --- a/packages/vlocity-deploy/src/deploymentSpecs/omniScript.ts +++ b/packages/vlocity-deploy/src/deploymentSpecs/omniScript.ts @@ -1,6 +1,6 @@ import { injectable , LifecyclePolicy , Logger } from '@vlocode/core'; -import { SalesforcePackage, SalesforceService } from '@vlocode/salesforce'; -import { forEachAsyncParallel, Timer } from '@vlocode/util'; +import { SalesforceDeployService, SalesforcePackage, SalesforceService } from '@vlocode/salesforce'; +import { forEachAsyncParallel, Iterable, Timer } from '@vlocode/util'; import { DatapackDeploymentRecord, DeploymentStatus } from '../datapackDeploymentRecord'; import { VlocityDatapack } from '../datapack'; import type { DatapackDeploymentEvent, DatapackDeploymentOptions } from '../datapackDeployer'; @@ -14,14 +14,15 @@ export class OmniScript implements DatapackDeploymentSpec { public constructor( private readonly activator: OmniScriptActivator, - private readonly salesforceService: SalesforceService, @injectable.param('DatapackDeploymentOptions') private readonly options: DatapackDeploymentOptions, private readonly logger: Logger) { } public async preprocess(datapack: VlocityDatapack) { - if (datapack.IsLwcEnabled__c && this.options.skipLwcActivation !== true) { + if (datapack.IsLwcEnabled__c && !this.options.skipLwcActivation) { this.lwcEnabledDatapacks.add(datapack.key); + } else { + this.logger.verbose(`Skipping LWC component compilation for ${datapack.key} due to "skipLwcActivation" being set as "true"`); } // Insert as inactive and update later in the process these are activated @@ -37,14 +38,11 @@ export class OmniScript implements DatapackDeploymentSpec { } public async afterDeploy(event: DatapackDeploymentEvent) { - const packages = new Array(); + // First activate the OmniScripts which generates the OmniScriptDefinition__c records await forEachAsyncParallel(event.getDeployedRecords('OmniScript__c'), async record => { try { const timer = new Timer(); await this.activator.activate(record.recordId, { skipLwcDeployment: true }); - if (this.lwcEnabledDatapacks.has(record.datapackKey)) { - packages.push(await this.activator.getLwcComponentBundle(record.recordId)); - } this.logger.info(`Activated ${record.datapackKey} [${timer.stop()}]`); } catch(err) { this.logger.error(`Failed to activate ${record.datapackKey} -- ${err}`); @@ -52,9 +50,33 @@ export class OmniScript implements DatapackDeploymentSpec { } }, 4); + // Then compile the LWC components; this is not done in a parallel loop to avoid LOCK errors from the tooling API + if (!this.options.skipLwcActivation) { + await this.deployLwcComponents(event); + } + } + + public async deployLwcComponents(event: DatapackDeploymentEvent) { + const packages = new Array(); + + for (const record of Iterable.filter(event.getDeployedRecords('OmniScript__c'), r => this.lwcEnabledDatapacks.has(r.datapackKey))) { + try { + if (this.options.useToolingApi) { + await this.activator.activateLwc(record.recordId, { toolingApi: true }); + } else { + packages.push(await this.activator.getLwcComponentBundle(record.recordId)); + } + } catch(err) { + this.logger.error(`Failed to deploy LWC component for ${record.datapackKey} -- ${err}`); + record.updateStatus(DeploymentStatus.Failed, err.message || err); + } + } + if (packages.length) { - this.logger.info(`Deploying ${packages.length} LWC components`); - await this.salesforceService.deploy.deployPackage(packages.reduce((p, c) => p.merge(c))); + const timer = new Timer(); + this.logger.info(`Deploying ${packages.length} LWC component(s) using metadata api...`); + await new SalesforceDeployService(undefined, Logger.null).deployPackage(packages.reduce((p, c) => p.merge(c))); + this.logger.info(`Deployed ${packages.length} LWC components [${timer.stop()}]`); } } } \ No newline at end of file diff --git a/packages/vlocity-deploy/src/omniScript/omniScriptActivator.ts b/packages/vlocity-deploy/src/omniScript/omniScriptActivator.ts index 8149ed87..24853cc3 100644 --- a/packages/vlocity-deploy/src/omniScript/omniScriptActivator.ts +++ b/packages/vlocity-deploy/src/omniScript/omniScriptActivator.ts @@ -1,7 +1,7 @@ import { QueryService, SalesforceService, NamespaceService, QueryBuilder, SalesforceDeployService } from '@vlocode/salesforce'; import { injectable, Logger } from '@vlocode/core'; import { OmniScriptDefinition, OmniScriptDetail } from './omniScriptDefinition'; -import { Iterable } from '@vlocode/util'; +import { Iterable, Timer } from '@vlocode/util'; import { OmniScriptLwcCompiler } from './omniScriptLwcCompiler'; import { ScriptDefinitionProvider } from './scriptDefinitionProvider'; @@ -63,9 +63,9 @@ export class OmniScriptActivator { * Activate the LWC component for the specified OmniScript regardless of the script is LWC enabled or not. * @param id Id of the OmniScript for which to activate the LWC component */ - public async activateLwc(id: string) { + public async activateLwc(id: string, options?: { toolingApi?: boolean }) { const definition = await this.definitionProvider.getScriptDefinition(id); - await this.deployLwcComponent(definition); + await this.deployLwcComponent(definition, options); } /** @@ -78,13 +78,50 @@ export class OmniScriptActivator { return this.lwcCompiler.compileToPackage(definition); } - private async deployLwcComponent(definition: OmniScriptDefinition) { + private async deployLwcComponent(definition: OmniScriptDefinition, options?: { toolingApi?: boolean }) { + const timer = new Timer(); + const apiLabel = options?.toolingApi ? 'tooling' : 'metadata'; + this.logger.info(`Deploying OmniScript ${definition.bpType}/${definition.bpSubType} LWC component with ${apiLabel} api...`); + + if (options?.toolingApi) { + await this.deployLwcWithToolingApi(definition); + } else { + await this.deployLwcWithMetadataApi(definition); + } + + this.logger.info(`Deployed OmniScript ${definition.bpType}/${definition.bpSubType} LWC component with ${apiLabel} api [${timer.stop()}]`); + } + + private async deployLwcWithMetadataApi(definition: OmniScriptDefinition) { const sfPackage = await this.lwcCompiler.compileToPackage(definition); const deployService = new SalesforceDeployService(this.salesforceService, Logger.null); - const result = await deployService.deployPackage(sfPackage); + const result = await deployService.deployPackage(sfPackage); if (!result.success) { throw new Error(`OmniScript LWC Component deployment failed: ${result.details?.componentFailures.map(failure => failure.problem)}`); + } + } + + private async deployLwcWithToolingApi(definition: OmniScriptDefinition) { + const tollingRecord = await this.lwcCompiler.compileToolingRecord(definition) + const result = await this.upsertToolingRecord(`LightningComponentBundle`, tollingRecord); + if (!result.success) { + throw new Error(`OmniScript LWC Component deployment failed: ${JSON.stringify(result.errors)}`); + } + } + + private async upsertToolingRecord(type: string, toolingRecord: { Id?: string, FullName: string, Metadata: any }): Promise<{ success: boolean, errors: string[] }> { + const connection = await this.salesforceService.getJsForceConnection(); + if (!toolingRecord.Id) { + const existingRecord = await connection.tooling.query<{ Id: string }>(`SELECT Id FROM ${type} WHERE DeveloperName = '${toolingRecord.FullName}'`); + if (existingRecord.totalSize > 0) { + toolingRecord.Id = existingRecord.records[0].Id; + } } + + if (toolingRecord.Id) { + return connection.tooling.update(type, toolingRecord) as any; + } + return connection.tooling.create(type, toolingRecord) as any; } private async getScript(input: OmniScriptDetail | string) { diff --git a/packages/vlocity-deploy/src/omniScript/omniScriptLwcCompiler.ts b/packages/vlocity-deploy/src/omniScript/omniScriptLwcCompiler.ts index 469a5b02..c29d06fe 100644 --- a/packages/vlocity-deploy/src/omniScript/omniScriptLwcCompiler.ts +++ b/packages/vlocity-deploy/src/omniScript/omniScriptLwcCompiler.ts @@ -6,6 +6,7 @@ import { injectable } from '@vlocode/core'; import { OmniScriptDefinition, OmniScriptDetail } from './omniScriptDefinition'; import { VlocityNamespaceService } from '../vlocityNamespaceService'; import { ScriptDefinitionProvider } from './scriptDefinitionProvider'; +import { Timer, XML } from '@vlocode/util'; export interface CompiledResource { name: string, @@ -52,6 +53,7 @@ export class OmniScriptLwcCompiler{ throw new Error('Unable to find OmniScript LWC compiler; is the Vlocity managed package installed?'); } + // create JSDOM to init compiler const connection = await this.salesforceService.getJsForceConnection(); if (!this.compilerApiVersion) { @@ -95,6 +97,45 @@ export class OmniScriptLwcCompiler{ return { name: componentName, resources } } + public async compileToolingRecord(scriptDefinition: OmniScriptDefinition, options?: { lwcName?: string }) { + // Get the name of the component or generate it and compile the OS + const componentName = options?.lwcName ?? this.getLwcName({ type: scriptDefinition.bpType, subType: scriptDefinition.bpSubType, language: scriptDefinition.bpLang }); + const compiler = await this.getCompiler(); + const resources = await compiler.compileActivated(componentName, scriptDefinition, false, true, this.namespaceService.getNamespace()); + + const componentMetaDefinition = resources.find(r => r.name.endsWith('.js-meta.xml')); + if (!componentMetaDefinition) { + throw new Error(`LWC compiler did not generate a .js-meta.xml file for ${componentName}`); + } + + const { LightningComponentBundle: componentDef } = XML.parse(componentMetaDefinition.source); + const targetConfigs = XML.stringify(componentDef.targetConfigs, undefined, { headless: true }); + + const toolingMetadata = { + apiVersion: componentDef.apiVersion, + lwcResources: { + lwcResource: resources.filter(f => !f.name.endsWith('.js-meta.xml')).map(res =>({ + filePath: res.name, + source: Buffer.from(res.source, 'utf-8').toString('base64') + })) + }, + capabilities: { + capability: [] + }, + isExposed: componentDef.isExposed, + description: componentDef.description, + masterLabel: componentDef.masterLabel, + runtimeNamespace: componentDef.runtimeNamespace, + targets: componentDef.targets, + targetConfigs: Buffer.from(targetConfigs, 'utf-8').toString('base64') + } + + return { + FullName: componentName, + Metadata: toolingMetadata + }; + } + /** * Compile and package an OmniScript into a deployable Metadata package * @param scriptDefinition Definition of the OmniScript to compile