Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/store-execute-response-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': major
---

Return store execute results as {data, extensions} and preserve response extensions in GraphQL error details.
2 changes: 2 additions & 0 deletions docs/cli/error_handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ When `--json` or `-j` is active, a fatal error writes one document to stdout:
Callers can explicitly attach selected, JSON-serializable data to `error.details`. The renderer includes this field as
data, so consumers do not need to parse display strings. Do not attach a raw error, request, credentials, or other private
properties. For example, `store execute` exposes GraphQL errors at `error.details.errors`, including their error codes.
Response-level GraphQL metadata is available at `error.details.extensions`, including `cost.throttleStatus` when returned
by the API.

```ts
const error = new AbortError('GraphQL operation failed.', JSON.stringify({errors}, null, 2))
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/cli/services/store/admin-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {AbortError} from '@shopify/cli-kit/node/error'
import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session'

interface GraphQLClientErrorLike {
response: {status?: number; errors?: unknown}
response: {status?: number; errors?: unknown; extensions?: unknown}
}

export function isGraphQLClientErrorLike(error: unknown): error is GraphQLClientErrorLike {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('runAdminStoreGraphQLOperation', () => {
})

test('executes the GraphQL request successfully', async () => {
vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}})
vi.mocked(graphqlRequest).mockResolvedValue({shop: {name: 'Test shop'}})
const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'})

const result = await runAdminStoreGraphQLOperation({context, request})
Expand All @@ -70,10 +70,33 @@ describe('runAdminStoreGraphQLOperation', () => {
url: `https://${store}/admin/api/2025-10/graphql.json`,
token: 'token',
variables: undefined,
responseOptions: {handleErrors: false},
responseOptions: {handleErrors: false, onResponse: expect.any(Function)},
})
})

test.each([
{},
{
cost: {
requestedQueryCost: 10,
actualQueryCost: 2,
throttleStatus: {maximumAvailable: 2000, currentlyAvailable: 1998, restoreRate: 100},
},
custom: {value: 'preserved'},
},
])('preserves response extensions %j separately from query data', async (extensions) => {
const data = {extensions: {name: 'Test shop'}}
vi.mocked(graphqlRequest).mockImplementation(async ({responseOptions}) => {
responseOptions?.onResponse?.({data, extensions, status: 200, headers: new Headers()})
return data
})
const request = await prepareStoreExecuteRequest({query: 'query { extensions: shop { name } }'})

const result = await runAdminStoreGraphQLOperation({context, request})

expect(result).toStrictEqual({data, extensions})
})

test('clears stored auth and throws a re-auth error on 401 using the real session scopes', async () => {
vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}})
const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'})
Expand Down Expand Up @@ -137,17 +160,28 @@ describe('runAdminStoreGraphQLOperation', () => {
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, 'preview:placeholder-uuid')
})

test('throws a GraphQL operation error when errors are returned', async () => {
test.each([
undefined,
{},
{
cost: {
requestedQueryCost: 10,
actualQueryCost: 0,
throttleStatus: {maximumAvailable: 2000, currentlyAvailable: 5, restoreRate: 100},
},
},
])('preserves GraphQL errors and response extensions %j in JSON failures', async (extensions) => {
const errors = [{message: 'Field does not exist', extensions: {code: 'UNDEFINED_FIELD'}, path: ['nope']}]
vi.mocked(graphqlRequest).mockRejectedValue({response: {errors}})
const details = {errors, ...(extensions === undefined ? {} : {extensions})}
vi.mocked(graphqlRequest).mockRejectedValue({response: {...details, status: 200, headers: {}}})
const request = await prepareStoreExecuteRequest({query: 'query { nope }'})

const error: unknown = await runAdminStoreGraphQLOperation({context, request}).catch((error: unknown) => error)
expect(error).toBeInstanceOf(AbortError)
expect(error).toMatchObject({
message: 'GraphQL operation failed.',
tryMessage: JSON.stringify({errors}, null, 2),
details: {errors},
tryMessage: JSON.stringify(details, null, 2),
details,
})

const output = mockAndCaptureOutput()
Expand All @@ -160,8 +194,8 @@ describe('runAdminStoreGraphQLOperation', () => {
error: {
type: 'abort',
message: 'GraphQL operation failed.',
tryMessage: JSON.stringify({errors}, null, 2),
details: {errors},
tryMessage: JSON.stringify(details, null, 2),
details,
},
})
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {AdminSession} from '@shopify/cli-kit/node/session'
import type {PreparedStoreExecuteRequest} from './request.js'
import type {AdminStoreGraphQLContext} from './admin-context.js'
import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session'
import type {GraphQLResponse} from '@shopify/cli-kit/node/api/graphql'

export {ABORTED_FETCH_MESSAGE_FRAGMENTS}

Expand Down Expand Up @@ -70,14 +71,22 @@ export async function runAdminStoreGraphQLOperation(input: {
return await renderSingleTask({
title: outputContent`Executing GraphQL operation`,
task: async () => {
return graphqlRequest({
let extensions: GraphQLResponse<unknown>['extensions']
const data = await graphqlRequest({
query: input.request.query,
api: 'Admin',
url: adminUrl(input.context.adminSession.storeFqdn, input.context.version, input.context.adminSession),
token: input.context.adminSession.token,
variables: input.request.parsedVariables,
responseOptions: {handleErrors: false},
responseOptions: {
handleErrors: false,
onResponse: (response) => {
extensions = response.extensions
},
},
})
// Keep response metadata separate from query fields, including aliases named `extensions`.
return {data, ...(extensions === undefined ? {} : {extensions})}
},
renderOptions: {stdout: process.stderr},
})
Expand All @@ -91,7 +100,10 @@ export async function runAdminStoreGraphQLOperation(input: {
if (classified) throw classified

if (isGraphQLClientErrorLike(error) && error.response.errors) {
const details = {errors: error.response.errors}
const details = {
errors: error.response.errors,
...(error.response.extensions === undefined ? {} : {extensions: error.response.extensions}),
}
const graphQLError = new AbortError('GraphQL operation failed.', JSON.stringify(details, null, 2))
graphQLError.details = details
throw graphQLError
Expand Down
10 changes: 6 additions & 4 deletions packages/store/src/cli/services/store/execute/result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,14 @@ describe('writeOrOutputStoreExecuteResult', () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
const outputPath = joinPath(tmpDir, 'results.json')
const result = {data: {shop: {name: 'Test shop'}}, extensions: {cost: {actualQueryCost: 2}}}

// When
await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, outputPath)
await writeOrOutputStoreExecuteResult(result, outputPath)

// Then
const content = await readFile(outputPath)
expect(content).toContain('Test shop')
expect(JSON.parse(content)).toStrictEqual(result)
expect(renderSuccess).toHaveBeenCalledWith({
headline: 'Operation succeeded.',
body: `Results written to ${outputPath}`,
Expand All @@ -70,11 +71,12 @@ describe('writeOrOutputStoreExecuteResult', () => {

test('suppresses success rendering in json mode', async () => {
const output = mockAndCaptureOutput()
const result = {data: {shop: {name: 'Test shop'}}, extensions: {cost: {actualQueryCost: 2}}}

await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json')
await writeOrOutputStoreExecuteResult(result, undefined, 'json')

expect(renderSuccess).not.toHaveBeenCalled()
expect(output.output()).toContain('Test shop')
expect(JSON.parse(output.output())).toStrictEqual(result)
})

test('writes json results to stdout without writing to stderr', async () => {
Expand Down
Loading