Skip to content
Merged
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
---

Preserve GraphQL extensions in `store execute --json` results and error details, plus partial data on failure. Query data moves from the root to `.data` when using `--json`, including with `--output-file`.
2 changes: 2 additions & 0 deletions docs/cli/error_handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ Unknown codes are omitted. Upstream API codes stay inside their native `details`
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. Partial response data is available at `error.details.data` when returned alongside GraphQL errors.

```ts
const error = new AbortError('GraphQL operation failed.', JSON.stringify({errors}, null, 2))
Expand Down
27 changes: 21 additions & 6 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9836,15 +9836,30 @@ DESCRIPTION

```json
{
"anyOf": [
{
"type": "object",
"additionalProperties": {}
"type": "object",
"properties": {
"data": {
"anyOf": [
{
"type": "object",
"additionalProperties": {}
},
{
"type": "null"
}
],
"description": "GraphQL response data, preserving query fields and aliases."
},
{
"type": "null"
"extensions": {
"type": "object",
"additionalProperties": {},
"description": "Response-level GraphQL metadata, when returned by the API."
}
},
"required": [
"data"
],
"additionalProperties": false,
"title": "StoreExecuteResult",
"$schema": "http://json-schema.org/draft-07/schema#"
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9799,7 +9799,7 @@
"args": {
},
"customPluginName": "@shopify/store",
"description": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `StoreExecuteResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ],\n \"title\": \"StoreExecuteResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"description": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `StoreExecuteResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"data\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"GraphQL response data, preserving query fields and aliases.\"\n },\n \"extensions\": {\n \"type\": \"object\",\n \"additionalProperties\": {},\n \"description\": \"Response-level GraphQL metadata, when returned by the API.\"\n }\n },\n \"required\": [\n \"data\"\n ],\n \"additionalProperties\": false,\n \"title\": \"StoreExecuteResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"descriptionWithMarkdown": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.",
"examples": [
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\"",
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; data?: unknown}
}

export function isGraphQLClientErrorLike(error: unknown): error is GraphQLClientErrorLike {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,46 @@ 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'}})
Comment thread
gonzaloriestra marked this conversation as resolved.
const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'})

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

expect(result).toEqual({data: {shop: {name: 'Test shop'}}})
expect(result).toStrictEqual({data: {shop: {name: 'Test shop'}}})
expect(adminUrl).toHaveBeenCalledWith(store, '2025-10', context.adminSession)
expect(graphqlRequest).toHaveBeenCalledWith({
query: 'query { shop { name } }',
api: 'Admin',
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},
details,
})

const output = mockAndCaptureOutput()
Expand All @@ -161,7 +195,7 @@ describe('runAdminStoreGraphQLOperation', () => {
type: 'abort',
message: 'GraphQL operation failed.',
tryMessage: JSON.stringify({errors}, null, 2),
details: {errors},
details,
},
})
} finally {
Expand All @@ -170,6 +204,26 @@ describe('runAdminStoreGraphQLOperation', () => {
}
})

test.each([null, {shop: {name: 'Test shop'}, products: null}])(
'preserves GraphQL response data %j in JSON failures',
async (data) => {
const errors = [{message: 'Access denied', path: ['products']}]
const extensions = {cost: {actualQueryCost: 1}}
vi.mocked(graphqlRequest).mockRejectedValue({response: {errors, extensions, data, status: 200}})
const request = await prepareStoreExecuteRequest({
query: 'query { shop { name } products(first: 1) { nodes { id } } }',
})

const error: unknown = await runAdminStoreGraphQLOperation({context, request}).catch((error: unknown) => error)

expect(error).toBeInstanceOf(AbortError)
expect(error).toMatchObject({
tryMessage: JSON.stringify({errors}, null, 2),
details: {errors, extensions, data},
})
},
)

test('maps a 402 ClientError to a store-unavailable AbortError even when the response also carries `errors`', async () => {
// Branch-ordering regression check: a 402 response that also carries GraphQL `errors`
// must surface as the store-unavailable AbortError, not the generic "GraphQL operation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {storeExecuteJsonOutputSchema, type StoreExecuteResult} from './types.js'
import {
classifyAdminApiError,
isGraphQLClientErrorLike,
Expand All @@ -13,7 +14,6 @@ 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 {StoreExecuteResult} from './types.js'

export {ABORTED_FETCH_MESSAGE_FRAGMENTS}

Expand Down Expand Up @@ -83,14 +83,22 @@ export async function runAdminStoreGraphQLOperation(input: {
return await renderSingleTask({
title: outputContent`Executing GraphQL operation`,
task: async () => {
return graphqlRequest<StoreExecuteResult>({
let extensions: unknown
const data = await graphqlRequest<StoreExecuteResult['data']>({
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 storeExecuteJsonOutputSchema.validate({data, ...(extensions === undefined ? {} : {extensions})})
},
renderOptions: {stdout: process.stderr},
})
Expand All @@ -104,8 +112,13 @@ export async function runAdminStoreGraphQLOperation(input: {
if (classified) throw classified

if (isGraphQLClientErrorLike(error) && error.response.errors) {
const details = {errors: error.response.errors}
const graphQLError = new AbortError('GraphQL operation failed.', JSON.stringify(details, null, 2))
const {errors, extensions, data} = error.response
const details = {
Comment thread
gonzaloriestra marked this conversation as resolved.
errors,
...(extensions === undefined ? {} : {extensions}),
...(data === undefined ? {} : {data}),
}
const graphQLError = new AbortError('GraphQL operation failed.', JSON.stringify({errors}, null, 2))
graphQLError.details = details
throw graphQLError
}
Expand Down
59 changes: 44 additions & 15 deletions packages/store/src/cli/services/store/execute/result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,59 +13,88 @@ describe('writeOrOutputStoreExecuteResult', () => {
mockAndCaptureOutput().clear()
})

test('writes results to a file when outputFile is provided', async () => {
test('writes raw query data to a file in text mode', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
const outputPath = joinPath(tmpDir, 'results.json')
const result = {data: {extensions: {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(content).toBe(JSON.stringify(result.data, null, 2))
expect(renderSuccess).toHaveBeenCalledWith({
headline: 'Operation succeeded.',
body: `Results written to ${outputPath}`,
})
})
})

test('writes results to stdout when no outputFile is provided', async () => {
test('writes raw query data to stdout in text mode', async () => {
const output = mockAndCaptureOutput()
const result = {data: {extensions: {name: 'Test shop'}}, extensions: {cost: {actualQueryCost: 2}}}

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

expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'})
expect(output.output()).toContain('Test shop')
expect(output.output()).toBe(JSON.stringify(result.data, null, 2))
})

test('outputs the exact JSON result without a success message', async () => {
const output = mockAndCaptureOutput()
const result = {renamedShop: {name: 'Test shop', optional: null}, products: [], enabled: false}
const result = {data: {shop: {name: 'Test shop'}}, extensions: {cost: {actualQueryCost: 2}}}

await writeOrOutputStoreExecuteResult(result, undefined, 'json')

expect(output.output()).toBe(JSON.stringify(result, null, 2))
expect(renderSuccess).not.toHaveBeenCalled()
})

test.each([{}, null, {zebra: false, apple: null, omitted: undefined, nested: {list: [null, 1, 'value']}}])(
'preserves arbitrary GraphQL fields, order, and omissions: %j',
(result) => {
expect(storeExecuteJsonOutputSchema.encode(result)).toBe(JSON.stringify(result, null, 2))
},
)
test.each([
{},
null,
{zebra: false, apple: null, omitted: undefined, nested: {list: [null, 1, 'value']}},
{extensions: {name: 'Test shop'}, data: false, error: null},
])('preserves arbitrary GraphQL fields, order, and omissions: %j', (data) => {
const result = {data}

test.each([undefined, 'not an object', 42, []])('rejects invalid response roots: %j', (result) => {
expect(storeExecuteJsonOutputSchema.encode(result)).toBe(JSON.stringify(result, null, 2))
})

test.each([undefined, null, 'not an object', 42, []])('rejects invalid response roots: %j', (result) => {
expect(() => storeExecuteJsonOutputSchema.validate(result)).toThrow()
})

test.each([
{},
{shop: {name: 'Test shop'}},
{data: undefined},
{data: 'not an object'},
{data: []},
{data: {}, unexpected: true},
{data: {}, extensions: null},
{data: {}, extensions: []},
{data: {}, extensions: 'not an object'},
])('rejects invalid response envelopes: %j', (result) => {
expect(() => storeExecuteJsonOutputSchema.validate(result)).toThrow()
})

test('documents the response envelope in JSON Schema', () => {
expect(storeExecuteJsonOutputSchema.jsonSchema).toMatchObject({
type: 'object',
properties: {data: expect.any(Object), extensions: expect.any(Object)},
required: ['data'],
additionalProperties: false,
})
})

test('writes an exact JSON file without a result or success message in JSON mode', async () => {
const output = mockAndCaptureOutput()
await inTemporaryDirectory(async (directory) => {
const path = joinPath(directory, 'result.json')
const result = {shop: {name: 'Test shop'}, products: []}
const result = {data: {shop: {name: 'Test shop'}, products: []}, extensions: {cost: {actualQueryCost: 2}}}

await writeOrOutputStoreExecuteResult(result, path, 'json')

Expand Down
3 changes: 2 additions & 1 deletion packages/store/src/cli/services/store/execute/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export async function writeOrOutputStoreExecuteResult(
outputFile?: string,
format: StoreExecuteOutputFormat = 'text',
): Promise<void> {
const serializedResult = storeExecuteJsonOutputSchema.encode(result)
const serializedResult =
format === 'json' ? storeExecuteJsonOutputSchema.encode(result) : JSON.stringify(result.data, null, 2)

if (outputFile) {
await writeFile(outputFile, serializedResult)
Expand Down
14 changes: 12 additions & 2 deletions packages/store/src/cli/services/store/execute/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ import {zod} from '@shopify/cli-kit/node/schema'

export const storeExecuteJsonOutputSchema = defineJsonOutputSchema({
name: 'StoreExecuteResult',
// The caller's query owns the response fields, including aliases and nested values.
schema: zod.record(zod.unknown()).nullable(),
schema: zod
.object({
data: zod
.record(zod.unknown())
.nullable()
.describe('GraphQL response data, preserving query fields and aliases.'),
extensions: zod
.record(zod.unknown())
.optional()
.describe('Response-level GraphQL metadata, when returned by the API.'),
})
.strict(),
})

export type StoreExecuteResult = InferJsonOutputSchema<typeof storeExecuteJsonOutputSchema>
Loading