diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutation.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutation.ts new file mode 100644 index 00000000000..eed7f4565c4 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutation.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type BulkOperationRunMutationMutationVariables = Types.Exact<{ + mutation: Types.Scalars['String']['input'] + stagedUploadPath: Types.Scalars['String']['input'] + clientIdentifier?: Types.InputMaybe +}> + +export type BulkOperationRunMutationMutation = { + bulkOperationRunMutation?: { + bulkOperation?: { + completedAt?: unknown | null + createdAt: unknown + errorCode?: Types.BulkOperationErrorCode | null + fileSize?: unknown | null + id: string + objectCount: unknown + partialDataUrl?: string | null + query: string + rootObjectCount: unknown + status: Types.BulkOperationStatus + type: Types.BulkOperationType + url?: string | null + } | null + userErrors: {code?: Types.BulkMutationErrorCode | null; field?: string[] | null; message: string}[] + } | null +} + +export const BulkOperationRunMutation = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'BulkOperationRunMutation'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'mutation'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'stagedUploadPath'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'clientIdentifier'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperationRunMutation'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'mutation'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'mutation'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'stagedUploadPath'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'stagedUploadPath'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'clientIdentifier'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'clientIdentifier'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperation'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'completedAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'errorCode'}}, + {kind: 'Field', name: {kind: 'Name', value: 'fileSize'}}, + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'objectCount'}}, + {kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}}, + {kind: 'Field', name: {kind: 'Name', value: 'query'}}, + {kind: 'Field', name: {kind: 'Name', value: 'rootObjectCount'}}, + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'type'}}, + {kind: 'Field', name: {kind: 'Name', value: 'url'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'userErrors'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'code'}}, + {kind: 'Field', name: {kind: 'Name', value: 'field'}}, + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/staged-uploads-create.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/staged-uploads-create.ts new file mode 100644 index 00000000000..c6d15becc46 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/staged-uploads-create.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type StagedUploadsCreateMutationVariables = Types.Exact<{ + input: Types.StagedUploadInput[] | Types.StagedUploadInput +}> + +export type StagedUploadsCreateMutation = { + stagedUploadsCreate?: { + stagedTargets?: + | { + url?: string | null + resourceUrl?: string | null + parameters: {name: string; value: string}[] + }[] + | null + userErrors: {field?: string[] | null; message: string}[] + } | null +} + +export const StagedUploadsCreate = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'StagedUploadsCreate'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'input'}}, + type: { + kind: 'NonNullType', + type: { + kind: 'ListType', + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'StagedUploadInput'}}}, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'stagedUploadsCreate'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'input'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'input'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'stagedTargets'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'url'}}, + {kind: 'Field', name: {kind: 'Name', value: 'resourceUrl'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'parameters'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'value'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'userErrors'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'field'}}, + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts index 942fb8d4851..f7f5a8bf841 100644 --- a/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts @@ -120,6 +120,32 @@ export type Scalars = { UtcOffset: {input: any; output: any} } +/** Possible error codes that can be returned by `BulkMutationUserError`. */ +export type BulkMutationErrorCode = + /** + * There was a problem reading the JSONL file. This error might be intermittent, + * so you can try performing the same query again. + */ + | 'INTERNAL_FILE_SERVER_ERROR' + /** The operation did not run because the mutation is invalid. Check your mutation syntax and try again. */ + | 'INVALID_MUTATION' + /** The JSONL file submitted via the `stagedUploadsCreate` mutation is invalid. Update the file and try again. */ + | 'INVALID_STAGED_UPLOAD_FILE' + /** Bulk operations limit reached. Please try again later. */ + | 'LIMIT_REACHED' + /** + * The JSONL file could not be found. Try [uploading the file](https://shopify.dev/api/usage/bulk-operations/imports#generate-the-uploaded-url-and-parameters) + * again, and check that you've entered the URL correctly for the + * `stagedUploadPath` mutation argument. + */ + | 'NO_SUCH_FILE' + /** + * The operation did not run because another bulk mutation is already running. + * [Wait for the operation to finish](https://shopify.dev/api/usage/bulk-operations/imports#wait-for-the-operation-to-finish) + * before retrying this operation. + */ + | 'OPERATION_IN_PROGRESS' + /** Error codes for failed bulk operations. */ export type BulkOperationErrorCode = /** @@ -177,3 +203,123 @@ export type BulkOperationUserErrorCode = | 'LIMIT_REACHED' /** A bulk operation is already in progress. */ | 'OPERATION_IN_PROGRESS' + +/** + * The possible HTTP methods that can be used when sending a request to upload a file using information from a + * [StagedMediaUploadTarget](https://shopify.dev/api/admin-graphql/latest/objects/StagedMediaUploadTarget). + */ +export type StagedUploadHttpMethodType = + /** The POST HTTP method. */ + | 'POST' + /** The PUT HTTP method. */ + | 'PUT' + +/** The input fields for generating staged upload targets. */ +export type StagedUploadInput = { + /** + * The size of the file to upload, in bytes. This is required when the request's resource property is set to + * [VIDEO](https://shopify.dev/api/admin-graphql/latest/enums/StagedUploadTargetGenerateUploadResource#value-video) + * or [MODEL_3D](https://shopify.dev/api/admin-graphql/latest/enums/StagedUploadTargetGenerateUploadResource#value-model3d). + */ + fileSize?: InputMaybe + /** The file's name and extension. */ + filename: Scalars['String']['input'] + /** + * The HTTP method to be used when sending a request to upload the file using the returned staged + * upload target. + */ + httpMethod?: InputMaybe + /** The file's MIME type. */ + mimeType: Scalars['String']['input'] + /** The file's intended Shopify resource type. */ + resource: StagedUploadTargetGenerateUploadResource +} + +/** The resource type to receive. */ +export type StagedUploadTargetGenerateUploadResource = + /** + * Represents bulk mutation variables. + * + * For example, bulk mutation variables can be used for bulk operations using the + * [bulkOperationRunMutation mutation](https://shopify.dev/api/admin-graphql/latest/mutations/bulkOperationRunMutation). + */ + | 'BULK_MUTATION_VARIABLES' + /** + * An image associated with a collection. + * + * For example, after uploading an image, you can use the + * [collectionUpdate mutation](https://shopify.dev/api/admin-graphql/latest/mutations/collectionUpdate) + * to add the image to a collection. + */ + | 'COLLECTION_IMAGE' + /** + * Represents a file associated with a dispute. + * + * For example, after uploading the file, you can add the file to a dispute using the + * [disputeEvidenceUpdate mutation](https://shopify.dev/api/admin-graphql/latest/mutations/disputeEvidenceUpdate). + */ + | 'DISPUTE_FILE_UPLOAD' + /** + * Represents any file other than HTML. + * + * For example, after uploading the file, you can add the file to the + * [Files page](https://shopify.com/admin/settings/files) in Shopify admin using the + * [fileCreate mutation](https://shopify.dev/api/admin-graphql/latest/mutations/fileCreate). + */ + | 'FILE' + /** + * An image. + * + * For example, after uploading an image, you can add the image to a product using the + * [productCreateMedia mutation](https://shopify.dev/api/admin-graphql/latest/mutations/productCreateMedia) + * or to the [Files page](https://shopify.com/admin/settings/files) in Shopify admin using the + * [fileCreate mutation](https://shopify.dev/api/admin-graphql/latest/mutations/fileCreate). + */ + | 'IMAGE' + /** + * A Shopify hosted 3d model. + * + * For example, after uploading the 3d model, you can add the 3d model to a product using the + * [productCreateMedia mutation](https://shopify.dev/api/admin-graphql/latest/mutations/productCreateMedia). + */ + | 'MODEL_3D' + /** + * An image that's associated with a product. + * + * For example, after uploading the image, you can add the image to a product using the + * [productCreateMedia mutation](https://shopify.dev/api/admin-graphql/latest/mutations/productCreateMedia). + */ + | 'PRODUCT_IMAGE' + /** + * Represents a label associated with a return. + * + * For example, once uploaded, this resource can be used to [create a + * ReverseDelivery](https://shopify.dev/api/admin-graphql/unstable/mutations/reverseDeliveryCreateWithShipping). + */ + | 'RETURN_LABEL' + /** + * An image. + * + * For example, after uploading the image, you can add the image to the + * [Files page](https://shopify.com/admin/settings/files) in Shopify admin using the + * [fileCreate mutation](https://shopify.dev/api/admin-graphql/latest/mutations/fileCreate). + */ + | 'SHOP_IMAGE' + /** + * Represents a redirect CSV file. + * + * Example usage: This resource can be used for creating a + * [UrlRedirectImport](https://shopify.dev/api/admin-graphql/2022-04/objects/UrlRedirectImport) + * object for use in the + * [urlRedirectImportCreate mutation](https://shopify.dev/api/admin-graphql/latest/mutations/urlRedirectImportCreate). + */ + | 'URL_REDIRECT_IMPORT' + /** + * A Shopify-hosted video. + * + * For example, after uploading the video, you can add the video to a product using the + * [productCreateMedia mutation](https://shopify.dev/api/admin-graphql/latest/mutations/productCreateMedia) + * or to the [Files page](https://shopify.com/admin/settings/files) in Shopify admin using the + * [fileCreate mutation](https://shopify.dev/api/admin-graphql/latest/mutations/fileCreate). + */ + | 'VIDEO' diff --git a/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutation.graphql b/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutation.graphql new file mode 100644 index 00000000000..f6027b46353 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutation.graphql @@ -0,0 +1,32 @@ +mutation BulkOperationRunMutation( + $mutation: String! + $stagedUploadPath: String! + $clientIdentifier: String +) { + bulkOperationRunMutation( + mutation: $mutation + stagedUploadPath: $stagedUploadPath + clientIdentifier: $clientIdentifier + ) { + bulkOperation { + completedAt + createdAt + errorCode + fileSize + id + objectCount + partialDataUrl + query + rootObjectCount + status + type + url + } + userErrors { + code + field + message + } + } +} + diff --git a/packages/app/src/cli/api/graphql/bulk-operations/mutations/staged-uploads-create.graphql b/packages/app/src/cli/api/graphql/bulk-operations/mutations/staged-uploads-create.graphql new file mode 100644 index 00000000000..7e2c51d7e6b --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/mutations/staged-uploads-create.graphql @@ -0,0 +1,17 @@ +mutation StagedUploadsCreate($input: [StagedUploadInput!]!) { + stagedUploadsCreate(input: $input) { + stagedTargets { + url + resourceUrl + parameters { + name + value + } + } + userErrors { + field + message + } + } +} + diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index 5a0339234cd..af5f0d3a1b7 100644 --- a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any */ +import {JsonMapType} from '@shopify/cli-kit/node/toml' + export type Maybe = T | null export type InputMaybe = Maybe export type Exact = {[K in keyof T]: T[K]} @@ -40,6 +42,8 @@ export type Scalars = { ISO8601Date: {input: any; output: any} /** An ISO 8601-encoded datetime */ ISO8601DateTime: {input: any; output: any} + /** Represents untyped JSON */ + JSON: {input: JsonMapType | string; output: JsonMapType} /** The ID for a LegalEntity. */ LegalEntityID: {input: any; output: any} /** The ID for a OrganizationDomain. */ diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index ac8884410ce..6ea51953740 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -38,6 +38,7 @@ export default class Execute extends AppLinkedCommand { app: appContextResult.app, storeFqdn: store.shopDomain, query: flags.query, + variables: flags.variables, }) return {app: appContextResult.app} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index 6da20e180fb..176648efb2e 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -38,13 +38,20 @@ export const appFlags = { export const bulkOperationFlags = { query: Flags.string({ char: 'q', - description: 'The GraphQL query, as a string.', + description: 'The GraphQL query or mutation to run as a bulk operation.', env: 'SHOPIFY_FLAG_QUERY', required: true, }), + variables: Flags.string({ + char: 'v', + description: + 'The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.', + env: 'SHOPIFY_FLAG_VARIABLES', + multiple: true, + }), store: Flags.string({ char: 's', - description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.', + description: 'The store domain. Must be an existing dev store.', env: 'SHOPIFY_FLAG_STORE', parse: async (input) => normalizeStoreFqdn(input), }), diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts index 52dbdfa8bce..c727734aa08 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts @@ -1,11 +1,15 @@ import {executeBulkOperation} from './execute-bulk-operation.js' import {runBulkOperationQuery} from './run-query.js' +import {runBulkOperationMutation} from './run-mutation.js' import {AppLinkedInterface} from '../../models/app/app.js' -import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui' -import {describe, test, expect, vi} from 'vitest' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {describe, test, expect, vi, beforeEach} from 'vitest' vi.mock('./run-query.js') +vi.mock('./run-mutation.js') vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/session') describe('executeBulkOperation', () => { const mockApp = { @@ -13,7 +17,7 @@ describe('executeBulkOperation', () => { } as AppLinkedInterface const storeFqdn = 'test-store.myshopify.com' - const query = 'query { products { edges { node { id } } } }' + const mockAdminSession = {token: 'test-token', storeFqdn} const successfulBulkOperation = { id: 'gid://shopify/BulkOperation/123', @@ -25,7 +29,12 @@ describe('executeBulkOperation', () => { url: null, } - test('executeBulkOperation successfully runs', async () => { + beforeEach(() => { + vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockAdminSession) + }) + + test('runs query operation when GraphQL document starts with query', async () => { + const query = 'query { products { edges { node { id } } } }' const mockResponse = { bulkOperation: successfulBulkOperation, userErrors: [], @@ -39,21 +48,91 @@ describe('executeBulkOperation', () => { }) expect(runBulkOperationQuery).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query, + variables: undefined, + }) + expect(runBulkOperationMutation).not.toHaveBeenCalled() + }) + + test('runs query operation when GraphQL document starts with curly brace', async () => { + const query = '{ products { edges { node { id } } } }' + const mockResponse = { + bulkOperation: successfulBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any) + + await executeBulkOperation({ + app: mockApp, storeFqdn, query, }) - expect(renderInfo).toHaveBeenCalledWith({ - headline: 'Starting bulk operation.', - body: `App: ${mockApp.name}\nStore: ${storeFqdn}`, + expect(runBulkOperationQuery).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query, + variables: undefined, + }) + expect(runBulkOperationMutation).not.toHaveBeenCalled() + }) + + test('runs mutation operation when GraphQL document starts with mutation', async () => { + const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' + const mockResponse = { + bulkOperation: successfulBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query: mutation, + }) + + expect(runBulkOperationMutation).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query: mutation, + variables: undefined, + }) + expect(runBulkOperationQuery).not.toHaveBeenCalled() + }) + + test('passes variables to mutation when provided with `--variables` flag', async () => { + const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' + const variables = ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}'] + const mockResponse = { + bulkOperation: successfulBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query: mutation, + variables, + }) + + expect(runBulkOperationMutation).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query: mutation, + variables, }) + }) - expect(renderInfo).toHaveBeenCalledWith({ - customSections: expect.arrayContaining([ - expect.objectContaining({ - title: 'Bulk Operation Created', - }), - ]), + test('renders success message when bulk operation is created', async () => { + const query = '{ products { edges { node { id } } } }' + const mockResponse = { + bulkOperation: successfulBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any) + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query, }) expect(renderSuccess).toHaveBeenCalledWith({ @@ -62,7 +141,8 @@ describe('executeBulkOperation', () => { }) }) - test('executeBulkOperation renders warning when user errors are present', async () => { + test('renders warning when user errors are present', async () => { + const query = '{ products { edges { node { id } } } }' const mockResponse = { bulkOperation: null, userErrors: [ diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts index 9d722de96d8..f0cca19ea83 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts @@ -1,26 +1,31 @@ import {runBulkOperationQuery} from './run-query.js' +import {runBulkOperationMutation} from './run-mutation.js' import {AppLinkedInterface} from '../../models/app/app.js' import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {parse} from 'graphql' interface ExecuteBulkOperationInput { app: AppLinkedInterface storeFqdn: string query: string + variables?: string[] } export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { - const {app, storeFqdn, query} = input + const {app, storeFqdn, query, variables} = input renderInfo({ headline: 'Starting bulk operation.', body: `App: ${app.name}\nStore: ${storeFqdn}`, }) - const bulkOperationResponse = await runBulkOperationQuery({ - storeFqdn, - query, - }) + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + + const bulkOperationResponse = isMutation(query) + ? await runBulkOperationMutation({adminSession, query, variables}) + : await runBulkOperationQuery({adminSession, query}) if (bulkOperationResponse?.userErrors?.length) { const errorMessages = bulkOperationResponse.userErrors @@ -63,3 +68,10 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr }) } } + +function isMutation(graphqlOperation: string): boolean { + const document = parse(graphqlOperation) + const firstOperation = document.definitions.find((def) => def.kind === 'OperationDefinition') + + return firstOperation?.kind === 'OperationDefinition' && firstOperation.operation === 'mutation' +} diff --git a/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts b/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts new file mode 100644 index 00000000000..dae9c33b18d --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts @@ -0,0 +1,43 @@ +import {runBulkOperationMutation} from './run-mutation.js' +import {stageFile} from './stage-file.js' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('./stage-file.js') + +describe('runBulkOperationMutation', () => { + const mockSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + const successfulBulkOperation = { + id: 'gid://shopify/BulkOperation/456', + status: 'CREATED', + errorCode: null, + createdAt: '2024-01-01T00:00:00Z', + objectCount: '0', + fileSize: '0', + url: null, + } + const mockSuccessResponse = { + bulkOperationRunMutation: { + bulkOperation: successfulBulkOperation, + userErrors: [], + }, + } + + beforeEach(() => { + vi.mocked(stageFile).mockResolvedValue('staged-uploads/bulk-mutation.jsonl') + }) + + test('returns a bulk operation when request succeeds', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + + const bulkOperationResult = await runBulkOperationMutation({ + adminSession: mockSession, + query: 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }', + variables: ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}'], + }) + + expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation) + expect(bulkOperationResult?.userErrors).toEqual([]) + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/run-mutation.ts b/packages/app/src/cli/services/bulk-operations/run-mutation.ts new file mode 100644 index 00000000000..cb86680df9c --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/run-mutation.ts @@ -0,0 +1,36 @@ +import {stageFile} from './stage-file.js' +import { + BulkOperationRunMutation as BulkOperationRunMutationDoc, + BulkOperationRunMutationMutation, + BulkOperationRunMutationMutationVariables, +} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {AdminSession} from '@shopify/cli-kit/node/session' + +interface BulkOperationRunMutationOptions { + adminSession: AdminSession + query: string + variables?: string[] +} + +export async function runBulkOperationMutation( + options: BulkOperationRunMutationOptions, +): Promise { + const {adminSession, query: mutation, variables} = options + + const stagedUploadPath = await stageFile({ + adminSession, + jsonVariables: variables, + }) + + const response = await adminRequestDoc({ + query: BulkOperationRunMutationDoc, + session: adminSession, + variables: { + mutation, + stagedUploadPath, + }, + }) + + return response.bulkOperationRunMutation +} diff --git a/packages/app/src/cli/services/bulk-operations/run-query.test.ts b/packages/app/src/cli/services/bulk-operations/run-query.test.ts index cabf0ba0064..150eec78f0d 100644 --- a/packages/app/src/cli/services/bulk-operations/run-query.test.ts +++ b/packages/app/src/cli/services/bulk-operations/run-query.test.ts @@ -1,10 +1,8 @@ import {runBulkOperationQuery} from './run-query.js' import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' -import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {describe, test, expect, vi} from 'vitest' vi.mock('@shopify/cli-kit/node/api/admin') -vi.mock('@shopify/cli-kit/node/session') describe('runBulkOperationQuery', () => { const mockSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} @@ -24,15 +22,11 @@ describe('runBulkOperationQuery', () => { }, } - beforeEach(() => { - vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockSession) - }) - test('returns a bulk operation when request succeeds', async () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) const bulkOperationResult = await runBulkOperationQuery({ - storeFqdn: 'test-store.myshopify.com', + adminSession: mockSession, query: 'query { products { edges { node { id } } } }', }) diff --git a/packages/app/src/cli/services/bulk-operations/run-query.ts b/packages/app/src/cli/services/bulk-operations/run-query.ts index f0281402d5a..e093b7ccf31 100644 --- a/packages/app/src/cli/services/bulk-operations/run-query.ts +++ b/packages/app/src/cli/services/bulk-operations/run-query.ts @@ -3,18 +3,17 @@ import { BulkOperationRunQueryMutation, } from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js' import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' -import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {AdminSession} from '@shopify/cli-kit/node/session' interface BulkOperationRunQueryOptions { - storeFqdn: string + adminSession: AdminSession query: string } export async function runBulkOperationQuery( options: BulkOperationRunQueryOptions, ): Promise { - const {storeFqdn, query} = options - const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + const {adminSession, query} = options const response = await adminRequestDoc({ query: BulkOperationRunQuery, session: adminSession, diff --git a/packages/app/src/cli/services/bulk-operations/stage-file.test.ts b/packages/app/src/cli/services/bulk-operations/stage-file.test.ts new file mode 100644 index 00000000000..3927c83b946 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/stage-file.test.ts @@ -0,0 +1,114 @@ +import {stageFile} from './stage-file.js' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {readFile, fileSize} from '@shopify/cli-kit/node/fs' +import {fetch, formData} from '@shopify/cli-kit/node/http' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/fs') +vi.mock('@shopify/cli-kit/node/http') + +describe('stageFile', () => { + const mockSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + const mockFileContents = '{"id":"gid://shopify/Product/123","title":"Test"}' + const mockFileSize = 52 + const mockUploadUrl = 'https://storage.googleapis.com/test-bucket/test-file' + const mockResourceUrl = 'tmp/staged-uploads/test-resource.jsonl' + + const mockSuccessResponse = { + stagedUploadsCreate: { + stagedTargets: [ + { + url: mockUploadUrl, + resourceUrl: mockResourceUrl, + parameters: [ + {name: 'key', value: 'test-key'}, + {name: 'policy', value: 'test-policy'}, + ], + }, + ], + userErrors: [], + }, + } + + beforeEach(() => { + vi.mocked(readFile).mockResolvedValue(Buffer.from(mockFileContents)) + vi.mocked(fileSize).mockResolvedValue(mockFileSize) + vi.mocked(formData).mockReturnValue({ + append: vi.fn(), + } as any) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(''), + } as any) + }) + + test('returns staged upload key when file is successfully staged with empty variables', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + + const result = await stageFile({ + adminSession: mockSession, + jsonVariables: [], + }) + + expect(result).toBe('test-key') + }) + + test('converts JSON strings array to JSONL format when uploading file', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + const mockAppend = vi.fn() + vi.mocked(formData).mockReturnValue({append: mockAppend} as any) + + const jsonVariables = ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}'] + + await stageFile({ + adminSession: mockSession, + jsonVariables, + }) + + // Find the form.append('file', buffer, options) call among all append calls + const fileAppendCall = mockAppend.mock.calls.find((call) => { + const fieldName = call[0] + return fieldName === 'file' + }) + // Extract the buffer (second argument) that was uploaded + const uploadedBuffer = fileAppendCall?.[1] + const uploadedContent = uploadedBuffer?.toString('utf-8') + + expect(uploadedContent).toBe('{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}\n') + }) + + test('handles multiple JSON variable strings correctly', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + const mockAppend = vi.fn() + vi.mocked(formData).mockReturnValue({append: mockAppend} as any) + + const jsonVariables = [ + '{"input":{"id":"gid://shopify/Product/1","title":"New Shirt"}}', + '{"input":{"id":"gid://shopify/Product/2","title":"Cool Pants"}}', + '{"input":{"id":"gid://shopify/Product/3","title":"Nice Hat"}}', + ] + + await stageFile({ + adminSession: mockSession, + jsonVariables, + }) + + const fileAppendCall = mockAppend.mock.calls.find((call) => { + const fieldName = call[0] + return fieldName === 'file' + }) + const uploadedBuffer = fileAppendCall?.[1] + const uploadedContent = uploadedBuffer?.toString('utf-8') + + const expectedContent = [ + '{"input":{"id":"gid://shopify/Product/1","title":"New Shirt"}}', + '{"input":{"id":"gid://shopify/Product/2","title":"Cool Pants"}}', + '{"input":{"id":"gid://shopify/Product/3","title":"Nice Hat"}}', + '', + ].join('\n') + + expect(uploadedContent).toBe(expectedContent) + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/stage-file.ts b/packages/app/src/cli/services/bulk-operations/stage-file.ts new file mode 100644 index 00000000000..a7d65f65f4a --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/stage-file.ts @@ -0,0 +1,123 @@ +import { + StagedUploadsCreate, + StagedUploadsCreateMutation, + StagedUploadsCreateMutationVariables, +} from '../../api/graphql/bulk-operations/generated/staged-uploads-create.js' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {formData, fetch} from '@shopify/cli-kit/node/http' +import {AbortError} from '@shopify/cli-kit/node/error' + +interface StageFileOptions { + adminSession: AdminSession + jsonVariables?: string[] +} + +export async function stageFile(options: StageFileOptions): Promise { + const {adminSession, jsonVariables = []} = options + + const buffer = convertJsonToJsonlBuffer(jsonVariables) + const filename = 'bulk-variables.jsonl' + const size = buffer.length + + const response = await requestStagedUpload(adminSession, filename, size) + const target = validateStagedUploadResponse(response) + + await uploadFileToStagedUrl(buffer, target.url, target.parameters, filename) + + return target.stagedUploadKey +} + +function convertJsonToJsonlBuffer(jsonVariables: string[]): Buffer { + const jsonlContent = `${jsonVariables.join('\n')}\n` + return Buffer.from(jsonlContent, 'utf-8') +} + +async function requestStagedUpload( + adminSession: AdminSession, + filename: string, + size: number, +): Promise { + return adminRequestDoc({ + query: StagedUploadsCreate, + session: adminSession, + variables: { + input: [ + { + filename, + fileSize: size.toString(), + httpMethod: 'POST', + mimeType: 'text/jsonl', + resource: 'BULK_MUTATION_VARIABLES', + }, + ], + }, + }) +} + +function validateStagedUploadResponse(response: StagedUploadsCreateMutation): { + url: string + resourceUrl: string + parameters: {name: string; value: string}[] + stagedUploadKey: string +} { + if (!response.stagedUploadsCreate) { + throw new AbortError('No response received from stagedUploadsCreate mutation') + } + + if (response.stagedUploadsCreate.userErrors.length > 0) { + const errors = response.stagedUploadsCreate.userErrors + .map((error: {field?: string[] | null; message: string}) => error.message) + .join(', ') + throw new AbortError(`Failed to create staged upload: ${errors}`) + } + + const target = response.stagedUploadsCreate.stagedTargets?.[0] + if (!target) { + throw new AbortError('No staged upload target returned from Shopify') + } + + if (!target.url || !target.resourceUrl) { + throw new AbortError('Invalid staged upload target: missing required URLs') + } + + const stagedUploadKey = target.parameters.find((param) => param.name === 'key')?.value + if (!stagedUploadKey) { + throw new AbortError('No key parameter found in staged upload target') + } + + return { + url: target.url, + resourceUrl: target.resourceUrl, + parameters: target.parameters, + stagedUploadKey, + } +} + +async function uploadFileToStagedUrl( + fileContents: Buffer, + uploadUrl: string, + parameters: {name: string; value: string}[], + filename: string, +): Promise { + const form = formData() + + for (const param of parameters) { + form.append(param.name, param.value) + } + + form.append('file', fileContents, { + filename, + contentType: 'text/jsonl', + }) + + const uploadResponse = await fetch(uploadUrl, { + method: 'POST', + body: form, + }) + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text() + throw new AbortError(`Failed to upload file to staged URL: ${uploadResponse.statusText}\n${errorText}`) + } +} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index febd61f6e87..d8d00d63b12 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -857,7 +857,7 @@ }, "query": { "char": "q", - "description": "The GraphQL query, as a string.", + "description": "The GraphQL query or mutation to run as a bulk operation.", "env": "SHOPIFY_FLAG_QUERY", "hasDynamicHelp": false, "multiple": false, @@ -878,13 +878,22 @@ }, "store": { "char": "s", - "description": "Store URL. Must be an existing development or Shopify Plus sandbox store.", + "description": "The store domain. Must be an existing dev store.", "env": "SHOPIFY_FLAG_STORE", "hasDynamicHelp": false, "multiple": false, "name": "store", "type": "option" }, + "variables": { + "char": "v", + "description": "The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.", + "env": "SHOPIFY_FLAG_VARIABLES", + "hasDynamicHelp": false, + "multiple": true, + "name": "variables", + "type": "option" + }, "verbose": { "allowNo": false, "description": "Increase the verbosity of the output.",