From 28223f4344eb034694575e19434d8f657243c6c4 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Fri, 7 Nov 2025 13:34:01 -0800 Subject: [PATCH 1/3] minimal foundations of the BulkOps CLI that future PRs will build upon --- .../cli/api/graphql/admin-bulk-operations.ts | 44 ++++++++++ packages/app/src/cli/commands/app/execute.ts | 83 ++++++++++++++++--- packages/app/src/cli/flags.ts | 16 ++++ .../cli/services/bulk-operation-run-query.ts | 40 +++++++++ .../bulk-operations-run-query.test.ts | 42 ++++++++++ packages/cli/oclif.manifest.json | 23 ++++- 6 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/cli/api/graphql/admin-bulk-operations.ts create mode 100644 packages/app/src/cli/services/bulk-operation-run-query.ts create mode 100644 packages/app/src/cli/services/bulk-operations-run-query.test.ts diff --git a/packages/app/src/cli/api/graphql/admin-bulk-operations.ts b/packages/app/src/cli/api/graphql/admin-bulk-operations.ts new file mode 100644 index 00000000000..f4c0706a8f9 --- /dev/null +++ b/packages/app/src/cli/api/graphql/admin-bulk-operations.ts @@ -0,0 +1,44 @@ +import {gql} from 'graphql-request' + +// eslint-disable-next-line @shopify/cli/no-inline-graphql +export const BulkOperationRunQuery = gql` + mutation BulkOperationRunQuery($query: String!) { + bulkOperationRunQuery(query: $query) { + bulkOperation { + id + status + errorCode + createdAt + objectCount + fileSize + url + } + userErrors { + field + message + } + } + } +` + +export interface BulkOperation { + id: string + status: string + errorCode: string | null + createdAt: string + objectCount: string + fileSize: string + url: string | null +} + +export interface BulkOperationError { + field: string[] | null + message: string +} + +export interface BulkOperationRunQuerySchema { + bulkOperationRunQuery: { + bulkOperation: BulkOperation | null + userErrors: BulkOperationError[] + } +} diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index a3dba4e5186..5bd5b469b12 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -1,29 +1,86 @@ -import {appFlags} from '../../flags.js' -import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../utilities/app-unlinked-command.js' -import {AppInterface} from '../../models/app/app.js' +import {appFlags, bulkOperationFlags} from '../../flags.js' +import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' +import {linkedAppContext} from '../../services/app-context.js' +import {storeContext} from '../../services/store-context.js' +import {runBulkOperationQuery} from '../../services/bulk-operation-run-query.js' import {globalFlags} from '@shopify/cli-kit/node/cli' -import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' -export default class Execute extends AppUnlinkedCommand { - static summary = 'Execute app operations.' +export default class Execute extends AppLinkedCommand { + static summary = 'Execute bulk operations.' - static description = 'Execute app operations.' + static description = 'Execute bulk operations against the Shopify Admin API.' static hidden = true static flags = { ...globalFlags, ...appFlags, + ...bulkOperationFlags, } - async run(): Promise { - await this.parse(Execute) + async run(): Promise { + const {flags} = await this.parse(Execute) - renderSuccess({ - headline: 'Execute command ran successfully!', - body: 'Placeholder command. Add execution logic here.', + const appContextResult = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, }) - return {app: undefined as unknown as AppInterface} + const store = await storeContext({ + appContextResult, + storeFqdn: flags.store, + forceReselectStore: flags.reset, + }) + + renderInfo({ + headline: 'Starting bulk operation.', + body: `App: ${appContextResult.app.name}\nStore: ${store.shopDomain}`, + }) + + const {result, errors} = await runBulkOperationQuery({ + storeFqdn: store.shopDomain, + query: flags.query, + }) + + if (errors?.length) { + const errorMessages = errors.map((error) => `${error.field?.join('.') ?? 'unknown'}: ${error.message}`).join('\n') + renderWarning({ + headline: 'Bulk operation errors.', + body: errorMessages, + }) + return {app: appContextResult.app} + } + + if (result) { + const infoSections = [ + { + title: 'Bulk Operation Created', + body: [ + { + list: { + items: [ + outputContent`ID: ${outputToken.cyan(result.id)}`.value, + outputContent`Status: ${outputToken.yellow(result.status)}`.value, + outputContent`Created: ${outputToken.gray(result.createdAt)}`.value, + ], + }, + }, + ], + }, + ] + + renderInfo({customSections: infoSections}) + + renderSuccess({ + headline: 'Bulk operation started successfully!', + body: 'Congrats!', + }) + } + + return {app: appContextResult.app} } } diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index bf6944a81ea..6da20e180fb 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -1,5 +1,6 @@ import {Flags} from '@oclif/core' import {resolvePath, cwd} from '@shopify/cli-kit/node/path' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' /** * An object that contains the flags that @@ -33,3 +34,18 @@ export const appFlags = { exclusive: ['config'], }), } + +export const bulkOperationFlags = { + query: Flags.string({ + char: 'q', + description: 'The GraphQL query, as a string.', + env: 'SHOPIFY_FLAG_QUERY', + required: true, + }), + store: Flags.string({ + char: 's', + description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + }), +} diff --git a/packages/app/src/cli/services/bulk-operation-run-query.ts b/packages/app/src/cli/services/bulk-operation-run-query.ts new file mode 100644 index 00000000000..8002b832f54 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operation-run-query.ts @@ -0,0 +1,40 @@ +import { + BulkOperationRunQuery, + BulkOperation, + BulkOperationError, + BulkOperationRunQuerySchema, +} from '../api/graphql/admin-bulk-operations.js' +import {adminRequest} from '@shopify/cli-kit/node/api/admin' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' + +interface BulkOperationRunQueryOptions { + storeFqdn: string + query: string +} + +/** + * Executes a bulk operation query against the Shopify Admin API. + * The operation runs asynchronously in the background. + */ +export async function runBulkOperationQuery( + options: BulkOperationRunQueryOptions, +): Promise<{result?: BulkOperation; errors?: BulkOperationError[]}> { + const {storeFqdn, query} = options + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + const response = await adminRequest(BulkOperationRunQuery, adminSession, {query}) + + if (response.bulkOperationRunQuery.userErrors.length > 0) { + return { + errors: response.bulkOperationRunQuery.userErrors, + } + } + + const bulkOperation = response.bulkOperationRunQuery.bulkOperation + if (bulkOperation) { + return {result: bulkOperation} + } + + return { + errors: [{field: null, message: 'No bulk operation was created'}], + } +} 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 new file mode 100644 index 00000000000..4d2bf4b7c8a --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations-run-query.test.ts @@ -0,0 +1,42 @@ +import {runBulkOperationQuery} from './bulk-operation-run-query.js' +import {adminRequest} from '@shopify/cli-kit/node/api/admin' +import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' +import {describe, test, expect, vi, beforeEach} 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'} + const successfulBulkOperation = { + id: 'gid://shopify/BulkOperation/123', + status: 'CREATED', + errorCode: null, + createdAt: '2024-01-01T00:00:00Z', + objectCount: '0', + fileSize: '0', + url: null, + } + const mockSuccessResponse = { + bulkOperationRunQuery: { + bulkOperation: successfulBulkOperation, + userErrors: [], + }, + } + + beforeEach(() => { + vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockSession) + }) + + test('returns a bulk operation when request succeeds', async () => { + vi.mocked(adminRequest).mockResolvedValue(mockSuccessResponse) + + const bulkOperationResult = await runBulkOperationQuery({ + storeFqdn: 'test-store.myshopify.com', + query: 'query { products { edges { node { id } } } }', + }) + + expect(bulkOperationResult.result).toEqual(successfulBulkOperation) + expect(bulkOperationResult.errors).toBeUndefined() + }) +}) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9ae31dcf8fe..febd61f6e87 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -814,7 +814,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Execute app operations.", + "description": "Execute bulk operations against the Shopify Admin API.", "flags": { "client-id": { "description": "The Client ID of your app.", @@ -855,6 +855,16 @@ "noCacheDefault": true, "type": "option" }, + "query": { + "char": "q", + "description": "The GraphQL query, as a string.", + "env": "SHOPIFY_FLAG_QUERY", + "hasDynamicHelp": false, + "multiple": false, + "name": "query", + "required": true, + "type": "option" + }, "reset": { "allowNo": false, "description": "Reset all your settings.", @@ -866,6 +876,15 @@ "name": "reset", "type": "boolean" }, + "store": { + "char": "s", + "description": "Store URL. Must be an existing development or Shopify Plus sandbox store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, "verbose": { "allowNo": false, "description": "Increase the verbosity of the output.", @@ -884,7 +903,7 @@ "pluginName": "@shopify/cli", "pluginType": "core", "strict": true, - "summary": "Execute app operations." + "summary": "Execute bulk operations." }, "app:function:build": { "aliases": [ From 05a5da9d29fb09b6a14b234fd1dfbea5f5e1a55b Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Mon, 10 Nov 2025 18:16:27 -0800 Subject: [PATCH 2/3] refactor with generated GraphQL types --- graphql.config.ts | 1 + packages/app/project.json | 27 ++- .../cli/api/graphql/admin-bulk-operations.ts | 44 ----- .../generated/bulk-operation-run-query.ts | 92 ++++++++++ .../bulk-operations/generated/types.d.ts | 163 ++++++++++++++++++ .../bulk-operation-run-query.graphql | 18 ++ packages/app/src/cli/commands/app/execute.ts | 11 +- .../cli/services/bulk-operation-run-query.ts | 31 ++-- .../bulk-operations-run-query.test.ts | 8 +- 9 files changed, 316 insertions(+), 79 deletions(-) delete mode 100644 packages/app/src/cli/api/graphql/admin-bulk-operations.ts create mode 100644 packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts create mode 100644 packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts create mode 100644 packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql diff --git a/graphql.config.ts b/graphql.config.ts index 86ad826236e..dc34cb06360 100644 --- a/graphql.config.ts +++ b/graphql.config.ts @@ -81,6 +81,7 @@ export default { appDev: projectFactory('app-dev', 'app_dev_schema.graphql'), appManagement: projectFactory('app-management', 'app_management_schema.graphql'), admin: projectFactory('admin', 'admin_schema.graphql', 'cli-kit'), + bulkOperations: projectFactory('bulk-operations', 'admin_schema.graphql'), webhooks: projectFactory('webhooks', 'webhooks_schema.graphql'), functions: projectFactory('functions', 'functions_cli_schema.graphql', 'app'), }, diff --git a/packages/app/project.json b/packages/app/project.json index a142717c91d..fe5bbddfc17 100644 --- a/packages/app/project.json +++ b/packages/app/project.json @@ -58,7 +58,8 @@ "{projectRoot}/src/cli/api/graphql/app-dev/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts", - "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts" + "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts" ], "options": { "commands": [ @@ -68,7 +69,8 @@ "pnpm eslint 'src/cli/api/graphql/app-dev/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/app-management/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/webhooks/generated/**/*.{ts,tsx}' --fix", - "pnpm eslint 'src/cli/api/graphql/functions/generated/**/*.{ts,tsx}' --fix" + "pnpm eslint 'src/cli/api/graphql/functions/generated/**/*.{ts,tsx}' --fix", + "pnpm eslint 'src/cli/api/graphql/bulk-operations/generated/**/*.{ts,tsx}' --fix" ], "cwd": "packages/app" } @@ -150,6 +152,17 @@ "cwd": "{workspaceRoot}" } }, + "graphql-codegen:generate:bulk-operations": { + "executor": "nx:run-commands", + "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/bulk-operations/**/*.graphql"], + "outputs": ["{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts"], + "options": { + "commands": [ + "pnpm exec graphql-codegen --project=bulkOperations" + ], + "cwd": "{workspaceRoot}" + } + }, "graphql-codegen:postfix": { "executor": "nx:run-commands", "dependsOn": [ @@ -159,7 +172,8 @@ "graphql-codegen:generate:app-dev", "graphql-codegen:generate:app-management", "graphql-codegen:generate:webhooks", - "graphql-codegen:generate:functions" + "graphql-codegen:generate:functions", + "graphql-codegen:generate:bulk-operations" ], "inputs": [{ "dependentTasksOutputFiles": "**/*.ts" }], "outputs": [ @@ -169,7 +183,8 @@ "{projectRoot}/src/cli/api/graphql/app-dev/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts", - "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts" + "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts" ], "options": { "commands": [ @@ -179,11 +194,11 @@ "find ./packages/app/src/cli/api/graphql/app-dev/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/app-management/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/webhooks/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", - "find ./packages/app/src/cli/api/graphql/functions/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" + "find ./packages/app/src/cli/api/graphql/functions/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", + "find ./packages/app/src/cli/api/graphql/bulk-operations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" ], "cwd": "{workspaceRoot}" } } } } - diff --git a/packages/app/src/cli/api/graphql/admin-bulk-operations.ts b/packages/app/src/cli/api/graphql/admin-bulk-operations.ts deleted file mode 100644 index f4c0706a8f9..00000000000 --- a/packages/app/src/cli/api/graphql/admin-bulk-operations.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {gql} from 'graphql-request' - -// eslint-disable-next-line @shopify/cli/no-inline-graphql -export const BulkOperationRunQuery = gql` - mutation BulkOperationRunQuery($query: String!) { - bulkOperationRunQuery(query: $query) { - bulkOperation { - id - status - errorCode - createdAt - objectCount - fileSize - url - } - userErrors { - field - message - } - } - } -` - -export interface BulkOperation { - id: string - status: string - errorCode: string | null - createdAt: string - objectCount: string - fileSize: string - url: string | null -} - -export interface BulkOperationError { - field: string[] | null - message: string -} - -export interface BulkOperationRunQuerySchema { - bulkOperationRunQuery: { - bulkOperation: BulkOperation | null - userErrors: BulkOperationError[] - } -} 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 new file mode 100644 index 00000000000..f1f2cc287c7 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts @@ -0,0 +1,92 @@ +/* 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 BulkOperationRunQueryMutationVariables = Types.Exact<{ + query: Types.Scalars['String']['input'] +}> + +export type BulkOperationRunQueryMutation = { + bulkOperationRunQuery?: { + bulkOperation?: { + id: string + status: Types.BulkOperationStatus + errorCode?: Types.BulkOperationErrorCode | null + createdAt: unknown + objectCount: unknown + fileSize?: unknown | null + url?: string | null + } | null + userErrors: {field?: string[] | null; message: string}[] + } | null +} + +export const BulkOperationRunQuery = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'BulkOperationRunQuery'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'query'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperationRunQuery'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'query'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'query'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperation'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'errorCode'}}, + {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'objectCount'}}, + {kind: 'Field', name: {kind: 'Name', value: 'fileSize'}}, + {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: '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 new file mode 100644 index 00000000000..c580cd035f0 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts @@ -0,0 +1,163 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax */ +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]} +export type MakeOptional = Omit & {[SubKey in K]?: Maybe} +export type MakeMaybe = Omit & {[SubKey in K]: Maybe} +export type MakeEmpty = {[_ in K]?: never} +export type Incremental = T | {[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never} +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: {input: string; output: string} + String: {input: string; output: string} + Boolean: {input: boolean; output: boolean} + Int: {input: number; output: number} + Float: {input: number; output: number} + /** + * An Amazon Web Services Amazon Resource Name (ARN), including the Region and account ID. + * For more information, refer to [Amazon Resource Names](https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html). + */ + ARN: {input: any; output: any} + /** + * Represents non-fractional signed whole numeric values. Since the value may + * exceed the size of a 32-bit integer, it's encoded as a string. + */ + BigInt: {input: any; output: any} + /** + * A string containing a hexadecimal representation of a color. + * + * For example, "#6A8D48". + */ + Color: {input: any; output: any} + /** + * Represents an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)-encoded date string. + * For example, September 7, 2019 is represented as `"2019-07-16"`. + */ + Date: {input: any; output: any} + /** + * Represents an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)-encoded date and time string. + * For example, 3:50 pm on September 7, 2019 in the time zone of UTC (Coordinated Universal Time) is + * represented as `"2019-09-07T15:50:00Z`". + */ + DateTime: {input: any; output: any} + /** + * A signed decimal number, which supports arbitrary precision and is serialized as a string. + * + * Example values: `"29.99"`, `"29.999"`. + */ + Decimal: {input: any; output: any} + /** + * A string containing a strict subset of HTML code. Non-allowed tags will be stripped out. + * Allowed tags: + * * `a` (allowed attributes: `href`, `target`) + * * `b` + * * `br` + * * `em` + * * `i` + * * `strong` + * * `u` + * Use [HTML](https://shopify.dev/api/admin-graphql/latest/scalars/HTML) instead if you need to + * include other HTML tags. + * + * Example value: `"Your current domain is example.myshopify.com."` + */ + FormattedString: {input: any; output: any} + /** + * A string containing HTML code. Refer to the [HTML spec](https://html.spec.whatwg.org/#elements-3) for a + * complete list of HTML elements. + * + * Example value: `"

Grey cotton knit sweater.

"` + */ + HTML: {input: any; output: any} + /** + * A [JSON](https://www.json.org/json-en.html) object. + * + * Example value: + * `{ + * "product": { + * "id": "gid://shopify/Product/1346443542550", + * "title": "White T-shirt", + * "options": [{ + * "name": "Size", + * "values": ["M", "L"] + * }] + * } + * }` + */ + JSON: {input: JsonMapType | string; output: JsonMapType} + /** A monetary value string without a currency symbol or code. Example value: `"100.57"`. */ + Money: {input: any; output: any} + /** A scalar value. */ + Scalar: {input: any; output: any} + /** + * Represents a unique identifier in the Storefront API. A `StorefrontID` value can + * be used wherever an ID is expected in the Storefront API. + * + * Example value: `"Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0LzEwMDc5Nzg1MTAw"`. + */ + StorefrontID: {input: any; output: any} + /** + * Represents an [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) and + * [RFC 3987](https://datatracker.ietf.org/doc/html/rfc3987)-compliant URI string. + * + * For example, `"https://example.myshopify.com"` is a valid URL. It includes a scheme (`https`) and a host + * (`example.myshopify.com`). + */ + URL: {input: string; output: string} + /** + * An unsigned 64-bit integer. Represents whole numeric values between 0 and 2^64 - 1 encoded as a string of base-10 digits. + * + * Example value: `"50"`. + */ + UnsignedInt64: {input: any; output: any} + /** + * Time between UTC time and a location's observed time, in the format `"+HH:MM"` or `"-HH:MM"`. + * + * Example value: `"-07:00"`. + */ + UtcOffset: {input: any; output: any} +} + +/** Error codes for failed bulk operations. */ +export type BulkOperationErrorCode = + /** + * The provided operation `query` returned access denied due to missing + * [access scopes](https://shopify.dev/api/usage/access-scopes). + * Review the requested object permissions and execute the query as a normal non-bulk GraphQL request to see more details. + */ + | 'ACCESS_DENIED' + /** + * The operation resulted in partial or incomplete data due to internal server errors during execution. + * These errors might be intermittent, so you can try performing the same query again. + */ + | 'INTERNAL_SERVER_ERROR' + /** + * The operation resulted in partial or incomplete data due to query timeouts during execution. + * In some cases, timeouts can be avoided by modifying your `query` to select fewer fields. + */ + | 'TIMEOUT' + +/** The valid values for the status of a bulk operation. */ +export type BulkOperationStatus = + /** The bulk operation has been canceled. */ + | 'CANCELED' + /** + * Cancelation has been initiated on the bulk operation. There may be a short delay from when a cancelation + * starts until the operation is actually canceled. + */ + | 'CANCELING' + /** The bulk operation has successfully completed. */ + | 'COMPLETED' + /** The bulk operation has been created. */ + | 'CREATED' + /** The bulk operation URL has expired. */ + | 'EXPIRED' + /** + * The bulk operation has failed. For information on why the operation failed, use + * [BulkOperation.errorCode](https://shopify.dev/api/admin-graphql/latest/enums/bulkoperationerrorcode). + */ + | 'FAILED' + /** The bulk operation is runnning. */ + | 'RUNNING' 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 new file mode 100644 index 00000000000..9fb61f635f2 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql @@ -0,0 +1,18 @@ +mutation BulkOperationRunQuery($query: String!) { + bulkOperationRunQuery(query: $query) { + bulkOperation { + id + status + errorCode + createdAt + objectCount + fileSize + url + } + userErrors { + field + message + } + } +} + diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 5bd5b469b12..52f8be69d5f 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -41,13 +41,15 @@ export default class Execute extends AppLinkedCommand { body: `App: ${appContextResult.app.name}\nStore: ${store.shopDomain}`, }) - const {result, errors} = await runBulkOperationQuery({ + const bulkOperationResponse = await runBulkOperationQuery({ storeFqdn: store.shopDomain, query: flags.query, }) - if (errors?.length) { - const errorMessages = errors.map((error) => `${error.field?.join('.') ?? 'unknown'}: ${error.message}`).join('\n') + if (bulkOperationResponse?.userErrors?.length) { + const errorMessages = bulkOperationResponse.userErrors + .map((error) => `${error.field?.join('.') ?? 'unknown'}: ${error.message}`) + .join('\n') renderWarning({ headline: 'Bulk operation errors.', body: errorMessages, @@ -55,6 +57,7 @@ export default class Execute extends AppLinkedCommand { return {app: appContextResult.app} } + const result = bulkOperationResponse?.bulkOperation if (result) { const infoSections = [ { @@ -65,7 +68,7 @@ export default class Execute extends AppLinkedCommand { items: [ outputContent`ID: ${outputToken.cyan(result.id)}`.value, outputContent`Status: ${outputToken.yellow(result.status)}`.value, - outputContent`Created: ${outputToken.gray(result.createdAt)}`.value, + outputContent`Created: ${outputToken.gray(String(result.createdAt))}`.value, ], }, }, diff --git a/packages/app/src/cli/services/bulk-operation-run-query.ts b/packages/app/src/cli/services/bulk-operation-run-query.ts index 8002b832f54..551d52b1328 100644 --- a/packages/app/src/cli/services/bulk-operation-run-query.ts +++ b/packages/app/src/cli/services/bulk-operation-run-query.ts @@ -1,10 +1,8 @@ import { BulkOperationRunQuery, - BulkOperation, - BulkOperationError, - BulkOperationRunQuerySchema, -} from '../api/graphql/admin-bulk-operations.js' -import {adminRequest} from '@shopify/cli-kit/node/api/admin' + 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' interface BulkOperationRunQueryOptions { @@ -18,23 +16,14 @@ interface BulkOperationRunQueryOptions { */ export async function runBulkOperationQuery( options: BulkOperationRunQueryOptions, -): Promise<{result?: BulkOperation; errors?: BulkOperationError[]}> { +): Promise { const {storeFqdn, query} = options const adminSession = await ensureAuthenticatedAdmin(storeFqdn) - const response = await adminRequest(BulkOperationRunQuery, adminSession, {query}) + const response = await adminRequestDoc({ + query: BulkOperationRunQuery, + session: adminSession, + variables: {query}, + }) - if (response.bulkOperationRunQuery.userErrors.length > 0) { - return { - errors: response.bulkOperationRunQuery.userErrors, - } - } - - const bulkOperation = response.bulkOperationRunQuery.bulkOperation - if (bulkOperation) { - return {result: bulkOperation} - } - - return { - errors: [{field: null, message: 'No bulk operation was created'}], - } + return response.bulkOperationRunQuery } 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 4d2bf4b7c8a..b698a4bedb6 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,5 +1,5 @@ import {runBulkOperationQuery} from './bulk-operation-run-query.js' -import {adminRequest} from '@shopify/cli-kit/node/api/admin' +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' @@ -29,14 +29,14 @@ describe('runBulkOperationQuery', () => { }) test('returns a bulk operation when request succeeds', async () => { - vi.mocked(adminRequest).mockResolvedValue(mockSuccessResponse) + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) const bulkOperationResult = await runBulkOperationQuery({ storeFqdn: 'test-store.myshopify.com', query: 'query { products { edges { node { id } } } }', }) - expect(bulkOperationResult.result).toEqual(successfulBulkOperation) - expect(bulkOperationResult.errors).toBeUndefined() + expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation) + expect(bulkOperationResult?.userErrors).toEqual([]) }) }) From 3784bae8eeb1a6d2892324f37f7c458a822533b8 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Wed, 12 Nov 2025 14:20:49 -0800 Subject: [PATCH 3/3] refactor: extract bulk operation logic into service class; create bulk-operations directory in services directory --- bin/get-graphql-schemas.js | 7 ++ .../generated/bulk-operation-run-query.ts | 34 +++++-- .../bulk-operations/generated/types.d.ts | 16 ++++ .../bulk-operation-run-query.graphql | 20 +++-- packages/app/src/cli/commands/app/execute.ts | 50 +---------- .../execute-bulk-operation.test.ts | 88 +++++++++++++++++++ .../bulk-operations/execute-bulk-operation.ts | 65 ++++++++++++++ .../run-query.test.ts} | 2 +- .../run-query.ts} | 6 +- 9 files changed, 221 insertions(+), 67 deletions(-) create mode 100644 packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts create mode 100644 packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts rename packages/app/src/cli/services/{bulk-operations-run-query.test.ts => bulk-operations/run-query.test.ts} (95%) rename packages/app/src/cli/services/{bulk-operation-run-query.ts => bulk-operations/run-query.ts} (78%) diff --git a/bin/get-graphql-schemas.js b/bin/get-graphql-schemas.js index 3990a5a01f8..f67cce34f7d 100755 --- a/bin/get-graphql-schemas.js +++ b/bin/get-graphql-schemas.js @@ -71,6 +71,13 @@ const schemas = [ pathToFile: 'areas/core/shopify/db/graphql/functions_cli_api_schema_unstable_public.graphql', localPath: './packages/app/src/cli/api/graphql/functions/functions_cli_schema.graphql', }, + { + owner: 'shop', + repo: 'world', + pathToFile: 'areas/core/shopify/db/graphql/admin_schema_unstable_public.graphql', + localPath: './packages/app/src/cli/api/graphql/bulk-operations/admin_schema.graphql', + usesLfs: true, + }, ] 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 f1f2cc287c7..377935b76bd 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 @@ -10,15 +10,20 @@ export type BulkOperationRunQueryMutationVariables = Types.Exact<{ export type BulkOperationRunQueryMutation = { bulkOperationRunQuery?: { bulkOperation?: { - id: string - status: Types.BulkOperationStatus - errorCode?: Types.BulkOperationErrorCode | null + completedAt?: unknown | null createdAt: unknown - objectCount: 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: {field?: string[] | null; message: string}[] + userErrors: {code?: Types.BulkOperationUserErrorCode | null; field?: string[] | null; message: string}[] } | null } @@ -48,6 +53,11 @@ 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', @@ -58,12 +68,17 @@ export const BulkOperationRunQuery = { selectionSet: { kind: 'SelectionSet', selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'id'}}, - {kind: 'Field', name: {kind: 'Name', value: 'status'}}, - {kind: 'Field', name: {kind: 'Name', value: 'errorCode'}}, + {kind: 'Field', name: {kind: 'Name', value: 'completedAt'}}, {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, - {kind: 'Field', name: {kind: 'Name', value: 'objectCount'}}, + {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'}}, ], @@ -75,6 +90,7 @@ export const BulkOperationRunQuery = { 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'}}, 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 c580cd035f0..942fb8d4851 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 @@ -161,3 +161,19 @@ export type BulkOperationStatus = | 'FAILED' /** The bulk operation is runnning. */ | 'RUNNING' + +/** The valid values for the bulk operation's type. */ +export type BulkOperationType = + /** The bulk operation is a mutation. */ + | 'MUTATION' + /** The bulk operation is a query. */ + | 'QUERY' + +/** Possible error codes that can be returned by `BulkOperationUserError`. */ +export type BulkOperationUserErrorCode = + /** The input value is invalid. */ + | 'INVALID' + /** Bulk operations limit reached. Please try again later. */ + | 'LIMIT_REACHED' + /** A bulk operation is already in progress. */ + | 'OPERATION_IN_PROGRESS' 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 9fb61f635f2..ea15a587663 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,15 +1,25 @@ mutation BulkOperationRunQuery($query: String!) { - bulkOperationRunQuery(query: $query) { + bulkOperationRunQuery( + query: $query + # Set to false to optimize for speed over grouped results + groupObjects: false + ) { bulkOperation { - id - status - errorCode + completedAt createdAt - objectCount + errorCode fileSize + id + objectCount + partialDataUrl + query + rootObjectCount + status + type url } userErrors { + code field message } diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 52f8be69d5f..ac8884410ce 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -2,10 +2,8 @@ import {appFlags, bulkOperationFlags} from '../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {linkedAppContext} from '../../services/app-context.js' import {storeContext} from '../../services/store-context.js' -import {runBulkOperationQuery} from '../../services/bulk-operation-run-query.js' +import {executeBulkOperation} from '../../services/bulk-operations/execute-bulk-operation.js' import {globalFlags} from '@shopify/cli-kit/node/cli' -import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui' -import {outputContent, outputToken} from '@shopify/cli-kit/node/output' export default class Execute extends AppLinkedCommand { static summary = 'Execute bulk operations.' @@ -36,54 +34,12 @@ export default class Execute extends AppLinkedCommand { forceReselectStore: flags.reset, }) - renderInfo({ - headline: 'Starting bulk operation.', - body: `App: ${appContextResult.app.name}\nStore: ${store.shopDomain}`, - }) - - const bulkOperationResponse = await runBulkOperationQuery({ + await executeBulkOperation({ + app: appContextResult.app, storeFqdn: store.shopDomain, query: flags.query, }) - if (bulkOperationResponse?.userErrors?.length) { - const errorMessages = bulkOperationResponse.userErrors - .map((error) => `${error.field?.join('.') ?? 'unknown'}: ${error.message}`) - .join('\n') - renderWarning({ - headline: 'Bulk operation errors.', - body: errorMessages, - }) - return {app: appContextResult.app} - } - - const result = bulkOperationResponse?.bulkOperation - if (result) { - const infoSections = [ - { - title: 'Bulk Operation Created', - body: [ - { - list: { - items: [ - outputContent`ID: ${outputToken.cyan(result.id)}`.value, - outputContent`Status: ${outputToken.yellow(result.status)}`.value, - outputContent`Created: ${outputToken.gray(String(result.createdAt))}`.value, - ], - }, - }, - ], - }, - ] - - renderInfo({customSections: infoSections}) - - renderSuccess({ - headline: 'Bulk operation started successfully!', - body: 'Congrats!', - }) - } - return {app: appContextResult.app} } } 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 new file mode 100644 index 00000000000..52dbdfa8bce --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts @@ -0,0 +1,88 @@ +import {executeBulkOperation} from './execute-bulk-operation.js' +import {runBulkOperationQuery} from './run-query.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' + +vi.mock('./run-query.js') +vi.mock('@shopify/cli-kit/node/ui') + +describe('executeBulkOperation', () => { + const mockApp = { + name: 'Test App', + } as AppLinkedInterface + + const storeFqdn = 'test-store.myshopify.com' + const query = 'query { products { edges { node { id } } } }' + + const successfulBulkOperation = { + id: 'gid://shopify/BulkOperation/123', + status: 'CREATED', + errorCode: null, + createdAt: '2024-01-01T00:00:00Z', + objectCount: '0', + fileSize: '0', + url: null, + } + + test('executeBulkOperation successfully runs', async () => { + const mockResponse = { + bulkOperation: successfulBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query, + }) + + expect(runBulkOperationQuery).toHaveBeenCalledWith({ + storeFqdn, + query, + }) + + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'Starting bulk operation.', + body: `App: ${mockApp.name}\nStore: ${storeFqdn}`, + }) + + expect(renderInfo).toHaveBeenCalledWith({ + customSections: expect.arrayContaining([ + expect.objectContaining({ + title: 'Bulk Operation Created', + }), + ]), + }) + + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'Bulk operation started successfully!', + body: 'Congrats!', + }) + }) + + test('executeBulkOperation renders warning when user errors are present', async () => { + const mockResponse = { + bulkOperation: null, + userErrors: [ + {field: ['query'], message: 'Invalid query syntax'}, + {field: null, message: 'Another error'}, + ], + } + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query, + }) + + expect(renderWarning).toHaveBeenCalledWith({ + headline: 'Bulk operation errors.', + body: 'query: Invalid query syntax\nunknown: Another error', + }) + + expect(renderSuccess).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 new file mode 100644 index 00000000000..9d722de96d8 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts @@ -0,0 +1,65 @@ +import {runBulkOperationQuery} from './run-query.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' + +interface ExecuteBulkOperationInput { + app: AppLinkedInterface + storeFqdn: string + query: string +} + +export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { + const {app, storeFqdn, query} = input + + renderInfo({ + headline: 'Starting bulk operation.', + body: `App: ${app.name}\nStore: ${storeFqdn}`, + }) + + const bulkOperationResponse = await runBulkOperationQuery({ + storeFqdn, + query, + }) + + if (bulkOperationResponse?.userErrors?.length) { + const errorMessages = bulkOperationResponse.userErrors + .map( + (error: {field?: string[] | null; message: string}) => + `${error.field?.join('.') ?? 'unknown'}: ${error.message}`, + ) + .join('\n') + renderWarning({ + headline: 'Bulk operation errors.', + body: errorMessages, + }) + return + } + + const result = bulkOperationResponse?.bulkOperation + if (result) { + const infoSections = [ + { + title: 'Bulk Operation Created', + body: [ + { + list: { + items: [ + outputContent`ID: ${outputToken.cyan(result.id)}`.value, + outputContent`Status: ${outputToken.yellow(result.status)}`.value, + outputContent`Created: ${outputToken.gray(String(result.createdAt))}`.value, + ], + }, + }, + ], + }, + ] + + renderInfo({customSections: infoSections}) + + renderSuccess({ + headline: 'Bulk operation started successfully!', + body: 'Congrats!', + }) + } +} 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 similarity index 95% rename from packages/app/src/cli/services/bulk-operations-run-query.test.ts rename to packages/app/src/cli/services/bulk-operations/run-query.test.ts index b698a4bedb6..cabf0ba0064 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,4 +1,4 @@ -import {runBulkOperationQuery} from './bulk-operation-run-query.js' +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' diff --git a/packages/app/src/cli/services/bulk-operation-run-query.ts b/packages/app/src/cli/services/bulk-operations/run-query.ts similarity index 78% rename from packages/app/src/cli/services/bulk-operation-run-query.ts rename to packages/app/src/cli/services/bulk-operations/run-query.ts index 551d52b1328..f0281402d5a 100644 --- a/packages/app/src/cli/services/bulk-operation-run-query.ts +++ b/packages/app/src/cli/services/bulk-operations/run-query.ts @@ -1,7 +1,7 @@ import { BulkOperationRunQuery, BulkOperationRunQueryMutation, -} from '../api/graphql/bulk-operations/generated/bulk-operation-run-query.js' +} 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' @@ -10,10 +10,6 @@ interface BulkOperationRunQueryOptions { query: string } -/** - * Executes a bulk operation query against the Shopify Admin API. - * The operation runs asynchronously in the background. - */ export async function runBulkOperationQuery( options: BulkOperationRunQueryOptions, ): Promise {