Skip to content

Commit

Permalink
feat(cli): Add job queue command
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Apr 3, 2024
1 parent 41675a4 commit 2193a77
Show file tree
Hide file tree
Showing 10 changed files with 240 additions and 66 deletions.
2 changes: 2 additions & 0 deletions packages/cli/src/commands/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,6 +23,7 @@ export function registerAddCommand(program: Command) {
addEntityCommand,
addServiceCommand,
addApiExtensionCommand,
addJobQueueCommand,
addUiExtensionsCommand,
addCodegenCommand,
];
Expand Down
52 changes: 6 additions & 46 deletions packages/cli/src/commands/add/api-extension/add-api-extension.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 changes: 147 additions & 0 deletions packages/cli/src/commands/add/job-queue/add-job-queue.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
14 changes: 11 additions & 3 deletions packages/cli/src/commands/add/plugin/create-new-plugin.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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({
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/add/service/add-service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';

Expand Down Expand Up @@ -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`),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/shared/cli-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type CommandCategory =
| `Plugin: Entity`
| `Plugin: Service`
| `Plugin: API`
| `Plugin: Job Queue`
| `Project: Codegen`
| `Other`;

Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/shared/service-ref.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ClassDeclaration, Node, Type } from 'ts-morph';
import { ClassDeclaration, Node, Scope, Type } from 'ts-morph';

import { EntityRef } from './entity-ref';

Expand Down Expand Up @@ -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'];
Expand Down
Loading

0 comments on commit 2193a77

Please sign in to comment.