From fe5ff4c077723c02d9d2a5f167b708cf39d291cd Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 11 Sep 2026 17:16:49 +0200 Subject: [PATCH] Preserve GraphQL response extensions in store execute --- .../store-execute-response-extensions.md | 5 ++ docs/cli/error_handling.md | 2 + .../src/cli/services/store/admin-errors.ts | 2 +- .../store/execute/admin-transport.test.ts | 50 ++++++++++++++++--- .../services/store/execute/admin-transport.ts | 18 +++++-- .../cli/services/store/execute/result.test.ts | 10 ++-- 6 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 .changeset/store-execute-response-extensions.md diff --git a/.changeset/store-execute-response-extensions.md b/.changeset/store-execute-response-extensions.md new file mode 100644 index 00000000000..dd9843e7f33 --- /dev/null +++ b/.changeset/store-execute-response-extensions.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': major +--- + +Return store execute results as {data, extensions} and preserve response extensions in GraphQL error details. diff --git a/docs/cli/error_handling.md b/docs/cli/error_handling.md index 073cb136867..3187f3722f1 100644 --- a/docs/cli/error_handling.md +++ b/docs/cli/error_handling.md @@ -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)) diff --git a/packages/store/src/cli/services/store/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index bc2f224b616..bf7f465463a 100644 --- a/packages/store/src/cli/services/store/admin-errors.ts +++ b/packages/store/src/cli/services/store/admin-errors.ts @@ -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 { diff --git a/packages/store/src/cli/services/store/execute/admin-transport.test.ts b/packages/store/src/cli/services/store/execute/admin-transport.test.ts index 65cb62f20c2..a1d161e79f6 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.test.ts @@ -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}) @@ -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 } }'}) @@ -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() @@ -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 { diff --git a/packages/store/src/cli/services/store/execute/admin-transport.ts b/packages/store/src/cli/services/store/execute/admin-transport.ts index c13c569f9ce..184cb980ef4 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -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} @@ -70,14 +71,22 @@ export async function runAdminStoreGraphQLOperation(input: { return await renderSingleTask({ title: outputContent`Executing GraphQL operation`, task: async () => { - return graphqlRequest({ + let extensions: GraphQLResponse['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}, }) @@ -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 diff --git a/packages/store/src/cli/services/store/execute/result.test.ts b/packages/store/src/cli/services/store/execute/result.test.ts index fbb58b422f1..1c88aa40697 100644 --- a/packages/store/src/cli/services/store/execute/result.test.ts +++ b/packages/store/src/cli/services/store/execute/result.test.ts @@ -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}`, @@ -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 () => {