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/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/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..377935b76bd --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts @@ -0,0 +1,108 @@ +/* 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?: { + 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.BulkOperationUserErrorCode | null; 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'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'groupObjects'}, + value: {kind: 'BooleanValue', value: false}, + }, + ], + 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/types.d.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts new file mode 100644 index 00000000000..942fb8d4851 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts @@ -0,0 +1,179 @@ +/* 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' + +/** 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 new file mode 100644 index 00000000000..ea15a587663 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql @@ -0,0 +1,28 @@ +mutation BulkOperationRunQuery($query: String!) { + bulkOperationRunQuery( + query: $query + # Set to false to optimize for speed over grouped results + groupObjects: false + ) { + bulkOperation { + completedAt + createdAt + 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 a3dba4e5186..ac8884410ce 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -1,29 +1,45 @@ -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 {executeBulkOperation} from '../../services/bulk-operations/execute-bulk-operation.js' import {globalFlags} from '@shopify/cli-kit/node/cli' -import {renderSuccess} from '@shopify/cli-kit/node/ui' -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, + }) + + await executeBulkOperation({ + app: appContextResult.app, + storeFqdn: store.shopDomain, + query: flags.query, + }) + + 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-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 new file mode 100644 index 00000000000..cabf0ba0064 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/run-query.test.ts @@ -0,0 +1,42 @@ +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' + +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(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + + const bulkOperationResult = await runBulkOperationQuery({ + storeFqdn: 'test-store.myshopify.com', + query: 'query { products { edges { node { id } } } }', + }) + + expect(bulkOperationResult?.bulkOperation).toEqual(successfulBulkOperation) + expect(bulkOperationResult?.userErrors).toEqual([]) + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/run-query.ts b/packages/app/src/cli/services/bulk-operations/run-query.ts new file mode 100644 index 00000000000..f0281402d5a --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/run-query.ts @@ -0,0 +1,25 @@ +import { + BulkOperationRunQuery, + 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 { + storeFqdn: string + query: string +} + +export async function runBulkOperationQuery( + options: BulkOperationRunQueryOptions, +): Promise { + const {storeFqdn, query} = options + const adminSession = await ensureAuthenticatedAdmin(storeFqdn) + const response = await adminRequestDoc({ + query: BulkOperationRunQuery, + session: adminSession, + variables: {query}, + }) + + return response.bulkOperationRunQuery +} 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": [