Răsfoiți Sursa

feat(cli): Add job queue command

Michael Bromley 1 an în urmă
părinte
comite
2193a77247

+ 2 - 0
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,
             ];

+ 6 - 46
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<ServiceRef> {
-    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));
-}

+ 147 - 0
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<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
+    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<void>');
+    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 };
+}

+ 11 - 3
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<CliCommandReturnVal> {
     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({

+ 3 - 2
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`),
     );

+ 3 - 3
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',

+ 1 - 0
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`;
 

+ 16 - 1
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'];

+ 51 - 0
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<ServiceRef> {
+    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));
+}

+ 0 - 11
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, '/');