diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts index 236889a0bfe..480d02c9f17 100644 --- a/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts @@ -49,11 +49,6 @@ export const BulkOperationRunQuery = { name: {kind: 'Name', value: 'query'}, value: {kind: 'Variable', name: {kind: 'Name', value: 'query'}}, }, - { - kind: 'Argument', - name: {kind: 'Name', value: 'groupObjects'}, - value: {kind: 'BooleanValue', value: false}, - }, ], selectionSet: { kind: 'SelectionSet', diff --git a/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql b/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql index 61650c51c57..9922c8acc89 100644 --- a/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql +++ b/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql @@ -1,8 +1,6 @@ mutation BulkOperationRunQuery($query: String!) { bulkOperationRunQuery( query: $query - # Set to false to optimize for speed over grouped results - groupObjects: false ) { bulkOperation { completedAt diff --git a/packages/app/src/cli/commands/app/bulk/execute.ts b/packages/app/src/cli/commands/app/bulk/execute.ts index 4d5b6fd3a6f..c1bd9b1a6a1 100644 --- a/packages/app/src/cli/commands/app/bulk/execute.ts +++ b/packages/app/src/cli/commands/app/bulk/execute.ts @@ -52,6 +52,7 @@ export default class BulkExecute extends AppLinkedCommand { variableFile: flags['variable-file'], watch: flags.watch, outputFile: flags['output-file'], + ...(flags.version && {version: flags.version}), }) return {app: appContextResult.app} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index 1cdcba90204..273a0320e9f 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -72,4 +72,8 @@ export const bulkOperationFlags = { description: 'The file path where results should be written. If not specified, results will be written to STDOUT.', env: 'SHOPIFY_FLAG_OUTPUT_FILE', }), + version: Flags.string({ + description: 'The API version to use for the bulk operation. If not specified, uses the latest stable version.', + env: 'SHOPIFY_FLAG_VERSION', + }), } 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 9d84c405e43..380b14b30cf 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 @@ -26,6 +26,13 @@ vi.mock('@shopify/cli-kit/node/session', async () => { ensureAuthenticatedAdminAsApp: vi.fn(), } }) +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + supportedApiVersions: vi.fn(() => Promise.resolve(['2025-01', '2025-04', '2025-07', '2025-10'])), + } +}) describe('executeBulkOperation', () => { const mockRemoteApp = { @@ -512,4 +519,63 @@ describe('executeBulkOperation', () => { expect(renderSuccess).not.toHaveBeenCalled() }) + + test('allows executing bulk operations against unstable', async () => { + const query = '{ products { edges { node { id } } } }' + const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) + + await executeBulkOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + version: 'unstable', + }) + + expect(runBulkOperationQuery).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query, + version: 'unstable', + }) + }) + + test('allows executing bulk operations against a specific stable version', async () => { + const query = '{ products { edges { node { id } } } }' + const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) + + await executeBulkOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + version: '2025-01', + }) + + expect(runBulkOperationQuery).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query, + version: '2025-01', + }) + }) + + test('throws error when an API version is specified but is not supported', async () => { + const query = '{ products { edges { node { id } } } }' + + await expect( + executeBulkOperation({ + remoteApp: mockRemoteApp, + storeFqdn, + query, + version: '2099-12', + }), + ).rejects.toThrow('Invalid API version') + + expect(runBulkOperationQuery).not.toHaveBeenCalled() + }) }) 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 ed51bcb4f07..0d65f17b1d7 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 @@ -7,6 +7,7 @@ import {OrganizationApp} from '../../models/organization.js' import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' +import {supportedApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {AbortController} from '@shopify/cli-kit/node/abort' import {parse} from 'graphql' @@ -20,6 +21,7 @@ interface ExecuteBulkOperationInput { variableFile?: string watch?: boolean outputFile?: string + version?: string } async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise { @@ -40,25 +42,37 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string } export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { - const {remoteApp, storeFqdn, query, variables, variableFile, outputFile, watch = false} = input - - renderInfo({ - headline: 'Starting bulk operation.', - body: `App: ${remoteApp.title}\nStore: ${storeFqdn}`, - }) + const {remoteApp, storeFqdn, query, variables, variableFile, outputFile, watch = false, version} = input const appSecret = remoteApp.apiSecretKeys[0]?.secret if (!appSecret) throw new BugError('No API secret keys found for app') const adminSession = await ensureAuthenticatedAdminAsApp(storeFqdn, remoteApp.apiKey, appSecret) + if (version) await validateApiVersion(adminSession, version) + const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) validateGraphQLDocument(query, variablesJsonl) + renderInfo({ + headline: 'Starting bulk operation.', + body: [ + { + list: { + items: [ + `App: ${remoteApp.title}`, + `Store: ${storeFqdn}`, + `API version: ${version || 'default (latest stable)'}`, + ], + }, + }, + ], + }) + const bulkOperationResponse = isMutation(query) - ? await runBulkOperationMutation({adminSession, query, variablesJsonl}) - : await runBulkOperationQuery({adminSession, query}) + ? await runBulkOperationMutation({adminSession, query, variablesJsonl, version}) + : await runBulkOperationQuery({adminSession, query, version}) if (bulkOperationResponse?.userErrors?.length) { const errorMessages = bulkOperationResponse.userErrors @@ -172,3 +186,15 @@ function isMutation(graphqlOperation: string): boolean { const operation = document.definitions.find((def) => def.kind === 'OperationDefinition') return operation?.kind === 'OperationDefinition' && operation.operation === 'mutation' } + +async function validateApiVersion(adminSession: {token: string; storeFqdn: string}, version: string): Promise { + if (version === 'unstable') return + + const supportedVersions = await supportedApiVersions(adminSession) + if (supportedVersions.includes(version)) return + + const firstLine = outputContent`Invalid API version: ${version}`.value + const secondLine = outputContent`Supported versions: ${supportedVersions.join(', ')}`.value + + throw new AbortError(`${firstLine}\n${secondLine}`) +} 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 index 17fde6aa919..20f320d286c 100644 --- a/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts @@ -40,4 +40,17 @@ describe('runBulkOperationMutation', () => { expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation) expect(bulkOperationResult?.userErrors).toEqual([]) }) + + test('starts bulk mutation with specific API version when provided', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + + await runBulkOperationMutation({ + adminSession: mockSession, + query: 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }', + variablesJsonl: '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}', + version: '2025-01', + }) + + expect(adminRequestDoc).toHaveBeenCalledWith(expect.objectContaining({version: '2025-01'})) + }) }) diff --git a/packages/app/src/cli/services/bulk-operations/run-mutation.ts b/packages/app/src/cli/services/bulk-operations/run-mutation.ts index fff5727ae24..d02cd2302f9 100644 --- a/packages/app/src/cli/services/bulk-operations/run-mutation.ts +++ b/packages/app/src/cli/services/bulk-operations/run-mutation.ts @@ -11,12 +11,13 @@ interface BulkOperationRunMutationOptions { adminSession: AdminSession query: string variablesJsonl?: string + version?: string } export async function runBulkOperationMutation( options: BulkOperationRunMutationOptions, ): Promise { - const {adminSession, query: mutation, variablesJsonl} = options + const {adminSession, query: mutation, variablesJsonl, version} = options const stagedUploadPath = await stageFile({ adminSession, @@ -30,6 +31,7 @@ export async function runBulkOperationMutation( mutation, stagedUploadPath, }, + ...(version && {version}), }) 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 150eec78f0d..cf40a9e40a5 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 @@ -33,4 +33,16 @@ describe('runBulkOperationQuery', () => { expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation) expect(bulkOperationResult?.userErrors).toEqual([]) }) + + test('starts bulk query with specific API version when provided', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + + await runBulkOperationQuery({ + adminSession: mockSession, + query: 'query { products { edges { node { id } } } }', + version: '2025-01', + }) + + expect(adminRequestDoc).toHaveBeenCalledWith(expect.objectContaining({version: '2025-01'})) + }) }) 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 716944dd737..404361e11db 100644 --- a/packages/app/src/cli/services/bulk-operations/run-query.ts +++ b/packages/app/src/cli/services/bulk-operations/run-query.ts @@ -8,17 +8,19 @@ import {AdminSession} from '@shopify/cli-kit/node/session' interface BulkOperationRunQueryOptions { adminSession: AdminSession query: string + version?: string } export async function runBulkOperationQuery( options: BulkOperationRunQueryOptions, ): Promise { - const {adminSession, query} = options + const {adminSession, query, version} = options const response = await adminRequestDoc({ query: BulkOperationRunQuery, session: adminSession, variables: {query}, + ...(version && {version}), }) return response.bulkOperationRunQuery diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 88360138939..9c7f19ca148 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -202,6 +202,14 @@ "name": "verbose", "type": "boolean" }, + "version": { + "description": "The API version to use for the bulk operation. If not specified, uses the latest stable version.", + "env": "SHOPIFY_FLAG_VERSION", + "hasDynamicHelp": false, + "multiple": false, + "name": "version", + "type": "option" + }, "watch": { "allowNo": false, "description": "Wait for bulk operation results before exiting.",