From 2193a77247f7a636425324e6ced988102f2f8001 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Tue, 26 Mar 2024 06:55:00 +0100 Subject: [PATCH] feat(cli): Add job queue command --- packages/cli/src/commands/add/add.ts | 2 + .../add/api-extension/add-api-extension.ts | 52 +------ .../commands/add/job-queue/add-job-queue.ts | 147 ++++++++++++++++++ .../commands/add/plugin/create-new-plugin.ts | 14 +- .../src/commands/add/service/add-service.ts | 5 +- .../add-ui-extension-static-prop.ts | 6 +- packages/cli/src/shared/cli-command.ts | 1 + packages/cli/src/shared/service-ref.ts | 17 +- packages/cli/src/shared/shared-prompts.ts | 51 ++++++ packages/cli/src/utilities/ast-utils.ts | 11 -- 10 files changed, 240 insertions(+), 66 deletions(-) create mode 100644 packages/cli/src/commands/add/job-queue/add-job-queue.ts diff --git a/packages/cli/src/commands/add/add.ts b/packages/cli/src/commands/add/add.ts index 5259aaa946..475284b82a 100644 --- a/packages/cli/src/commands/add/add.ts +++ b/packages/cli/src/commands/add/add.ts @@ -6,6 +6,7 @@ import { CliCommand } from '../../shared/cli-command'; import { addApiExtensionCommand } from './api-extension/add-api-extension'; import { addCodegenCommand } from './codegen/add-codegen'; import { addEntityCommand } from './entity/add-entity'; +import { addJobQueueCommand } from './job-queue/add-job-queue'; import { createNewPluginCommand } from './plugin/create-new-plugin'; import { addServiceCommand } from './service/add-service'; import { addUiExtensionsCommand } from './ui-extensions/add-ui-extensions'; @@ -22,6 +23,7 @@ export function registerAddCommand(program: Command) { addEntityCommand, addServiceCommand, addApiExtensionCommand, + addJobQueueCommand, addUiExtensionsCommand, addCodegenCommand, ]; diff --git a/packages/cli/src/commands/add/api-extension/add-api-extension.ts b/packages/cli/src/commands/add/api-extension/add-api-extension.ts index cdcf879713..b9baa94e9f 100644 --- a/packages/cli/src/commands/add/api-extension/add-api-extension.ts +++ b/packages/cli/src/commands/add/api-extension/add-api-extension.ts @@ -1,4 +1,5 @@ -import { select, spinner } from '@clack/prompts'; +import { spinner } from '@clack/prompts'; +import { paramCase } from 'change-case'; import path from 'path'; import { ClassDeclaration, @@ -13,10 +14,9 @@ import { import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command'; import { EntityRef } from '../../../shared/entity-ref'; import { ServiceRef } from '../../../shared/service-ref'; -import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts'; +import { analyzeProject, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts'; import { VendurePluginRef } from '../../../shared/vendure-plugin-ref'; -import { addImportsToFile, createFile, kebabize } from '../../../utilities/ast-utils'; -import { addServiceCommand } from '../service/add-service'; +import { addImportsToFile, createFile } from '../../../utilities/ast-utils'; const cancelledMessage = 'Add API extension cancelled'; @@ -109,7 +109,7 @@ function createSimpleResolver(project: Project, plugin: VendurePluginRef, servic path.join( plugin.getPluginDir().getPath(), 'api', - kebabize(serviceRef.name).replace('-service', '') + '-admin.resolver.ts', + paramCase(serviceRef.name).replace('-service', '') + '-admin.resolver.ts', ), ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -146,7 +146,7 @@ function createCrudResolver( path.join( plugin.getPluginDir().getPath(), 'api', - kebabize(serviceEntityRef.name) + '-admin.resolver.ts', + paramCase(serviceEntityRef.name) + '-admin.resolver.ts', ), ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -407,43 +407,3 @@ function getOrCreateApiExtensionsFile(project: Project, plugin: VendurePluginRef path.join(plugin.getPluginDir().getPath(), 'api', 'api-extensions.ts'), ); } - -async function selectServiceRef(project: Project, plugin: VendurePluginRef): Promise { - const serviceRefs = getServices(project); - const result = await select({ - message: 'Which service contains the business logic for this API extension?', - maxItems: 8, - options: [ - { - value: 'new', - label: `Create new generic service`, - }, - ...serviceRefs.map(sr => { - const features = sr.crudEntityRef - ? `CRUD service for ${sr.crudEntityRef.name}` - : `Generic service`; - const label = `${sr.name}: (${features})`; - return { - value: sr, - label, - }; - }), - ], - }); - if (result === 'new') { - return addServiceCommand.run({ type: 'basic', plugin }).then(r => r.serviceRef); - } else { - return result as ServiceRef; - } -} - -function getServices(project: Project): ServiceRef[] { - const servicesSourceFiles = project.getSourceFiles().filter(sf => { - return sf.getDirectory().getPath().endsWith('/services'); - }); - - return servicesSourceFiles - .flatMap(sf => sf.getClasses()) - .filter(classDeclaration => classDeclaration.getDecorator('Injectable')) - .map(classDeclaration => new ServiceRef(classDeclaration)); -} diff --git a/packages/cli/src/commands/add/job-queue/add-job-queue.ts b/packages/cli/src/commands/add/job-queue/add-job-queue.ts new file mode 100644 index 0000000000..27493a9532 --- /dev/null +++ b/packages/cli/src/commands/add/job-queue/add-job-queue.ts @@ -0,0 +1,147 @@ +import { cancel, isCancel, text } from '@clack/prompts'; +import { camelCase, pascalCase } from 'change-case'; +import { Node, Scope } from 'ts-morph'; + +import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command'; +import { ServiceRef } from '../../../shared/service-ref'; +import { analyzeProject, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts'; +import { VendurePluginRef } from '../../../shared/vendure-plugin-ref'; +import { addImportsToFile } from '../../../utilities/ast-utils'; + +const cancelledMessage = 'Add API extension cancelled'; + +export interface AddJobQueueOptions { + plugin?: VendurePluginRef; +} + +export const addJobQueueCommand = new CliCommand({ + id: 'add-job-queue', + category: 'Plugin: Job Queue', + description: 'Defines an new job queue on a service', + run: options => addJobQueue(options), +}); + +async function addJobQueue( + options?: AddJobQueueOptions, +): Promise> { + const providedVendurePlugin = options?.plugin; + const project = await analyzeProject({ providedVendurePlugin, cancelledMessage }); + const plugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage)); + const serviceRef = await selectServiceRef(project, plugin); + + const jobQueueName = await text({ + message: 'What is the name of the job queue?', + initialValue: 'my-background-task', + validate: input => { + if (!/^[a-z][a-z-0-9]+$/.test(input)) { + return 'The job queue name must be lowercase and contain only letters, numbers and dashes'; + } + }, + }); + + if (isCancel(jobQueueName)) { + cancel(cancelledMessage); + process.exit(0); + } + + addImportsToFile(serviceRef.classDeclaration.getSourceFile(), { + moduleSpecifier: '@vendure/core', + namedImports: ['JobQueue', 'JobQueueService', 'SerializedRequestContext'], + }); + + addImportsToFile(serviceRef.classDeclaration.getSourceFile(), { + moduleSpecifier: '@vendure/common/lib/generated-types', + namedImports: ['JobState'], + }); + + addImportsToFile(serviceRef.classDeclaration.getSourceFile(), { + moduleSpecifier: '@nestjs/common', + namedImports: ['OnModuleInit'], + }); + + serviceRef.injectDependency({ + name: 'jobQueueService', + type: 'JobQueueService', + }); + + serviceRef.classDeclaration.addProperty({ + name: camelCase(jobQueueName), + scope: Scope.Private, + type: writer => writer.write('JobQueue<{ ctx: SerializedRequestContext, someArg: string; }>'), + }); + + serviceRef.classDeclaration.addImplements('OnModuleInit'); + let onModuleInitMethod = serviceRef.classDeclaration.getMethod('onModuleInit'); + if (!onModuleInitMethod) { + onModuleInitMethod = serviceRef.classDeclaration.addMethod({ + name: 'onModuleInit', + isAsync: false, + returnType: 'void', + scope: Scope.Public, + }); + onModuleInitMethod.setScope(Scope.Private); + } + onModuleInitMethod.setIsAsync(true); + onModuleInitMethod.setReturnType('Promise'); + const body = onModuleInitMethod.getBody(); + if (Node.isBlock(body)) { + body.addStatements(writer => { + writer + .write( + `this.${camelCase(jobQueueName)} = await this.jobQueueService.createQueue({ + name: '${jobQueueName}', + process: async job => { + // Deserialize the RequestContext from the job data + const ctx = RequestContext.deserialize(job.data.ctx); + // The "someArg" property is passed in when the job is triggered + const someArg = job.data.someArg; + + // Inside the \`process\` function we define how each job + // in the queue will be processed. + // Let's simulate some long-running task + const totalItems = 10; + for (let i = 0; i < totalItems; i++) { + await new Promise(resolve => setTimeout(resolve, 500)); + + // You can optionally respond to the job being cancelled + // during processing. This can be useful for very long-running + // tasks which can be cancelled by the user. + if (job.state === JobState.CANCELLED) { + throw new Error('Job was cancelled'); + } + + // Progress can be reported as a percentage like this + job.setProgress(Math.floor(i / totalItems * 100)); + } + + // The value returned from the \`process\` function is stored + // as the "result" field of the job + return { + processedCount: totalItems, + message: \`Successfully processed \${totalItems} items\`, + }; + }, + })`, + ) + .newLine(); + }).forEach(s => s.formatText()); + } + + serviceRef.classDeclaration + .addMethod({ + name: `trigger${pascalCase(jobQueueName)}`, + scope: Scope.Public, + parameters: [{ name: 'ctx', type: 'RequestContext' }], + statements: writer => { + writer.write(`return this.${camelCase(jobQueueName)}.add({ + ctx: ctx.serialize(), + someArg: 'foo', + })`); + }, + }) + .formatText(); + + await project.save(); + + return { project, modifiedSourceFiles: [serviceRef.classDeclaration.getSourceFile()], serviceRef }; +} diff --git a/packages/cli/src/commands/add/plugin/create-new-plugin.ts b/packages/cli/src/commands/add/plugin/create-new-plugin.ts index 6f1860ff38..7c09a85993 100644 --- a/packages/cli/src/commands/add/plugin/create-new-plugin.ts +++ b/packages/cli/src/commands/add/plugin/create-new-plugin.ts @@ -1,15 +1,16 @@ -import { cancel, intro, isCancel, outro, select, spinner, text } from '@clack/prompts'; +import { cancel, intro, isCancel, select, spinner, text } from '@clack/prompts'; import { constantCase, paramCase, pascalCase } from 'change-case'; import * as fs from 'fs-extra'; import path from 'path'; -import { SourceFile } from 'ts-morph'; import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command'; import { VendureConfigRef } from '../../../shared/vendure-config-ref'; import { VendurePluginRef } from '../../../shared/vendure-plugin-ref'; import { addImportsToFile, createFile, getTsMorphProject } from '../../../utilities/ast-utils'; +import { addApiExtensionCommand } from '../api-extension/add-api-extension'; import { addCodegenCommand } from '../codegen/add-codegen'; import { addEntityCommand } from '../entity/add-entity'; +import { addJobQueueCommand } from '../job-queue/add-job-queue'; import { addServiceCommand } from '../service/add-service'; import { addUiExtensionsCommand } from '../ui-extensions/add-ui-extensions'; @@ -78,7 +79,14 @@ export async function createNewPlugin(): Promise { configSpinner.stop('Updated VendureConfig'); let done = false; - const followUpCommands = [addEntityCommand, addServiceCommand, addUiExtensionsCommand, addCodegenCommand]; + const followUpCommands = [ + addEntityCommand, + addServiceCommand, + addApiExtensionCommand, + addJobQueueCommand, + addUiExtensionsCommand, + addCodegenCommand, + ]; const allModifiedSourceFiles = [...modifiedSourceFiles]; while (!done) { const featureType = await select({ diff --git a/packages/cli/src/commands/add/service/add-service.ts b/packages/cli/src/commands/add/service/add-service.ts index f2a10e6b34..0ff5301d7d 100644 --- a/packages/cli/src/commands/add/service/add-service.ts +++ b/packages/cli/src/commands/add/service/add-service.ts @@ -1,4 +1,5 @@ import { cancel, isCancel, select, text } from '@clack/prompts'; +import { paramCase } from 'change-case'; import path from 'path'; import { ClassDeclaration, SourceFile } from 'ts-morph'; @@ -8,7 +9,7 @@ import { EntityRef } from '../../../shared/entity-ref'; import { ServiceRef } from '../../../shared/service-ref'; import { analyzeProject, selectEntity, selectPlugin } from '../../../shared/shared-prompts'; import { VendurePluginRef } from '../../../shared/vendure-plugin-ref'; -import { addImportsToFile, createFile, kebabize } from '../../../utilities/ast-utils'; +import { addImportsToFile, createFile } from '../../../utilities/ast-utils'; const cancelledMessage = 'Add service cancelled'; @@ -124,7 +125,7 @@ async function addService( removedUnusedConstructorArgs(serviceClassDeclaration, entityRef); } - const serviceFileName = kebabize(options.serviceName).replace(/-service$/, '.service'); + const serviceFileName = paramCase(options.serviceName).replace(/-service$/, '.service'); serviceSourceFile?.move( path.join(vendurePlugin.getPluginDir().getPath(), 'services', `${serviceFileName}.ts`), ); diff --git a/packages/cli/src/commands/add/ui-extensions/codemods/add-ui-extension-static-prop/add-ui-extension-static-prop.ts b/packages/cli/src/commands/add/ui-extensions/codemods/add-ui-extension-static-prop/add-ui-extension-static-prop.ts index 9746daf4b0..340a066eec 100644 --- a/packages/cli/src/commands/add/ui-extensions/codemods/add-ui-extension-static-prop/add-ui-extension-static-prop.ts +++ b/packages/cli/src/commands/add/ui-extensions/codemods/add-ui-extension-static-prop/add-ui-extension-static-prop.ts @@ -1,8 +1,8 @@ -import { ClassDeclaration } from 'ts-morph'; +import { paramCase } from 'change-case'; import { AdminUiExtensionTypeName } from '../../../../../constants'; import { VendurePluginRef } from '../../../../../shared/vendure-plugin-ref'; -import { addImportsToFile, kebabize } from '../../../../../utilities/ast-utils'; +import { addImportsToFile } from '../../../../../utilities/ast-utils'; /** * @description @@ -11,7 +11,7 @@ import { addImportsToFile, kebabize } from '../../../../../utilities/ast-utils'; export function addUiExtensionStaticProp(plugin: VendurePluginRef) { const pluginClass = plugin.classDeclaration; const adminUiExtensionType = AdminUiExtensionTypeName; - const extensionId = kebabize(pluginClass.getName() as string).replace(/-plugin$/, ''); + const extensionId = paramCase(pluginClass.getName() as string).replace(/-plugin$/, ''); pluginClass .addProperty({ name: 'ui', diff --git a/packages/cli/src/shared/cli-command.ts b/packages/cli/src/shared/cli-command.ts index ecddb98c94..716bc2616d 100644 --- a/packages/cli/src/shared/cli-command.ts +++ b/packages/cli/src/shared/cli-command.ts @@ -8,6 +8,7 @@ export type CommandCategory = | `Plugin: Entity` | `Plugin: Service` | `Plugin: API` + | `Plugin: Job Queue` | `Project: Codegen` | `Other`; diff --git a/packages/cli/src/shared/service-ref.ts b/packages/cli/src/shared/service-ref.ts index c2f9a8a91f..9837fc1ac4 100644 --- a/packages/cli/src/shared/service-ref.ts +++ b/packages/cli/src/shared/service-ref.ts @@ -1,4 +1,4 @@ -import { ClassDeclaration, Node, Type } from 'ts-morph'; +import { ClassDeclaration, Node, Scope, Type } from 'ts-morph'; import { EntityRef } from './entity-ref'; @@ -37,6 +37,21 @@ export class ServiceRef { this.crudEntityRef = this.getEntityRef(); } + injectDependency(dependency: { scope?: Scope; name: string; type: string }) { + for (const constructorDeclaration of this.classDeclaration.getConstructors()) { + const existingParam = constructorDeclaration.getParameter(dependency.name); + if (!existingParam) { + constructorDeclaration.addParameter({ + name: dependency.name, + type: dependency.type, + hasQuestionToken: false, + isReadonly: false, + scope: dependency.scope ?? Scope.Private, + }); + } + } + } + private getEntityRef(): EntityRef | undefined { if (this.features.findOne) { const potentialCrudMethodNames = ['findOne', 'findAll', 'create', 'update', 'delete']; diff --git a/packages/cli/src/shared/shared-prompts.ts b/packages/cli/src/shared/shared-prompts.ts index 2688a457d9..c82c0fa5f2 100644 --- a/packages/cli/src/shared/shared-prompts.ts +++ b/packages/cli/src/shared/shared-prompts.ts @@ -1,10 +1,12 @@ import { cancel, isCancel, multiselect, select, spinner } from '@clack/prompts'; import { ClassDeclaration, Project } from 'ts-morph'; +import { addServiceCommand } from '../commands/add/service/add-service'; import { Messages } from '../constants'; import { getPluginClasses, getTsMorphProject } from '../utilities/ast-utils'; import { EntityRef } from './entity-ref'; +import { ServiceRef } from './service-ref'; import { VendurePluginRef } from './vendure-plugin-ref'; export async function analyzeProject(options: { @@ -109,3 +111,52 @@ export async function selectMultiplePluginClasses( } return (targetPlugins as ClassDeclaration[]).map(pc => new VendurePluginRef(pc)); } + +export async function selectServiceRef(project: Project, plugin: VendurePluginRef): Promise { + const serviceRefs = getServices(project).filter(sr => { + return sr.classDeclaration + .getSourceFile() + .getDirectoryPath() + .includes(plugin.getSourceFile().getDirectoryPath()); + }); + const result = await select({ + message: 'Which service contains the business logic for this API extension?', + maxItems: 8, + options: [ + { + value: 'new', + label: `Create new generic service`, + }, + ...serviceRefs.map(sr => { + const features = sr.crudEntityRef + ? `CRUD service for ${sr.crudEntityRef.name}` + : `Generic service`; + const label = `${sr.name}: (${features})`; + return { + value: sr, + label, + }; + }), + ], + }); + if (isCancel(result)) { + cancel('Cancelled'); + process.exit(0); + } + if (result === 'new') { + return addServiceCommand.run({ type: 'basic', plugin }).then(r => r.serviceRef); + } else { + return result as ServiceRef; + } +} + +export function getServices(project: Project): ServiceRef[] { + const servicesSourceFiles = project.getSourceFiles().filter(sf => { + return sf.getDirectory().getPath().endsWith('/services'); + }); + + return servicesSourceFiles + .flatMap(sf => sf.getClasses()) + .filter(classDeclaration => classDeclaration.getDecorator('Injectable')) + .map(classDeclaration => new ServiceRef(classDeclaration)); +} diff --git a/packages/cli/src/utilities/ast-utils.ts b/packages/cli/src/utilities/ast-utils.ts index 4a631c30e1..5caf9d3497 100644 --- a/packages/cli/src/utilities/ast-utils.ts +++ b/packages/cli/src/utilities/ast-utils.ts @@ -108,17 +108,6 @@ export function createFile(project: Project, templatePath: string) { } } -export function kebabize(str: string) { - return str - .split('') - .map((letter, idx) => { - return letter.toUpperCase() === letter - ? `${idx !== 0 ? '-' : ''}${letter.toLowerCase()}` - : letter; - }) - .join(''); -} - function convertPathToRelativeImport(filePath: string): string { // Normalize the path separators const normalizedPath = filePath.replace(/\\/g, '/');