diff --git a/.changeset/app-bulk-execute-json.md b/.changeset/app-bulk-execute-json.md new file mode 100644 index 00000000000..16591b45916 --- /dev/null +++ b/.changeset/app-bulk-execute-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to app bulk execute. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 194b4e02be4..f33adff0355 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -351,6 +351,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-bulk-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-bulk-execute.interface.ts", "syntaxKind": "PropertySignature", @@ -379,7 +388,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface appbulkexecute {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The file path where results should be written if --watch is specified. If not specified, results will be written to STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * The GraphQL query or mutation to run as a bulk operation.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The store domain. Must be an existing dev store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the bulk operation. If not specified, uses the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n\n /**\n * Wait for bulk operation results before exiting. Defaults to false.\n * @environment SHOPIFY_FLAG_WATCH\n */\n '--watch'?: ''\n}" + "value": "export interface appbulkexecute {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The file path where results should be written if --watch is specified. If not specified, results will be written to STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * The GraphQL query or mutation to run as a bulk operation.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The store domain. Must be an existing dev store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the bulk operation. If not specified, uses the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n\n /**\n * Wait for bulk operation results before exiting. Defaults to false.\n * @environment SHOPIFY_FLAG_WATCH\n */\n '--watch'?: ''\n}" } }, "appbulkstatus": { diff --git a/packages/app/src/cli/commands/app/bulk/execute.test.ts b/packages/app/src/cli/commands/app/bulk/execute.test.ts index f8b10e2d46d..f0894d61a50 100644 --- a/packages/app/src/cli/commands/app/bulk/execute.test.ts +++ b/packages/app/src/cli/commands/app/bulk/execute.test.ts @@ -1,5 +1,6 @@ import BulkExecute from './execute.js' -import {executeBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js' +import {renderExecuteBulkOperationResult} from '../../../services/bulk-operations/execute-result.js' +import {executeBulkOperation, prepareBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js' import {prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' import { testAppLinked, @@ -12,6 +13,8 @@ import {describe, expect, test, vi, beforeEach} from 'vitest' vi.mock('../../../services/bulk-operations/execute-bulk-operation.js') vi.mock('../../../utilities/execute-command-helpers.js') +vi.mock('../../../services/bulk-operations/execute-result.js') +vi.mock('../../../services/bulk-operations/progress.js') describe('app bulk execute command', () => { const app = testAppLinked() @@ -33,7 +36,19 @@ describe('app bulk execute command', () => { store, query: 'query { shop { name } }', }) - vi.mocked(executeBulkOperation).mockResolvedValue() + vi.mocked(prepareBulkOperation).mockResolvedValue({ + adminSession: {storeFqdn: store.shopDomain, token: 'token'}, + version: '2026-01', + query: 'query { shop { name } }', + variablesJsonl: undefined, + watch: false, + }) + vi.mocked(executeBulkOperation).mockResolvedValue({ + query: 'query { shop { name } }', + operation: null, + userErrors: [], + watchAborted: false, + }) }) test('prepares execution context and calls executeBulkOperation', async () => { @@ -41,25 +56,24 @@ describe('app bulk execute command', () => { await BulkExecute.run(['--query', 'query { shop { name } }', '--store', 'shop.myshopify.com']) // Then + expect(executeBulkOperation).toHaveBeenCalledWith(await vi.mocked(prepareBulkOperation).mock.results[0]!.value) expect(prepareExecuteContext).toHaveBeenCalledWith( expect.objectContaining({ query: 'query { shop { name } }', store: 'shop.myshopify.com', }), ) - expect(executeBulkOperation).toHaveBeenCalledWith({ - organization, + expect(prepareBulkOperation).toHaveBeenCalledWith({ remoteApp, store, query: 'query { shop { name } }', variables: undefined, variableFile: undefined, watch: false, - outputFile: undefined, }) }) - test('calls executeBulkOperation with variables flag', async () => { + test('passes input variables to preparation and output files to presentation', async () => { // When await BulkExecute.run([ '--query', @@ -74,15 +88,17 @@ describe('app bulk execute command', () => { ]) // Then - expect(executeBulkOperation).toHaveBeenCalledWith({ - organization, + expect(renderExecuteBulkOperationResult).toHaveBeenCalledWith( + {query: 'query { shop { name } }', operation: null, userErrors: [], watchAborted: false}, + {format: 'text', watch: true, outputFile: 'output.json'}, + ) + expect(prepareBulkOperation).toHaveBeenCalledWith({ remoteApp, store, query: 'query { shop { name } }', variables: ['{"key": "value"}'], variableFile: undefined, watch: true, - outputFile: 'output.json', }) }) @@ -98,15 +114,13 @@ describe('app bulk execute command', () => { ]) // Then - expect(executeBulkOperation).toHaveBeenCalledWith({ - organization, + expect(prepareBulkOperation).toHaveBeenCalledWith({ remoteApp, store, query: 'query { shop { name } }', variables: undefined, variableFile: expect.stringContaining('variables.jsonl'), watch: false, - outputFile: undefined, }) }) }) diff --git a/packages/app/src/cli/commands/app/bulk/execute.ts b/packages/app/src/cli/commands/app/bulk/execute.ts index befd68812cb..24d150563f5 100644 --- a/packages/app/src/cli/commands/app/bulk/execute.ts +++ b/packages/app/src/cli/commands/app/bulk/execute.ts @@ -1,8 +1,11 @@ import {appFlags, bulkOperationFlags} from '../../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' -import {executeBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js' +import {executeBulkOperation, prepareBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js' +import {executeBulkOperationJsonOutputSchema} from '../../../services/bulk-operations/types.js' +import {renderExecuteBulkOperationResult} from '../../../services/bulk-operations/execute-result.js' +import {logBulkOperationStart} from '../../../services/bulk-operations/progress.js' import {prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' export default class BulkExecute extends AppLinkedCommand { static summary = 'Execute bulk operations.' @@ -11,32 +14,54 @@ export default class BulkExecute extends AppLinkedCommand { Bulk operations allow you to process large amounts of data asynchronously. Learn more about [bulk query operations](https://shopify.dev/docs/api/usage/bulk-operations/queries) and [bulk mutation operations](https://shopify.dev/docs/api/usage/bulk-operations/imports). - Use [\`bulk status\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status) to check the status of your bulk operations.` + Use [\`bulk status\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status) to check the status of your bulk operations. + + With \`--watch\`, completed results are written as JSONL to stdout or \`--output-file\`. With \`--json\`, stdout contains one result object with operation details and downloaded JSONL in \`resultsJsonl\`. With \`--json --output-file\`, stdout contains only an absolute file receipt with \`path\` and \`format\`.` static description = this.descriptionForHelp() static flags = { ...globalFlags, + ...jsonFlag, ...appFlags, ...bulkOperationFlags, } + static get jsonOutputSchema() { + return executeBulkOperationJsonOutputSchema + } + async run(): Promise { const {flags} = await this.parse(BulkExecute) const {query, appContextResult, store} = await prepareExecuteContext(flags) - await executeBulkOperation({ - organization: appContextResult.organization, + const format = flags.json ? 'json' : 'text' + const input = await prepareBulkOperation({ remoteApp: appContextResult.remoteApp, store, query, variables: flags.variables, variableFile: flags['variable-file'], watch: flags.watch ?? false, - outputFile: flags['output-file'], ...(flags.version && {version: flags.version}), }) + logBulkOperationStart( + 'Starting bulk operation.', + { + organization: appContextResult.organization, + remoteApp: appContextResult.remoteApp, + storeFqdn: store.shopDomain, + version: input.version, + }, + format, + ) + const result = await executeBulkOperation(input) + await renderExecuteBulkOperationResult(result, { + format, + watch: flags.watch ?? false, + outputFile: flags['output-file'], + }) return {app: appContextResult.app} } diff --git a/packages/app/src/cli/commands/app/bulk/output.test.ts b/packages/app/src/cli/commands/app/bulk/output.test.ts index ee930dc0dbd..116dd2d51f3 100644 --- a/packages/app/src/cli/commands/app/bulk/output.test.ts +++ b/packages/app/src/cli/commands/app/bulk/output.test.ts @@ -1,47 +1,49 @@ import BulkStatus from './status.js' import BulkCancel from './cancel.js' +import BulkExecute from './execute.js' import { testBulkOperation, testBulkOperationContext, } from '../../../services/bulk-operations/bulk-operation.test-data.js' -import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' -import {createAdminSessionAsApp, resolveApiVersion} from '../../../services/graphql/common.js' +import {prepareAppStoreContext, prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' import { bulkOperationStatusJsonOutputSchema, cancelBulkOperationJsonOutputSchema, + executeBulkOperationJsonOutputSchema, } from '../../../services/bulk-operations/types.js' +import * as ui from '@shopify/cli-kit/node/ui' import { fetchBulkOperationById, fetchRecentBulkOperations, cancelBulkOperationRequest, + runBulkOperationQuery, + runBulkOperationMutation, + shortBulkOperationPoll, + watchBulkOperation, } from '@shopify/cli-kit/node/api/bulk-operations' +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' +import {fetch} from '@shopify/cli-kit/node/http' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath, cwd, relativePath} from '@shopify/cli-kit/node/path' import {AbortError, handler} from '@shopify/cli-kit/node/error' import {Config} from '@oclif/core' import {afterEach, expect, test, vi} from 'vitest' +import {fileURLToPath} from 'node:url' import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' vi.mock('../../../utilities/execute-command-helpers.js') -vi.mock('../../../services/graphql/common.js', async (importOriginal) => ({ - ...(await importOriginal()), - createAdminSessionAsApp: vi.fn(), - resolveApiVersion: vi.fn(), -})) - -vi.mock('@shopify/cli-kit/node/session', async (importOriginal) => ({ - ...(await importOriginal()), - ensureAuthenticatedAdminAsApp: vi.fn(), -})) - -vi.mock('@shopify/cli-kit/node/api/bulk-operations', async (importOriginal) => ({ - ...(await importOriginal()), - fetchBulkOperationById: vi.fn(), - fetchRecentBulkOperations: vi.fn(), - cancelBulkOperationRequest: vi.fn(), -})) +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/api/bulk-operations/fetch') +vi.mock('@shopify/cli-kit/node/api/bulk-operations/cancel') +vi.mock('@shopify/cli-kit/node/api/bulk-operations/run-query') +vi.mock('@shopify/cli-kit/node/api/bulk-operations/run-mutation') +vi.mock('@shopify/cli-kit/node/api/bulk-operations/watch-bulk-operation') const originalExitCode = process.exitCode @@ -58,6 +60,13 @@ function bulkOperation(overrides: Partial = {}): BulkOperation { }) } +function mockDownloadResults(results: string): void { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: async () => results, + } as Awaited>) +} + function expectedOperation() { return { gid: 'gid://shopify/BulkOperation/123', @@ -72,14 +81,18 @@ function expectedOperation() { } } -async function runCommand(Command: typeof BulkStatus | typeof BulkCancel, argv: string[]) { - const {appContextResult, store} = testBulkOperationContext() +async function runCommand( + Command: typeof BulkStatus | typeof BulkCancel | typeof BulkExecute, + argv: string[], + query = 'query { shop { name } }', +) { + const {appContextResult, store: defaultStore} = testBulkOperationContext() + const store = {...defaultStore, storeType: 'APP_DEVELOPMENT' as const} vi.mocked(prepareAppStoreContext).mockResolvedValue({appContextResult, store}) - + vi.mocked(prepareExecuteContext).mockResolvedValue({appContextResult, store, query}) const session = {storeFqdn: store.shopDomain, token: 'token'} - vi.mocked(createAdminSessionAsApp).mockResolvedValue(session) vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(session) - vi.mocked(resolveApiVersion).mockResolvedValue('2026-01') + vi.mocked(fetchApiVersions).mockResolvedValue([{handle: '2026-01', supported: true}]) const config = await Config.load() return runWithCommandEventsForCommand(argv, () => new Command(argv, config).run()) } @@ -156,33 +169,203 @@ test('cancellation emits one result through the real service, codec, presenter, }) }) -test.each([{name: 'cancel', Command: BulkCancel}])( - '$name user errors reach the fatal envelope without a success document', - async ({Command}) => { - const userErrors = [{field: ['id'], message: 'Operation rejected'}] - vi.mocked(cancelBulkOperationRequest).mockResolvedValue({bulkOperation: null, userErrors}) +test('unwatched execution reports the operation without downloading results', async () => { + vi.mocked(runBulkOperationQuery).mockResolvedValue({bulkOperation: bulkOperation(), userErrors: []}) + vi.mocked(shortBulkOperationPoll).mockResolvedValue(bulkOperation()) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(BulkExecute, ['--query', 'query { shop { name } }', '--json']) + expect(JSON.parse(stdout())).toEqual({ + storeDomain: 'shop.myshopify.com', + apiVersion: '2026-01', + status: 'success', + operation: expectedOperation(), + }) + expect(fetch).not.toHaveBeenCalled() + assertDiagnosticEvents(stderr(), 'Starting bulk operation.') + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({type: 'progress', status: 'started'}), + expect.objectContaining({type: 'progress', status: 'completed'}), + ]), + ) + }) +}) + +test.each(['', '{"id":"1"}\n{"id":"2"}\n'])('watched execution keeps native inline JSONL %j', async (results) => { + const operation = bulkOperation({status: 'COMPLETED', url: 'https://example.com/results.jsonl'}) + vi.mocked(runBulkOperationQuery).mockResolvedValue({bulkOperation: operation, userErrors: []}) + vi.mocked(watchBulkOperation).mockResolvedValue(operation) + mockDownloadResults(results) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(BulkExecute, ['--query', 'query { shop { name } }', '--watch', '--json']) + expect(JSON.parse(stdout())).toEqual({ + storeDomain: 'shop.myshopify.com', + apiVersion: '2026-01', + status: 'success', + operation: {...expectedOperation(), status: 'COMPLETED', url: 'https://example.com/results.jsonl'}, + resultsJsonl: results, + }) + assertDiagnosticEvents(stderr(), 'Starting bulk operation.') + }) +}) - vi.stubEnv('SHOPIFY_FLAG_JSON', '1') +test('file output keeps the exact JSONL bytes and prints only an absolute receipt', async () => { + const operation = bulkOperation({status: 'COMPLETED', url: 'https://example.com/results.jsonl'}) + const results = '{"id":"1"}\r\n{"id":"2"}\n' + vi.mocked(runBulkOperationQuery).mockResolvedValue({bulkOperation: operation, userErrors: []}) + vi.mocked(watchBulkOperation).mockResolvedValue(operation) + mockDownloadResults(results) + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'results.jsonl') await withCapturedStandardStreams(async ({stdout, stderr}) => { - const argv = ['--id', '123'] - try { - await runCommand(Command, [...argv, '--json']) - expect.fail('The command must reject the operation.') - } catch (error) { - if (!(error instanceof AbortError)) throw error - expect(error).toMatchObject({details: {userErrors}}) - expect(stdout()).toBe('') - await handler(error) + await runCommand(BulkExecute, [ + '--query', + 'query { shop { name } }', + '--watch', + '--json', + '--output-file', + relativePath(cwd(), path), + ]) + expect(JSON.parse(stdout())).toEqual({path, format: 'jsonl'}) + await expect(readFile(path)).resolves.toBe(results) + assertDiagnosticEvents(stderr(), 'Starting bulk operation.') + }) + }) +}) + +test.each([ + {file: false, fragment: false}, + {file: true, fragment: false}, + {file: false, fragment: true}, + {file: true, fragment: true}, +])('aliased mutation errors fail with file=$file and fragment=$fragment', async ({file, fragment}) => { + const query = fragment + ? await readFile( + fileURLToPath(new URL('../../../services/bulk-operations/fixtures/aliased-mutation.graphql', import.meta.url)), + ) + : 'mutation Update($input: ProductInput!) { update: productUpdate(input: $input) { errors: userErrors { message } } }' + const operation = bulkOperation({type: 'MUTATION', status: 'COMPLETED', url: 'https://example.com/results.jsonl'}) + const results = fragment + ? '{"data":{"__typename":"Mutation","update":{"errors":[{"message":"Rejected"}]}}}\r\n' + : '{"data":{"update":{"errors":[{"message":"Rejected"}]}}}\r\n' + vi.mocked(runBulkOperationMutation).mockResolvedValue({ + bulkOperation: operation, + userErrors: [], + }) + vi.mocked(watchBulkOperation).mockResolvedValue(operation) + mockDownloadResults(results) + + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'results.jsonl') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand( + BulkExecute, + [ + '--query', + query, + '--variables', + '{"input":{"id":"gid://shopify/Product/1"}}', + '--watch', + '--json', + ...(file ? ['--output-file', path] : []), + ], + query, + ) + if (file) { + expect(JSON.parse(stdout())).toEqual({path, format: 'jsonl'}) + await expect(readFile(path)).resolves.toBe(results) + } else { + expect(JSON.parse(stdout())).toEqual({ + storeDomain: 'shop.myshopify.com', + apiVersion: '2026-01', + status: 'partial', + operation: {...expectedOperation(), type: 'MUTATION', status: 'COMPLETED', url: operation.url}, + resultsJsonl: results, + }) } - expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', details: {userErrors}}}) - assertDiagnosticEvents(stderr(), 'Canceling bulk operation.') + expect(process.exitCode).toBe(1) + assertDiagnosticEvents(stderr(), 'Starting bulk operation.') }) - }, -) + }) +}) + +test('a completed query with no matches writes an empty JSONL file and a success receipt', async () => { + const operation = bulkOperation({status: 'COMPLETED', objectCount: '0', url: null}) + vi.mocked(runBulkOperationQuery).mockResolvedValue({bulkOperation: operation, userErrors: []}) + vi.mocked(watchBulkOperation).mockResolvedValue(operation) + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'results.jsonl') + await writeFile(path, 'previous export\n') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(BulkExecute, [ + '--query', + 'query { products { edges { node { id } } } }', + '--watch', + '--json', + '--output-file', + path, + ]) + expect(JSON.parse(stdout())).toEqual({path, format: 'jsonl'}) + await expect(readFile(path)).resolves.toBe('') + expect(fetch).not.toHaveBeenCalled() + expect(process.exitCode).toBe(originalExitCode) + assertDiagnosticEvents(stderr(), 'Starting bulk operation.') + }) + }) +}) + +test('without JSON mode, watched execution preserves the exact native stdout bytes', async () => { + const authenticationProgress = vi.spyOn(ui, 'renderSingleTask').mockImplementation(async ({task}) => task(vi.fn())) + const operation = bulkOperation({status: 'COMPLETED', url: 'https://example.com/results.jsonl'}) + const results = '{"id":"1"}\n{"id":"2"}\n' + vi.mocked(runBulkOperationQuery).mockResolvedValue({bulkOperation: operation, userErrors: []}) + vi.mocked(watchBulkOperation).mockResolvedValue(operation) + mockDownloadResults(results) + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(BulkExecute, ['--query', 'query { shop { name } }', '--watch']) + expect(stdout()).toBe(`${results}\n`) + expect(stderr()).toContain('Starting bulk operation.') + expect(stderr()).toContain('Bulk operation succeeded:') + expect(process.exitCode).toBe(originalExitCode) + }) + } finally { + authenticationProgress.mockRestore() + } +}) + +test.each([ + {name: 'cancel', Command: BulkCancel}, + {name: 'execute', Command: BulkExecute}, +])('$name user errors reach the fatal envelope without a success document', async ({Command}) => { + const userErrors = [{field: ['id'], message: 'Operation rejected'}] + vi.mocked(cancelBulkOperationRequest).mockResolvedValue({bulkOperation: null, userErrors}) + vi.mocked(runBulkOperationQuery).mockResolvedValue({bulkOperation: null, userErrors}) + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const argv = Command === BulkCancel ? ['--id', '123'] : ['--query', 'query { shop { name } }'] + try { + await runCommand(Command, [...argv, '--json']) + expect.fail('The command must reject the operation.') + } catch (error) { + if (!(error instanceof AbortError)) throw error + expect(error).toMatchObject({details: {userErrors}}) + expect(stdout()).toBe('') + await handler(error) + } + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', details: {userErrors}}}) + assertDiagnosticEvents(stderr(), Command === BulkCancel ? 'Canceling bulk operation.' : 'Starting bulk operation.') + }) +}) test.each([ {name: 'status', Command: BulkStatus, schema: bulkOperationStatusJsonOutputSchema}, {name: 'cancel', Command: BulkCancel, schema: cancelBulkOperationJsonOutputSchema}, + {name: 'execute', Command: BulkExecute, schema: executeBulkOperationJsonOutputSchema}, ] as const)('exposes the schema, JSON flag, and help for $name', ({Command, schema}) => { expect(Command.jsonOutputSchema).toBe(schema) expect(Command.flags.json).toBeDefined() 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 a41807f0e0b..af659dc5ba0 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,33 +1,27 @@ -import {executeBulkOperation} from './execute-bulk-operation.js' +import {renderExecuteBulkOperationResult} from './execute-result.js' +import {logBulkOperationStart} from './progress.js' +import {executeBulkOperation as runBulkOperation, prepareBulkOperation} from './execute-bulk-operation.js' import {resolveApiVersion, createAdminSessionAsApp} from '../graphql/common.js' -import {OrganizationApp, OrganizationSource, OrganizationStore} from '../../models/organization.js' +import {OrganizationApp, Organization, OrganizationSource, OrganizationStore} from '../../models/organization.js' import { runBulkOperationQuery, runBulkOperationMutation, watchBulkOperation, shortBulkOperationPoll, - downloadBulkOperationResults, BULK_OPERATIONS_MIN_API_VERSION, type BulkOperation, } from '@shopify/cli-kit/node/api/bulk-operations' -import {renderSuccess, renderWarning, renderError, renderInfo} from '@shopify/cli-kit/node/ui' -import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' +import {renderSingleTask, renderSuccess, renderWarning, renderError, renderInfo} from '@shopify/cli-kit/node/ui' +import {fetch} from '@shopify/cli-kit/node/http' import {inTemporaryDirectory, writeFile, readFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -vi.mock('@shopify/cli-kit/node/api/bulk-operations', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/bulk-operations') - return { - ...actual, - runBulkOperationQuery: vi.fn(), - runBulkOperationMutation: vi.fn(), - watchBulkOperation: vi.fn(), - shortBulkOperationPoll: vi.fn(), - downloadBulkOperationResults: vi.fn(), - } -}) +vi.mock('@shopify/cli-kit/node/api/bulk-operations/run-query') +vi.mock('@shopify/cli-kit/node/api/bulk-operations/run-mutation') +vi.mock('@shopify/cli-kit/node/api/bulk-operations/watch-bulk-operation') +vi.mock('@shopify/cli-kit/node/http') vi.mock('../graphql/common.js', async () => { const actual = await vi.importActual('../graphql/common.js') return { @@ -37,24 +31,14 @@ vi.mock('../graphql/common.js', async () => { validateMutationStore: vi.fn(), } }) -vi.mock('@shopify/cli-kit/node/ui', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/ui') - return { - ...actual, - renderSingleTask: vi.fn(async ({task}) => task()), - renderSuccess: vi.fn(), - renderWarning: vi.fn(), - renderError: vi.fn(), - renderInfo: vi.fn(), - } -}) -vi.mock('@shopify/cli-kit/node/session', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/session') - return { - ...actual, - ensureAuthenticatedAdminAsApp: vi.fn(), - } -}) +vi.mock('@shopify/cli-kit/node/ui') + +function mockDownloadResults(results: string): void { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: async () => results, + } as Awaited>) +} describe('executeBulkOperation', () => { const mockOrganization = { @@ -95,7 +79,7 @@ describe('executeBulkOperation', () => { beforeEach(() => { vi.mocked(createAdminSessionAsApp).mockResolvedValue(mockAdminSession) - vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(vi.fn())) vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation) vi.mocked(resolveApiVersion).mockResolvedValue(BULK_OPERATIONS_MIN_API_VERSION) }) @@ -104,6 +88,36 @@ describe('executeBulkOperation', () => { mockAndCaptureOutput().clear() }) + test('returns downloaded native JSONL without selecting an output channel or file', async () => { + const operation = {...createdBulkOperation, status: 'COMPLETED' as const, url: 'https://example.com/results.jsonl'} + const results = '{"id":"1"}\n' + vi.mocked(runBulkOperationQuery).mockResolvedValue({bulkOperation: operation, userErrors: []}) + vi.mocked(watchBulkOperation).mockResolvedValue(operation) + mockDownloadResults(results) + const output = mockAndCaptureOutput() + + await expect( + runBulkOperation({ + adminSession: mockAdminSession, + version: '2026-01', + query: 'query { shop { name } }', + variablesJsonl: undefined, + watch: true, + }), + ).resolves.toEqual({ + store: storeFqdn, + apiVersion: '2026-01', + query: 'query { shop { name } }', + operation, + userErrors: [], + watchAborted: false, + results, + }) + expect(output.output()).toBe('') + expect(renderSuccess).not.toHaveBeenCalled() + expect(renderInfo).not.toHaveBeenCalled() + }) + test('runs query operation when GraphQL document starts with query', async () => { const query = 'query { products { edges { node { id } } } }' const mockResponse: Awaited> = { @@ -112,7 +126,7 @@ describe('executeBulkOperation', () => { } vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -135,7 +149,7 @@ describe('executeBulkOperation', () => { } vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -159,7 +173,7 @@ describe('executeBulkOperation', () => { } vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -185,7 +199,7 @@ describe('executeBulkOperation', () => { } vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -208,7 +222,7 @@ describe('executeBulkOperation', () => { userErrors: [], } vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -234,7 +248,7 @@ describe('executeBulkOperation', () => { } vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -270,7 +284,7 @@ describe('executeBulkOperation', () => { } vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -294,7 +308,7 @@ describe('executeBulkOperation', () => { 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' await expect( - executeBulkOperation({ + executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -313,7 +327,7 @@ describe('executeBulkOperation', () => { const variables = ['{"input":{"id":"gid://shopify/Product/123"}}'] await expect( - executeBulkOperation({ + executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -334,7 +348,7 @@ describe('executeBulkOperation', () => { const query = 'query { products { edges { node { id } } } }' await expect( - executeBulkOperation({ + executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -352,7 +366,7 @@ describe('executeBulkOperation', () => { const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' await expect( - executeBulkOperation({ + executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -379,11 +393,11 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) - vi.mocked(downloadBulkOperationResults).mockResolvedValue( + mockDownloadResults( '{"data":{"products":{"edges":[{"node":{"id":"gid://shopify/Product/123"}}],"userErrors":[]}},"__lineNumber":0}', ) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -423,7 +437,7 @@ describe('executeBulkOperation', () => { return runningOperation }) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -435,7 +449,7 @@ describe('executeBulkOperation', () => { headline: `Bulk operation ${createdBulkOperation.id} is still running in the background.`, body: ['Monitor its progress with:\n', {command: expect.stringContaining('shopify app bulk status')}], }) - expect(downloadBulkOperationResults).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() }) test('uses quickWatchBulkOperation (not watchBulkOperation) when watch flag is false', async () => { @@ -448,7 +462,7 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -475,7 +489,7 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) vi.mocked(shortBulkOperationPoll).mockResolvedValue(runningOperation) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -507,7 +521,7 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) vi.mocked(shortBulkOperationPoll).mockResolvedValue(completedOperation) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -521,7 +535,7 @@ describe('executeBulkOperation', () => { body: ['Monitor its progress with:\n', {command: expect.stringContaining('shopify app bulk status')}], }), ) - expect(downloadBulkOperationResults).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() }) test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)( @@ -541,7 +555,7 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) vi.mocked(shortBulkOperationPoll).mockResolvedValue(errorOperation) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -578,9 +592,9 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) - vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsContent) + mockDownloadResults(resultsContent) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -614,9 +628,9 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) - vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsContent) + mockDownloadResults(resultsContent) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -644,7 +658,7 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) vi.mocked(watchBulkOperation).mockResolvedValue(finishedOperation) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -669,7 +683,7 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) await expect( - executeBulkOperation({ + executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -702,9 +716,9 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) - vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors) + mockDownloadResults(resultsWithErrors) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -738,9 +752,9 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) - vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithoutErrors) + mockDownloadResults(resultsWithoutErrors) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -776,9 +790,9 @@ describe('executeBulkOperation', () => { vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) - vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors) + mockDownloadResults(resultsWithErrors) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -806,7 +820,7 @@ describe('executeBulkOperation', () => { } vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -829,7 +843,7 @@ describe('executeBulkOperation', () => { } vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) - await executeBulkOperation({ + await executeAndPresentBulkOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, @@ -843,3 +857,18 @@ describe('executeBulkOperation', () => { }) }) }) + +async function executeAndPresentBulkOperation( + input: Parameters[0] & {organization: Organization; outputFile?: string}, +) { + const {organization, outputFile, ...options} = input + const prepared = await prepareBulkOperation(options) + logBulkOperationStart( + 'Starting bulk operation.', + {organization, remoteApp: input.remoteApp, storeFqdn: input.store.shopDomain, version: prepared.version}, + 'text', + ) + const result = await runBulkOperation(prepared) + await renderExecuteBulkOperationResult(result, {format: 'text', watch: input.watch ?? false, outputFile}) + return result +} 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 7eb5c158830..ce1fe3a2f4f 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,45 +1,27 @@ -import { - createAdminSessionAsApp, - formatOperationInfo, - resolveApiVersion, - validateMutationStore, - isMutation, -} from '../graphql/common.js' -import {OrganizationApp, Organization, OrganizationStore} from '../../models/organization.js' +import {createAdminSessionAsApp, resolveApiVersion, validateMutationStore, isMutation} from '../graphql/common.js' +import {OrganizationApp, OrganizationStore} from '../../models/organization.js' import { runBulkOperationQuery, runBulkOperationMutation, watchBulkOperation, shortBulkOperationPoll, - formatBulkOperationStatus, downloadBulkOperationResults, - resultsContainUserErrors, - extractBulkOperationId, BULK_OPERATIONS_MIN_API_VERSION, - type BulkOperation, } from '@shopify/cli-kit/node/api/bulk-operations' -import { - renderSuccess, - renderInfo, - renderError, - renderWarning, - renderSingleTask, - TokenItem, -} from '@shopify/cli-kit/node/ui' -import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' -import {AbortError, BugError} from '@shopify/cli-kit/node/error' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' import {AbortController} from '@shopify/cli-kit/node/abort' -import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs' +import {readFile, fileExists} from '@shopify/cli-kit/node/fs' +import type {ExecuteBulkOperationResult} from './types.js' interface ExecuteBulkOperationInput { - organization: Organization remoteApp: OrganizationApp store: OrganizationStore query: string variables?: string[] variableFile?: string watch?: boolean - outputFile?: string version?: string } @@ -60,18 +42,8 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string } } -export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { - const { - organization, - remoteApp, - store, - query, - variables, - variableFile, - outputFile, - watch = false, - version: userSpecifiedVersion, - } = input +export async function prepareBulkOperation(input: ExecuteBulkOperationInput) { + const {remoteApp, store, query, variables, variableFile, watch = false, version: userSpecifiedVersion} = input const {adminSession, version} = await renderSingleTask({ title: outputContent`Authenticating`, @@ -92,137 +64,48 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr validateBulkOperationVariables(query, variablesJsonl) validateMutationStore(query, store) - renderInfo({ - headline: 'Starting bulk operation.', - body: [ - { - list: { - items: formatOperationInfo({organization, remoteApp, storeFqdn: store.shopDomain, version}), - }, - }, - ], - }) + return {adminSession, version, query, variablesJsonl, watch} +} - const bulkOperationResponse = isMutation(query) +export async function executeBulkOperation( + input: Awaited>, +): Promise { + const {adminSession, version, query, variablesJsonl, watch} = input + const response = isMutation(query) ? await runBulkOperationMutation({adminSession, query, variablesJsonl, version}) : await runBulkOperationQuery({adminSession, query, version}) - if (bulkOperationResponse?.userErrors?.length) { - renderError({ - headline: 'Error creating bulk operation.', - body: { - list: { - items: bulkOperationResponse.userErrors.map((error) => - error.field ? `${error.field.join('.')}: ${error.message}` : error.message, - ), - }, - }, - }) - return + if (response?.userErrors?.length || !response?.bulkOperation) { + return { + store: adminSession.storeFqdn, + apiVersion: version, + query, + operation: response?.bulkOperation ?? null, + userErrors: response?.userErrors ?? [], + watchAborted: false, + } } - const createdOperation = bulkOperationResponse?.bulkOperation - if (createdOperation) { - if (watch) { - const abortController = new AbortController() - const operation = await watchBulkOperation(adminSession, createdOperation.id, abortController.signal, () => + const abortController = new AbortController() + const operation = watch + ? await watchBulkOperation(adminSession, response.bulkOperation.id, abortController.signal, () => abortController.abort(), ) + : await shortBulkOperationPoll(adminSession, response.bulkOperation.id) - if (abortController.signal.aborted) { - renderInfo({ - headline: `Bulk operation ${operation.id} is still running in the background.`, - body: statusCommandHelpMessage(operation.id), - }) - } else { - await renderBulkOperationResult(operation, outputFile) - } - } else { - const operation = await shortBulkOperationPoll(adminSession, createdOperation.id) - const errorStatuses = ['FAILED', 'CANCELED', 'EXPIRED'] - if (errorStatuses.includes(operation.status)) { - await renderBulkOperationResult(operation, outputFile) - } else { - renderSuccess({ - headline: 'Bulk operation is running.', - body: statusCommandHelpMessage(operation.id), - customSections: [{body: [{list: {items: [outputContent`ID: ${outputToken.cyan(operation.id)}`.value]}}]}], - }) - } - } - } else { - renderWarning({ - headline: 'Bulk operation not created successfully.', - body: 'This is an unexpected error. Please try again later.', - }) - throw new BugError('Bulk operation response returned null with no error message.') - } -} + const results = + watch && !abortController.signal.aborted && operation.status === 'COMPLETED' && operation.url + ? await downloadBulkOperationResults(operation.url) + : undefined -async function renderBulkOperationResult(operation: BulkOperation, outputFile?: string): Promise { - const headline = formatBulkOperationStatus(operation).value - const items = [ - outputContent`ID: ${outputToken.cyan(operation.id)}`.value, - outputContent`Status: ${outputToken.yellow(operation.status)}`.value, - outputContent`Created at: ${outputToken.gray(String(operation.createdAt))}`.value, - ...(operation.completedAt - ? [outputContent`Completed at: ${outputToken.gray(String(operation.completedAt))}`.value] - : []), - ] - - const customSections = [{body: [{list: {items}}]}] - - switch (operation.status) { - case 'CREATED': - renderSuccess({ - headline: 'Bulk operation started.', - body: statusCommandHelpMessage(operation.id), - customSections, - }) - break - case 'RUNNING': - renderSuccess({ - headline: 'Bulk operation is running.', - body: statusCommandHelpMessage(operation.id), - customSections, - }) - break - case 'COMPLETED': - if (operation.url) { - const results = await downloadBulkOperationResults(operation.url) - const hasUserErrors = resultsContainUserErrors(results) - - if (outputFile) { - await writeFile(outputFile, results) - } else { - outputResult(results) - } - - if (hasUserErrors) { - renderWarning({ - headline: 'Bulk operation completed with errors.', - body: outputFile - ? `Results written to ${outputFile}. Check file for error details.` - : 'Check results for error details.', - customSections, - }) - } else { - renderSuccess({ - headline, - body: outputFile ? [`Results written to ${outputFile}`] : undefined, - customSections, - }) - } - } else { - renderSuccess({headline, customSections}) - } - break - case 'CANCELED': - case 'CANCELING': - case 'EXPIRED': - case 'FAILED': - renderError({headline, customSections}) - break + return { + store: adminSession.storeFqdn, + apiVersion: version, + query, + operation, + userErrors: [], + watchAborted: abortController.signal.aborted, + results, } } @@ -243,10 +126,3 @@ function validateBulkOperationVariables(graphqlOperation: string, variablesJsonl ) } } - -function statusCommandHelpMessage(operationId: string): TokenItem { - return [ - 'Monitor its progress with:\n', - {command: `shopify app bulk status --id=${extractBulkOperationId(operationId)}`}, - ] -} diff --git a/packages/app/src/cli/services/bulk-operations/execute-result.ts b/packages/app/src/cli/services/bulk-operations/execute-result.ts new file mode 100644 index 00000000000..e7f9ae2b1ae --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/execute-result.ts @@ -0,0 +1,181 @@ +import {executeBulkOperationJsonOutputSchema, type ExecuteBulkOperationResult} from './types.js' +import {bulkOperationJsonContext, toBulkOperationJson} from './json.js' +import {resultsContainMutationErrors} from './mutation-errors.js' +import { + formatBulkOperationStatus, + resultsContainUserErrors, + extractBulkOperationId, +} from '@shopify/cli-kit/node/api/bulk-operations' +import {renderSuccess, renderInfo, renderError, renderWarning, type TokenItem} from '@shopify/cli-kit/node/ui' +import {outputContent, outputToken, outputResult, outputWarn} from '@shopify/cli-kit/node/output' +import {AbortError, BugError} from '@shopify/cli-kit/node/error' +import {writeFile} from '@shopify/cli-kit/node/fs' +import {resolvePath} from '@shopify/cli-kit/node/path' +import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' + +export async function renderExecuteBulkOperationResult( + result: ExecuteBulkOperationResult, + options: {format: 'text' | 'json'; watch: boolean; outputFile?: string}, +): Promise { + const {format, watch, outputFile} = options + const {operation, userErrors, watchAborted, results} = result + if (!operation && userErrors.length === 0) { + const warning = { + headline: 'Bulk operation not created successfully.', + body: 'This is an unexpected error. Please try again later.', + } + if (format === 'json') outputWarn(`${warning.headline} ${warning.body}`) + else renderWarning(warning) + throw new BugError('Bulk operation response returned null with no error message.') + } + + if (format === 'json') { + if (userErrors.length || (operation && ['FAILED', 'CANCELED', 'EXPIRED'].includes(operation.status))) { + const error = new AbortError('Bulk operation failed.') + error.details = { + ...bulkOperationJsonContext(result), + operation: operation ? toBulkOperationJson(operation) : null, + userErrors, + } + throw error + } + if (!operation) throw new BugError('Bulk operation response returned no operation.') + + const partial = + operation.type === 'MUTATION' && results !== undefined && resultsContainMutationErrors(results, result.query) + let status: 'success' | 'partial' | 'cancelled' = partial ? 'partial' : 'success' + if (watchAborted) status = 'cancelled' + if (partial) process.exitCode = 1 + const hasEmptyQueryResult = + watch && + !watchAborted && + operation.type === 'QUERY' && + operation.status === 'COMPLETED' && + String(operation.objectCount) === '0' && + !operation.url + const fileResults = results ?? (hasEmptyQueryResult ? '' : undefined) + if (outputFile && fileResults === undefined && !watchAborted) { + throw new AbortError('No results are available to write to the output file.') + } + if (outputFile && fileResults !== undefined) { + const path = resolvePath(outputFile) + await writeFile(path, fileResults) + outputResult(executeBulkOperationJsonOutputSchema.encode({path, format: 'jsonl'})) + } else { + outputResult( + executeBulkOperationJsonOutputSchema.encode({ + ...bulkOperationJsonContext(result), + status, + ...(watchAborted ? {reason: 'watch-aborted' as const} : {}), + operation: toBulkOperationJson(operation), + ...(results === undefined ? {} : {resultsJsonl: results}), + }), + ) + } + return + } + + if (userErrors.length) { + renderError({ + headline: 'Error creating bulk operation.', + body: { + list: { + items: userErrors.map((error) => + error.field ? `${error.field.join('.')}: ${error.message}` : error.message, + ), + }, + }, + }) + return + } + if (!operation) return + + if (watchAborted) { + renderInfo({ + headline: `Bulk operation ${operation.id} is still running in the background.`, + body: statusCommandHelpMessage(operation.id), + }) + } else if (watch || ['FAILED', 'CANCELED', 'EXPIRED'].includes(operation.status)) { + await renderBulkOperationResult(operation, results, outputFile) + } else { + renderSuccess({ + headline: 'Bulk operation is running.', + body: statusCommandHelpMessage(operation.id), + customSections: [{body: [{list: {items: [outputContent`ID: ${outputToken.cyan(operation.id)}`.value]}}]}], + }) + } +} + +async function renderBulkOperationResult( + operation: BulkOperation, + results?: string, + outputFile?: string, +): Promise { + const headline = formatBulkOperationStatus(operation).value + const items = [ + outputContent`ID: ${outputToken.cyan(operation.id)}`.value, + outputContent`Status: ${outputToken.yellow(operation.status)}`.value, + outputContent`Created at: ${outputToken.gray(String(operation.createdAt))}`.value, + ...(operation.completedAt + ? [outputContent`Completed at: ${outputToken.gray(String(operation.completedAt))}`.value] + : []), + ] + + const customSections = [{body: [{list: {items}}]}] + + switch (operation.status) { + case 'CREATED': + renderSuccess({ + headline: 'Bulk operation started.', + body: statusCommandHelpMessage(operation.id), + customSections, + }) + break + case 'RUNNING': + renderSuccess({ + headline: 'Bulk operation is running.', + body: statusCommandHelpMessage(operation.id), + customSections, + }) + break + case 'COMPLETED': + if (results === undefined) { + renderSuccess({headline, customSections}) + } else { + const hasUserErrors = resultsContainUserErrors(results) + + if (outputFile) await writeFile(outputFile, results) + else outputResult(results) + + if (hasUserErrors) { + renderWarning({ + headline: 'Bulk operation completed with errors.', + body: outputFile + ? `Results written to ${outputFile}. Check file for error details.` + : 'Check results for error details.', + customSections, + }) + } else { + renderSuccess({ + headline, + body: outputFile ? [`Results written to ${outputFile}`] : undefined, + customSections, + }) + } + } + break + case 'CANCELED': + case 'CANCELING': + case 'EXPIRED': + case 'FAILED': + renderError({headline, customSections}) + break + } +} + +function statusCommandHelpMessage(operationId: string): TokenItem { + return [ + 'Monitor its progress with:\n', + {command: `shopify app bulk status --id=${extractBulkOperationId(operationId)}`}, + ] +} diff --git a/packages/app/src/cli/services/bulk-operations/fixtures/aliased-mutation.graphql b/packages/app/src/cli/services/bulk-operations/fixtures/aliased-mutation.graphql new file mode 100644 index 00000000000..c6e55a88076 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/fixtures/aliased-mutation.graphql @@ -0,0 +1,16 @@ +mutation Update($input: ProductInput!) { + __typename + ...Operation +} + +fragment Operation on Mutation { + update: productUpdate(input: $input) { + ...Errors + } +} + +fragment Errors on ProductUpdatePayload { + errors: userErrors { + message + } +} diff --git a/packages/app/src/cli/services/bulk-operations/fixtures/cyclic-mutation-fragment.graphql b/packages/app/src/cli/services/bulk-operations/fixtures/cyclic-mutation-fragment.graphql new file mode 100644 index 00000000000..a9841f08873 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/fixtures/cyclic-mutation-fragment.graphql @@ -0,0 +1,12 @@ +mutation { + productUpdate { + ...Errors + } +} + +fragment Errors on ProductUpdatePayload { + errors: userErrors { + message + } + ...Errors +} diff --git a/packages/app/src/cli/services/bulk-operations/mutation-errors.test.ts b/packages/app/src/cli/services/bulk-operations/mutation-errors.test.ts new file mode 100644 index 00000000000..7a805da84bc --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/mutation-errors.test.ts @@ -0,0 +1,64 @@ +import {resultsContainMutationErrors} from './mutation-errors.js' +import {expect, test} from 'vitest' +import {readFile} from '@shopify/cli-kit/node/fs' +import {fileURLToPath} from 'node:url' + +test.each([ + { + query: 'mutation { productUpdate { userErrors { message } } }', + data: {productUpdate: {userErrors: [{message: 'Rejected'}]}}, + }, + { + query: 'mutation { update: productUpdate { errors: userErrors { message } } }', + data: {update: {errors: [{message: 'Rejected'}]}}, + }, + { + query: + 'mutation { ... on Mutation { update: productUpdate { ... on ProductUpdatePayload { errors: userErrors { message } } } } }', + data: {update: {errors: [{message: 'Rejected'}]}}, + }, +])('detects selected mutation errors in $query', ({query, data}) => { + expect(resultsContainMutationErrors(`${JSON.stringify({data})}\n`, query)).toBe(true) +}) + +test('detects aliases in named fragments after a root metadata field', async () => { + const query = await readFile(fileURLToPath(new URL('./fixtures/aliased-mutation.graphql', import.meta.url))) + const results = JSON.stringify({data: {__typename: 'Mutation', update: {errors: [{message: 'Rejected'}]}}}) + expect(resultsContainMutationErrors(results, query)).toBe(true) +}) + +test('ignores ordinary fields aliased as errors or userErrors', () => { + const query = 'mutation { metafieldsSet { errors: metafields { id } userErrors: metafields { id } } }' + const results = JSON.stringify({ + data: {metafieldsSet: {errors: [{id: '1'}], userErrors: [{id: '1'}]}}, + }) + expect(resultsContainMutationErrors(results, query)).toBe(false) +}) + +test.each([{userErrors: []}, {userErrors: null}, {userErrors: undefined}])( + 'does not report missing or empty mutation errors: $userErrors', + ({userErrors}) => { + const query = 'mutation { productUpdate { errors: userErrors { message } } }' + expect(resultsContainMutationErrors(JSON.stringify({data: {productUpdate: {errors: userErrors}}}), query)).toBe( + false, + ) + }, +) + +test('detects top-level GraphQL errors', () => { + expect( + resultsContainMutationErrors('{"errors":[{"message":"Rejected"}]}\n', 'mutation { productUpdate { id } }'), + ).toBe(true) +}) + +test('keeps scanning after blank or malformed lines', () => { + const query = 'mutation { productUpdate { errors: userErrors { message } } }' + const results = '\n{incomplete\n{"data":{"productUpdate":{"errors":[{"message":"Rejected"}]}}}\n' + expect(resultsContainMutationErrors(results, query)).toBe(true) +}) + +test('bounds traversal of cyclic fragments', async () => { + const query = await readFile(fileURLToPath(new URL('./fixtures/cyclic-mutation-fragment.graphql', import.meta.url))) + const results = '{"data":{"productUpdate":{"errors":[{"message":"Rejected"}]}}}\n' + expect(resultsContainMutationErrors(results, query)).toBe(true) +}) diff --git a/packages/app/src/cli/services/bulk-operations/mutation-errors.ts b/packages/app/src/cli/services/bulk-operations/mutation-errors.ts new file mode 100644 index 00000000000..1193383150b --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/mutation-errors.ts @@ -0,0 +1,61 @@ +import {getOperationAST, Kind, parse, type FieldNode, type FragmentDefinitionNode, type SelectionSetNode} from 'graphql' + +export function resultsContainMutationErrors(results: string, query: string): boolean { + const document = parse(query) + const fragments = new Map( + document.definitions + .filter((definition) => definition.kind === Kind.FRAGMENT_DEFINITION) + .map((fragment) => [fragment.name.value, fragment]), + ) + const operation = getOperationAST(document) + const errorFields = + operation?.operation === 'mutation' + ? selectedFields(operation.selectionSet, fragments).flatMap((mutation) => + mutation.selectionSet + ? selectedFields(mutation.selectionSet, fragments) + .filter((field) => field.name.value === 'userErrors') + .map((field): [string, string] => [ + mutation.alias?.value ?? mutation.name.value, + field.alias?.value ?? field.name.value, + ]) + : [], + ) + : [] + + return results + .trim() + .split('\n') + .filter((line) => line.trim().length > 0) + .some((line) => { + let result + try { + result = JSON.parse(line) + } catch (error) { + if (error instanceof SyntaxError) return false + throw error + } + if (Array.isArray(result.errors) && result.errors.length > 0) return true + return errorFields.some(([mutation, field]) => { + const errors = result.data?.[mutation]?.[field] + return Array.isArray(errors) && errors.length > 0 + }) + }) +} + +function selectedFields( + selectionSet: SelectionSetNode, + fragments: Map, + activeFragments: string[] = [], +): FieldNode[] { + return selectionSet.selections.flatMap((selection): FieldNode[] => { + if (selection.kind === Kind.FIELD) return [selection] + if (selection.kind === Kind.INLINE_FRAGMENT) { + return selectedFields(selection.selectionSet, fragments, activeFragments) + } + const name = selection.name.value + const fragment = fragments.get(name) + return fragment && !activeFragments.includes(name) + ? selectedFields(fragment.selectionSet, fragments, [...activeFragments, name]) + : [] + }) +} diff --git a/packages/app/src/cli/services/bulk-operations/result.test.ts b/packages/app/src/cli/services/bulk-operations/result.test.ts index 24bcc561013..f50781fa349 100644 --- a/packages/app/src/cli/services/bulk-operations/result.test.ts +++ b/packages/app/src/cli/services/bulk-operations/result.test.ts @@ -1,10 +1,15 @@ import {testBulkOperation} from './bulk-operation.test-data.js' +import {renderExecuteBulkOperationResult} from './execute-result.js' import {renderCancelBulkOperationResult} from './cancel-result.js' import {renderBulkOperationStatusResult} from './status-result.js' +import {executeBulkOperationJsonOutputSchema} from './types.js' import {afterEach, beforeEach, expect, test, vi} from 'vitest' -import {renderError} from '@shopify/cli-kit/node/ui' -import {AbortError} from '@shopify/cli-kit/node/error' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath, relativePath, cwd} from '@shopify/cli-kit/node/path' +import {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui' +import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import type {ExecuteBulkOperationResult} from './types.js' vi.mock('@shopify/cli-kit/node/ui') @@ -18,16 +23,203 @@ afterEach(() => { process.exitCode = originalExitCode }) -function completedResult() { +function completedResult(query = 'query { products { edges { node { id } } } }'): ExecuteBulkOperationResult { return { + query, + store: 'shop.myshopify.com', + apiVersion: '2026-01', operation: testBulkOperation({ status: 'COMPLETED', completedAt: '2026-09-01T00:01:00Z', url: 'https://example.com/results.jsonl', }), + userErrors: [], + watchAborted: false, + results: '{"id":"1"}\n{"id":"2"}\n', } } +function expectedOperation() { + return { + gid: 'gid://shopify/BulkOperation/123', + type: 'QUERY', + status: 'COMPLETED', + objectCount: '2', + createdAt: '2026-09-01T00:00:00Z', + completedAt: '2026-09-01T00:01:00Z', + errorCode: null, + url: 'https://example.com/results.jsonl', + partialDataUrl: null, + } +} + +test.each([2, '900719925474099312345'])('serializes the count exactly as a decimal string: %s', async (objectCount) => { + const result = completedResult() + const output = mockAndCaptureOutput() + await renderExecuteBulkOperationResult( + {...result, operation: {...result.operation!, objectCount}}, + {format: 'json', watch: true}, + ) + expect(JSON.parse(output.output()).operation.objectCount).toBe(String(objectCount)) +}) + +test('keeps upstream enum values extensible and CLI-owned structures strict', () => { + const result = {storeDomain: null, apiVersion: null, status: 'success' as const, operation: expectedOperation()} + expect(() => + executeBulkOperationJsonOutputSchema.validate({ + ...result, + operation: { + ...result.operation, + status: 'NEW_SERVER_STATUS', + errorCode: 'NEW_SERVER_ERROR', + type: 'NEW_SERVER_TYPE', + }, + }), + ).not.toThrow() + expect(() => executeBulkOperationJsonOutputSchema.validate({...result, accidentalField: true})).toThrow() + expect(() => executeBulkOperationJsonOutputSchema.validate({...result, status: 'NEW_CLI_STATUS'})).toThrow() + expect(() => + executeBulkOperationJsonOutputSchema.validate({...result, operation: {...result.operation, id: '123'}}), + ).toThrow() +}) + +test.each(['', '{"id":"1"}\n{"id":"2"}\n'])( + 'writes exact native JSONL and emits only an absolute file receipt: %j', + async (results) => { + const output = mockAndCaptureOutput() + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'results.jsonl') + await renderExecuteBulkOperationResult( + {...completedResult(), results}, + { + format: 'json', + watch: true, + outputFile: relativePath(cwd(), path), + }, + ) + await expect(readFile(path)).resolves.toBe(results) + expect(JSON.parse(output.info())).toEqual({path, format: 'jsonl'}) + expect(renderSuccess).not.toHaveBeenCalled() + }) + }, +) + +test.each([ + '{"data":{"productUpdate":{"userErrors":[{"message":"Rejected"}]}}}\n', + '{"errors":[{"message":"Variable input has an unrecognizable field"}]}\n', +])('reports downloaded mutation errors as a partial result with a nonzero exit: %s', async (results) => { + const output = mockAndCaptureOutput() + const result = completedResult('mutation { productUpdate { userErrors { message } } }') + await renderExecuteBulkOperationResult( + {...result, operation: {...result.operation!, type: 'MUTATION'}, results}, + {format: 'json', watch: true}, + ) + expect(JSON.parse(output.output())).toMatchObject({status: 'partial', resultsJsonl: results}) + expect(process.exitCode).toBe(1) +}) + +test.each([ + '{"data":{"productUpdate":{"userErrors":[{"message":"Rejected"}]}}}\n', + '{"errors":[{"message":"Variable input has an unrecognizable field"}]}\n', +])('writes downloaded mutation errors to a file and exits nonzero: %s', async (results) => { + const output = mockAndCaptureOutput() + const result = completedResult('mutation { productUpdate { userErrors { message } } }') + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'results.jsonl') + await renderExecuteBulkOperationResult( + {...result, operation: {...result.operation!, type: 'MUTATION'}, results}, + {format: 'json', watch: true, outputFile: path}, + ) + await expect(readFile(path)).resolves.toBe(results) + expect(JSON.parse(output.info())).toEqual({path, format: 'jsonl'}) + expect(process.exitCode).toBe(1) + }) +}) + +test.each([ + '{"id":"gid://shopify/Product/1","errors":["summer"]}\n', + '{"id":"gid://shopify/Product/1","data":{"preview":{"userErrors":null}}}\n', +])('does not interpret query aliases as mutation errors: %s', async (results) => { + const output = mockAndCaptureOutput() + const result = {...completedResult(), results} + await renderExecuteBulkOperationResult(result, {format: 'json', watch: true}) + expect(JSON.parse(output.output())).toMatchObject({status: 'success', resultsJsonl: results}) + expect(process.exitCode).toBe(originalExitCode) + output.clear() + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'results.jsonl') + await renderExecuteBulkOperationResult(result, {format: 'json', watch: true, outputFile: path}) + await expect(readFile(path)).resolves.toBe(results) + expect(JSON.parse(output.info())).toEqual({path, format: 'jsonl'}) + expect(process.exitCode).toBe(originalExitCode) + }) +}) + +test('does not emit a file receipt when no results were downloaded', async () => { + await expect( + renderExecuteBulkOperationResult( + {...completedResult(), results: undefined}, + { + format: 'json', + watch: true, + outputFile: './results.jsonl', + }, + ), + ).rejects.toThrow('No results are available') + expect(mockAndCaptureOutput().output()).toBe('') +}) + +test('reports cancellation of watching without losing the running operation', async () => { + const output = mockAndCaptureOutput() + const result = completedResult() + await renderExecuteBulkOperationResult( + { + ...result, + watchAborted: true, + results: undefined, + operation: {...result.operation!, status: 'RUNNING'}, + }, + {format: 'json', watch: true}, + ) + expect(JSON.parse(output.output())).toMatchObject({ + status: 'cancelled', + reason: 'watch-aborted', + operation: {status: 'RUNNING'}, + }) + expect(process.exitCode).toBe(originalExitCode) +}) + +test('routes creation user errors through the global fatal-error path', async () => { + const userErrors = [{message: 'Invalid query', field: ['query']}] + await expect( + renderExecuteBulkOperationResult( + {query: 'query { shop { name } }', operation: null, userErrors, watchAborted: false}, + { + format: 'json', + watch: false, + }, + ), + ).rejects.toMatchObject({details: {userErrors}}) + expect(mockAndCaptureOutput().output()).toBe('') +}) + +test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)( + 'fails execution when the requested operation is %s', + async (status) => { + const result = completedResult() + await expect( + renderExecuteBulkOperationResult( + {...result, operation: {...result.operation!, status}}, + { + format: 'json', + watch: true, + }, + ), + ).rejects.toThrow(AbortError) + expect(mockAndCaptureOutput().output()).toBe('') + }, +) + test('routes cancellation user errors through the global fatal-error path', () => { expect(() => renderCancelBulkOperationResult( @@ -41,7 +233,7 @@ test('routes cancellation user errors through the global fatal-error path', () = test('does not invent a list operation type or claim completeness for a capped list', () => { const output = mockAndCaptureOutput() - const {type: _type, ...operation} = completedResult().operation + const {type: _type, ...operation} = completedResult().operation! renderBulkOperationStatusResult({operations: Array.from({length: 100}, () => operation)}, 'json') const result = JSON.parse(output.output()) expect(result.operations[0]).not.toHaveProperty('type') @@ -49,6 +241,55 @@ test('does not invent a list operation type or claim completeness for a capped l expect(result.pageInfo).toEqual({hasNextPage: null}) }) +test('outputs native JSONL in text mode', async () => { + const output = mockAndCaptureOutput() + const result = completedResult() + await renderExecuteBulkOperationResult(result, {format: 'text', watch: true}) + expect(output.output()).toBe(result.results) + expect(renderSuccess).toHaveBeenCalledWith(expect.objectContaining({headline: expect.stringContaining('succeeded')})) +}) + +test('preserves an existing text-mode output file when result scanning fails', async () => { + const results = '{"id":"gid://shopify/Product/1","data":{"preview":{"userErrors":null}}}\n' + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'results.jsonl') + await writeFile(path, 'previous export\n') + await expect( + renderExecuteBulkOperationResult( + {...completedResult(), results}, + {format: 'text', watch: true, outputFile: path}, + ), + ).rejects.toThrow(TypeError) + await expect(readFile(path)).resolves.toBe('previous export\n') + expect(mockAndCaptureOutput().info()).toBe('') + }) +}) + +test('preserves text-mode creation user errors', async () => { + await renderExecuteBulkOperationResult( + {query: 'query { shop { name } }', operation: null, userErrors: [{message: 'Invalid query'}], watchAborted: false}, + {format: 'text', watch: false}, + ) + expect(renderError).toHaveBeenCalledWith({ + headline: 'Error creating bulk operation.', + body: {list: {items: ['Invalid query']}}, + }) + expect(process.exitCode).toBe(originalExitCode) +}) + +test('preserves the warning and bug failure when creation returns neither an operation nor errors', async () => { + await expect( + renderExecuteBulkOperationResult( + {query: 'query { shop { name } }', operation: null, userErrors: [], watchAborted: false}, + {format: 'text', watch: false}, + ), + ).rejects.toThrow(BugError) + expect(renderWarning).toHaveBeenCalledWith({ + headline: 'Bulk operation not created successfully.', + body: 'This is an unexpected error. Please try again later.', + }) +}) + test('keeps missing cancellation nonfatal in text mode', () => { renderCancelBulkOperationResult({operation: null, userErrors: []}, '123', 'text') expect(renderError).toHaveBeenCalledWith( diff --git a/packages/app/src/cli/services/bulk-operations/types.test.ts b/packages/app/src/cli/services/bulk-operations/types.test.ts index 636f6db7e1a..5d4ba8c8824 100644 --- a/packages/app/src/cli/services/bulk-operations/types.test.ts +++ b/packages/app/src/cli/services/bulk-operations/types.test.ts @@ -1,4 +1,8 @@ -import {bulkOperationStatusJsonOutputSchema, cancelBulkOperationJsonOutputSchema} from './types.js' +import { + bulkOperationStatusJsonOutputSchema, + cancelBulkOperationJsonOutputSchema, + executeBulkOperationJsonOutputSchema, +} from './types.js' import {toBulkOperationJson} from './json.js' import {testBulkOperation} from './bulk-operation.test-data.js' import {expect, test} from 'vitest' @@ -15,15 +19,15 @@ test('status result rejects unselected fields and lists without pagination metad expect(bulkOperationStatusJsonOutputSchema.jsonSchema.definitions?.BulkOperation).toBeDefined() }) -test.each([{name: 'cancel', schema: cancelBulkOperationJsonOutputSchema}])( - '$name uses the common operation projection', - ({schema}) => { - const value = { - storeDomain: 'shop.myshopify.com', - apiVersion: '2026-01', - status: 'success' as const, - operation: toBulkOperationJson(testBulkOperation()), - } - expect(JSON.parse(schema.encode(value))).toEqual(value) - }, -) +test.each([ + {name: 'cancel', schema: cancelBulkOperationJsonOutputSchema}, + {name: 'execute', schema: executeBulkOperationJsonOutputSchema}, +])('$name uses the common operation projection', ({schema}) => { + const value = { + storeDomain: 'shop.myshopify.com', + apiVersion: '2026-01', + status: 'success' as const, + operation: toBulkOperationJson(testBulkOperation()), + } + expect(JSON.parse(schema.encode(value))).toEqual(value) +}) diff --git a/packages/app/src/cli/services/bulk-operations/types.ts b/packages/app/src/cli/services/bulk-operations/types.ts index 2796f336f6b..71074c50034 100644 --- a/packages/app/src/cli/services/bulk-operations/types.ts +++ b/packages/app/src/cli/services/bulk-operations/types.ts @@ -44,3 +44,33 @@ export interface CancelBulkOperationResult extends BulkOperationContext { operation: BulkOperation | null userErrors: {field?: string[] | null; message: string}[] } + +const ExecuteBulkOperationResultSchema = BulkOperationContextSchema.extend({ + status: zod.enum(['success', 'partial', 'cancelled']), + reason: zod.literal('watch-aborted').optional(), + operation: BulkOperationSchema, + resultsJsonl: zod.string().optional().describe('Downloaded results in their native JSONL format.'), +}) + +const BulkOperationFileSchema = zod + .object({ + path: zod.string().describe('The absolute path of the downloaded results file.'), + format: zod.literal('jsonl'), + }) + .strict() + +export const executeBulkOperationJsonOutputSchema = defineJsonOutputSchema({ + name: 'ExecuteBulkOperationResult', + schema: zod.union([ExecuteBulkOperationResultSchema, BulkOperationFileSchema]), + definitions: { + BulkOperation: BulkOperationSchema, + ExecuteBulkOperationResult: ExecuteBulkOperationResultSchema, + BulkOperationFile: BulkOperationFileSchema, + }, +}) + +export interface ExecuteBulkOperationResult extends CancelBulkOperationResult { + query: string + watchAborted: boolean + results?: string +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 68e94d93dc5..2c0a7c34249 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -382,15 +382,19 @@ Execute bulk operations. ``` USAGE - $ shopify app bulk execute [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--no-input] [--output-file --watch] [--path ] [-q ] [--query-file ] [--reset | ] [-s - ] [--variable-file | -v ...] [--verbose] [--version ] + $ shopify app bulk execute [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] + [--no-color] [--no-input] [--output-file --watch] [--path ] [-q ] [--query-file ] + [--reset | ] [-s ] [--variable-file | -v ...] [--verbose] [--version ] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -q, --query= The GraphQL query or mutation to run as a bulk operation. [env: SHOPIFY_FLAG_QUERY] @@ -469,6 +473,176 @@ DESCRIPTION Use "`bulk status`" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status) to check the status of your bulk operations. + + With `--watch`, completed results are written as JSONL to stdout or `--output-file`. With `--json`, stdout contains + one result object with operation details and downloaded JSONL in `resultsJsonl`. With `--json --output-file`, stdout + contains only an absolute file receipt with `path` and `format`. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ExecuteBulkOperationResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ExecuteBulkOperationResult" + }, + { + "$ref": "#/definitions/BulkOperationFile" + } + ], + "title": "ExecuteBulkOperationResult", + "definitions": { + "BulkOperation": { + "type": "object", + "properties": { + "gid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/BulkOperation\\/[^/]+$", + "description": "The Shopify global ID of a bulk operation." + }, + "type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "errorCode": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "completedAt": { + "anyOf": [ + { + "$ref": "#/definitions/BulkOperation/properties/createdAt" + }, + { + "type": "null" + } + ], + "description": "A UTC ISO 8601 instant with whole seconds and the Z timezone marker." + }, + "objectCount": { + "type": "string", + "pattern": "^\\d+$", + "description": "A nonnegative decimal count, without loss of integer precision." + }, + "url": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "partialDataUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gid", + "type", + "status", + "errorCode", + "createdAt", + "completedAt", + "objectCount", + "url", + "partialDataUrl" + ], + "additionalProperties": false + }, + "ExecuteBulkOperationResult": { + "type": "object", + "properties": { + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[^.]+\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] + }, + "apiVersion": { + "type": [ + "string", + "null" + ], + "description": "The API version selected for the request." + }, + "status": { + "type": "string", + "enum": [ + "success", + "partial", + "cancelled" + ] + }, + "reason": { + "type": "string", + "const": "watch-aborted" + }, + "operation": { + "$ref": "#/definitions/BulkOperation" + }, + "resultsJsonl": { + "type": "string", + "description": "Downloaded results in their native JSONL format." + } + }, + "required": [ + "storeDomain", + "apiVersion", + "status", + "operation" + ], + "additionalProperties": false + }, + "BulkOperationFile": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute path of the downloaded results file." + }, + "format": { + "type": "string", + "const": "jsonl" + } + }, + "required": [ + "path", + "format" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app bulk status` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 117f7c094c1..cdafeed204b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -241,8 +241,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Executes an Admin API GraphQL query or mutation on the specified store, as a bulk operation. Mutations are only allowed on dev stores.\n\n Bulk operations allow you to process large amounts of data asynchronously. Learn more about \"bulk query operations\" (https://shopify.dev/docs/api/usage/bulk-operations/queries) and \"bulk mutation operations\" (https://shopify.dev/docs/api/usage/bulk-operations/imports).\n\n Use \"`bulk status`\" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status) to check the status of your bulk operations.", - "descriptionWithMarkdown": "Executes an Admin API GraphQL query or mutation on the specified store, as a bulk operation. Mutations are only allowed on dev stores.\n\n Bulk operations allow you to process large amounts of data asynchronously. Learn more about [bulk query operations](https://shopify.dev/docs/api/usage/bulk-operations/queries) and [bulk mutation operations](https://shopify.dev/docs/api/usage/bulk-operations/imports).\n\n Use [`bulk status`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status) to check the status of your bulk operations.", + "description": "Executes an Admin API GraphQL query or mutation on the specified store, as a bulk operation. Mutations are only allowed on dev stores.\n\n Bulk operations allow you to process large amounts of data asynchronously. Learn more about \"bulk query operations\" (https://shopify.dev/docs/api/usage/bulk-operations/queries) and \"bulk mutation operations\" (https://shopify.dev/docs/api/usage/bulk-operations/imports).\n\n Use \"`bulk status`\" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status) to check the status of your bulk operations.\n\n With `--watch`, completed results are written as JSONL to stdout or `--output-file`. With `--json`, stdout contains one result object with operation details and downloaded JSONL in `resultsJsonl`. With `--json --output-file`, stdout contains only an absolute file receipt with `path` and `format`.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ExecuteBulkOperationResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ExecuteBulkOperationResult\"\n },\n {\n \"$ref\": \"#/definitions/BulkOperationFile\"\n }\n ],\n \"title\": \"ExecuteBulkOperationResult\",\n \"definitions\": {\n \"BulkOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/BulkOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify global ID of a bulk operation.\"\n },\n \"type\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"errorCode\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"createdAt\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"completedAt\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/BulkOperation/properties/createdAt\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"objectCount\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"A nonnegative decimal count, without loss of integer precision.\"\n },\n \"url\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"partialDataUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"gid\",\n \"type\",\n \"status\",\n \"errorCode\",\n \"createdAt\",\n \"completedAt\",\n \"objectCount\",\n \"url\",\n \"partialDataUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ExecuteBulkOperationResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[^.]+\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"apiVersion\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The API version selected for the request.\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\",\n \"cancelled\"\n ]\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"watch-aborted\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/BulkOperation\"\n },\n \"resultsJsonl\": {\n \"type\": \"string\",\n \"description\": \"Downloaded results in their native JSONL format.\"\n }\n },\n \"required\": [\n \"storeDomain\",\n \"apiVersion\",\n \"status\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n \"BulkOperationFile\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"The absolute path of the downloaded results file.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"const\": \"jsonl\"\n }\n },\n \"required\": [\n \"path\",\n \"format\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Executes an Admin API GraphQL query or mutation on the specified store, as a bulk operation. Mutations are only allowed on dev stores.\n\n Bulk operations allow you to process large amounts of data asynchronously. Learn more about [bulk query operations](https://shopify.dev/docs/api/usage/bulk-operations/queries) and [bulk mutation operations](https://shopify.dev/docs/api/usage/bulk-operations/imports).\n\n Use [`bulk status`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status) to check the status of your bulk operations.\n\n With `--watch`, completed results are written as JSONL to stdout or `--output-file`. With `--json`, stdout contains one result object with operation details and downloaded JSONL in `resultsJsonl`. With `--json --output-file`, stdout contains only an absolute file receipt with `path` and `format`.", "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -274,6 +274,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index d496cf68c71..89bf2941481 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -3,7 +3,6 @@ const commandExceptions = [ // Existing finite commands awaiting migration. Remove entries as they adopt typed JSON output. // Do not add new finite commands to this section. 'packages/app/src/cli/commands/app/build.ts', - 'packages/app/src/cli/commands/app/bulk/execute.ts', 'packages/app/src/cli/commands/app/config/link.ts', 'packages/app/src/cli/commands/app/config/pull.ts', 'packages/app/src/cli/commands/app/config/use.ts',