From 02769cf689ebdb26cc9ef871567f33adc2abbef7 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 11 Sep 2026 17:16:49 +0200 Subject: [PATCH 1/3] 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 | 8 +-- 6 files changed, 70 insertions(+), 15 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 c17cbede637..919e2ef32b0 100644 --- a/docs/cli/error_handling.md +++ b/docs/cli/error_handling.md @@ -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. ```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 30aecd0d064..a42cc777c32 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 86eabba21ce..59617cb77bc 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -14,6 +14,7 @@ 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' +import type {GraphQLResponse} from '@shopify/cli-kit/node/api/graphql' export {ABORTED_FETCH_MESSAGE_FRAGMENTS} @@ -83,14 +84,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}, }) @@ -104,7 +113,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 cc8023a7ff4..802712a8918 100644 --- a/packages/store/src/cli/services/store/execute/result.test.ts +++ b/packages/store/src/cli/services/store/execute/result.test.ts @@ -17,13 +17,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}`, @@ -42,12 +43,13 @@ describe('writeOrOutputStoreExecuteResult', () => { 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() + expect(JSON.parse(output.output())).toStrictEqual(result) }) test.each([{}, null, {zebra: false, apple: null, omitted: undefined, nested: {list: [null, 1, 'value']}}])( From e768e334851ee416a0e035aa315333e82fdb93fc Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 16 Oct 2026 14:20:53 +0200 Subject: [PATCH 2/3] Define the store execute response envelope schema --- packages/cli/README.md | 27 ++++++++--- packages/cli/oclif.manifest.json | 2 +- .../services/store/execute/admin-transport.ts | 9 ++-- .../cli/services/store/execute/result.test.ts | 45 +++++++++++++++---- .../src/cli/services/store/execute/types.ts | 14 +++++- 5 files changed, 74 insertions(+), 23 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 102b71e50b7..3c9f92ae48d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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#" } diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index f3e1faecad1..200194502cd 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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 } }\"", 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 59617cb77bc..64c130b47c7 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -1,3 +1,4 @@ +import {storeExecuteJsonOutputSchema, type StoreExecuteResult} from './types.js' import { classifyAdminApiError, isGraphQLClientErrorLike, @@ -13,8 +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' -import type {GraphQLResponse} from '@shopify/cli-kit/node/api/graphql' export {ABORTED_FETCH_MESSAGE_FRAGMENTS} @@ -84,8 +83,8 @@ export async function runAdminStoreGraphQLOperation(input: { return await renderSingleTask({ title: outputContent`Executing GraphQL operation`, task: async () => { - let extensions: GraphQLResponse['extensions'] - const data = await graphqlRequest({ + let extensions: unknown + const data = await graphqlRequest({ query: input.request.query, api: 'Admin', url: adminUrl(input.context.adminSession.storeFqdn, input.context.version, input.context.adminSession), @@ -99,7 +98,7 @@ export async function runAdminStoreGraphQLOperation(input: { }, }) // Keep response metadata separate from query fields, including aliases named `extensions`. - return {data, ...(extensions === undefined ? {} : {extensions})} + return storeExecuteJsonOutputSchema.validate({data, ...(extensions === undefined ? {} : {extensions})}) }, renderOptions: {stdout: process.stderr}, }) 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 802712a8918..daa4bd9c51b 100644 --- a/packages/store/src/cli/services/store/execute/result.test.ts +++ b/packages/store/src/cli/services/store/execute/result.test.ts @@ -52,22 +52,49 @@ describe('writeOrOutputStoreExecuteResult', () => { expect(JSON.parse(output.output())).toStrictEqual(result) }) - 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([undefined, 'not an object', 42, []])('rejects invalid response roots: %j', (result) => { + 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} + + 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: []}} await writeOrOutputStoreExecuteResult(result, path, 'json') diff --git a/packages/store/src/cli/services/store/execute/types.ts b/packages/store/src/cli/services/store/execute/types.ts index 9ad9e91ca36..f33d1c98c80 100644 --- a/packages/store/src/cli/services/store/execute/types.ts +++ b/packages/store/src/cli/services/store/execute/types.ts @@ -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 From f5942e714c986d6e0a5531dc60d040612ddbba81 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 19 Oct 2026 15:33:23 +0200 Subject: [PATCH 3/3] Address store execute response review feedback --- .../store-execute-response-extensions.md | 2 +- docs/cli/error_handling.md | 2 +- .../src/cli/services/store/admin-errors.ts | 2 +- .../store/execute/admin-transport.test.ts | 26 ++++++++++++++++--- .../services/store/execute/admin-transport.ts | 8 +++--- .../cli/services/store/execute/result.test.ts | 16 ++++++------ .../src/cli/services/store/execute/result.ts | 3 ++- 7 files changed, 41 insertions(+), 18 deletions(-) diff --git a/.changeset/store-execute-response-extensions.md b/.changeset/store-execute-response-extensions.md index dd9843e7f33..6b110783e22 100644 --- a/.changeset/store-execute-response-extensions.md +++ b/.changeset/store-execute-response-extensions.md @@ -2,4 +2,4 @@ '@shopify/cli': major --- -Return store execute results as {data, extensions} and preserve response extensions in GraphQL error details. +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`. diff --git a/docs/cli/error_handling.md b/docs/cli/error_handling.md index 919e2ef32b0..de310f80b8c 100644 --- a/docs/cli/error_handling.md +++ b/docs/cli/error_handling.md @@ -155,7 +155,7 @@ Callers can explicitly attach selected, JSON-serializable data to `error.details 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. +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)) diff --git a/packages/store/src/cli/services/store/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index bf7f465463a..b41fd32be1d 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; extensions?: unknown} + response: {status?: number; errors?: unknown; extensions?: unknown; data?: 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 a42cc777c32..4990a9085ff 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 @@ -62,7 +62,7 @@ describe('runAdminStoreGraphQLOperation', () => { 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 } }', @@ -180,7 +180,7 @@ describe('runAdminStoreGraphQLOperation', () => { expect(error).toBeInstanceOf(AbortError) expect(error).toMatchObject({ message: 'GraphQL operation failed.', - tryMessage: JSON.stringify(details, null, 2), + tryMessage: JSON.stringify({errors}, null, 2), details, }) @@ -194,7 +194,7 @@ describe('runAdminStoreGraphQLOperation', () => { error: { type: 'abort', message: 'GraphQL operation failed.', - tryMessage: JSON.stringify(details, null, 2), + tryMessage: JSON.stringify({errors}, null, 2), details, }, }) @@ -204,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 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 64c130b47c7..df976d0f5f3 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -112,11 +112,13 @@ export async function runAdminStoreGraphQLOperation(input: { if (classified) throw classified if (isGraphQLClientErrorLike(error) && error.response.errors) { + const {errors, extensions, data} = error.response const details = { - errors: error.response.errors, - ...(error.response.extensions === undefined ? {} : {extensions: error.response.extensions}), + errors, + ...(extensions === undefined ? {} : {extensions}), + ...(data === undefined ? {} : {data}), } - const graphQLError = new AbortError('GraphQL operation failed.', JSON.stringify(details, null, 2)) + const graphQLError = new AbortError('GraphQL operation failed.', JSON.stringify({errors}, 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 daa4bd9c51b..5a7f6090489 100644 --- a/packages/store/src/cli/services/store/execute/result.test.ts +++ b/packages/store/src/cli/services/store/execute/result.test.ts @@ -13,18 +13,18 @@ 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: {shop: {name: 'Test shop'}}, extensions: {cost: {actualQueryCost: 2}}} + const result = {data: {extensions: {name: 'Test shop'}}, extensions: {cost: {actualQueryCost: 2}}} // When await writeOrOutputStoreExecuteResult(result, outputPath) // Then const content = await readFile(outputPath) - expect(JSON.parse(content)).toStrictEqual(result) + expect(content).toBe(JSON.stringify(result.data, null, 2)) expect(renderSuccess).toHaveBeenCalledWith({ headline: 'Operation succeeded.', body: `Results written to ${outputPath}`, @@ -32,13 +32,14 @@ describe('writeOrOutputStoreExecuteResult', () => { }) }) - 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 () => { @@ -49,7 +50,6 @@ describe('writeOrOutputStoreExecuteResult', () => { expect(output.output()).toBe(JSON.stringify(result, null, 2)) expect(renderSuccess).not.toHaveBeenCalled() - expect(JSON.parse(output.output())).toStrictEqual(result) }) test.each([ @@ -94,7 +94,7 @@ describe('writeOrOutputStoreExecuteResult', () => { const output = mockAndCaptureOutput() await inTemporaryDirectory(async (directory) => { const path = joinPath(directory, 'result.json') - const result = {data: {shop: {name: 'Test shop'}, products: []}} + const result = {data: {shop: {name: 'Test shop'}, products: []}, extensions: {cost: {actualQueryCost: 2}}} await writeOrOutputStoreExecuteResult(result, path, 'json') diff --git a/packages/store/src/cli/services/store/execute/result.ts b/packages/store/src/cli/services/store/execute/result.ts index 358e40a0ddd..f024d10379e 100644 --- a/packages/store/src/cli/services/store/execute/result.ts +++ b/packages/store/src/cli/services/store/execute/result.ts @@ -22,7 +22,8 @@ export async function writeOrOutputStoreExecuteResult( outputFile?: string, format: StoreExecuteOutputFormat = 'text', ): Promise { - const serializedResult = storeExecuteJsonOutputSchema.encode(result) + const serializedResult = + format === 'json' ? storeExecuteJsonOutputSchema.encode(result) : JSON.stringify(result.data, null, 2) if (outputFile) { await writeFile(outputFile, serializedResult)