From 93712ef48800dc9e2e46b8cf0a0a5a25384688e1 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:28:19 -0400 Subject: [PATCH] Add multi-mutation bulk execute (bulkOperationRunMutations) with plan files and validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `shopify app bulk execute` to run multi-step bulk mutation plans via the unstable `bulkOperationRunMutations` mutation, alongside the existing single-mutation `bulkOperationRunMutation` path. - Plan-file input (`--plan-file`): an ordered list of {mutationFile, variableFile} operations. Order is significant — cross-operation `$ref:[].` values resolve against earlier operations server-side. - Hybrid routing: a single-operation plan uses `bulkOperationRunMutation`; two or more operations use `bulkOperationRunMutations`. - Client-side validation (on by default, `--no-validate` to skip): introspects the store's Admin schema and validates each operation's document plus a representative JSONL row (required variables + input-field coercion), skipping coercion for `$ref` rows. - Staged uploads for the multi-op path run sequentially (each renders its own progress task; concurrent interactive renders clobber each other). - Generated GraphQL types and oclif manifest / README regenerated. Co-Authored-By: Claude Opus 4.8 --- .../src/cli/commands/app/bulk/execute.test.ts | 44 ++++- .../app/src/cli/commands/app/bulk/execute.ts | 24 ++- packages/app/src/cli/flags.ts | 23 ++- .../execute-bulk-operation.test.ts | 143 ++++++++++++++ .../bulk-operations/execute-bulk-operation.ts | 144 ++++++++++++-- .../cli/services/bulk-operations/plan.test.ts | 127 +++++++++++++ .../src/cli/services/bulk-operations/plan.ts | 130 +++++++++++++ .../generated/bulk-operation-run-mutations.ts | 110 +++++++++++ .../bulk-operations/generated/types.d.ts | 35 ++++ .../bulk-operation-run-mutations.graphql | 20 ++ .../src/public/node/api/bulk-operations.ts | 6 + .../download-bulk-operation-results.ts | 6 + .../api/bulk-operations/run-mutations.test.ts | 93 +++++++++ .../node/api/bulk-operations/run-mutations.ts | 63 ++++++ .../node/api/bulk-operations/stage-file.ts | 8 + .../node/api/bulk-operations/validate.test.ts | 78 ++++++++ .../node/api/bulk-operations/validate.ts | 179 ++++++++++++++++++ packages/cli/README.md | 15 +- packages/cli/oclif.manifest.json | 25 ++- 19 files changed, 1243 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/cli/services/bulk-operations/plan.test.ts create mode 100644 packages/app/src/cli/services/bulk-operations/plan.ts create mode 100644 packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutations.ts create mode 100644 packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutations.graphql create mode 100644 packages/cli-kit/src/public/node/api/bulk-operations/run-mutations.test.ts create mode 100644 packages/cli-kit/src/public/node/api/bulk-operations/run-mutations.ts create mode 100644 packages/cli-kit/src/public/node/api/bulk-operations/validate.test.ts create mode 100644 packages/cli-kit/src/public/node/api/bulk-operations/validate.ts 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..ccee27038be 100644 --- a/packages/app/src/cli/commands/app/bulk/execute.test.ts +++ b/packages/app/src/cli/commands/app/bulk/execute.test.ts @@ -1,6 +1,7 @@ import BulkExecute from './execute.js' import {executeBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js' -import {prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' +import {resolveBulkPlan} from '../../../services/bulk-operations/plan.js' +import {prepareAppStoreContext, prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' import { testAppLinked, testOrganization, @@ -11,6 +12,7 @@ import { import {describe, expect, test, vi, beforeEach} from 'vitest' vi.mock('../../../services/bulk-operations/execute-bulk-operation.js') +vi.mock('../../../services/bulk-operations/plan.js') vi.mock('../../../utilities/execute-command-helpers.js') describe('app bulk execute command', () => { @@ -33,6 +35,18 @@ describe('app bulk execute command', () => { store, query: 'query { shop { name } }', }) + vi.mocked(prepareAppStoreContext).mockResolvedValue({ + appContextResult: { + app, + remoteApp, + developerPlatformClient: remoteApp.developerPlatformClient, + organization, + specifications: [], + project: testProject(), + activeConfig: {} as never, + }, + store, + }) vi.mocked(executeBulkOperation).mockResolvedValue() }) @@ -51,6 +65,7 @@ describe('app bulk execute command', () => { organization, remoteApp, store, + validate: true, query: 'query { shop { name } }', variables: undefined, variableFile: undefined, @@ -78,6 +93,7 @@ describe('app bulk execute command', () => { organization, remoteApp, store, + validate: true, query: 'query { shop { name } }', variables: ['{"key": "value"}'], variableFile: undefined, @@ -102,6 +118,7 @@ describe('app bulk execute command', () => { organization, remoteApp, store, + validate: true, query: 'query { shop { name } }', variables: undefined, variableFile: expect.stringContaining('variables.jsonl'), @@ -109,4 +126,29 @@ describe('app bulk execute command', () => { outputFile: undefined, }) }) + + test('resolves the plan file and calls executeBulkOperation with operations', async () => { + const operations = [ + {mutation: 'mutation A($input: X!) { a(input: $input) { id } }', variablesJsonl: '{"$key":"k","input":{}}'}, + {mutation: 'mutation B($id: ID!) { b(id: $id) { id } }', variablesJsonl: '{"id":"$ref:A[k].id"}'}, + ] + vi.mocked(resolveBulkPlan).mockResolvedValue(operations) + + // When + await BulkExecute.run(['--plan-file', 'plan.json', '--store', 'shop.myshopify.com', '--watch']) + + // Then + expect(resolveBulkPlan).toHaveBeenCalledWith(expect.stringContaining('plan.json')) + expect(prepareAppStoreContext).toHaveBeenCalled() + expect(prepareExecuteContext).not.toHaveBeenCalled() + expect(executeBulkOperation).toHaveBeenCalledWith({ + organization, + remoteApp, + store, + validate: true, + operations, + watch: true, + 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 8c06f1ddeea..78d1e3ca68f 100644 --- a/packages/app/src/cli/commands/app/bulk/execute.ts +++ b/packages/app/src/cli/commands/app/bulk/execute.ts @@ -1,7 +1,8 @@ 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 {prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' +import {resolveBulkPlan} from '../../../services/bulk-operations/plan.js' +import {prepareAppStoreContext, prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' import {globalFlags} from '@shopify/cli-kit/node/cli' export default class BulkExecute extends AppLinkedCommand { @@ -24,6 +25,26 @@ export default class BulkExecute extends AppLinkedCommand { async run(): Promise { const {flags} = await this.parse(BulkExecute) + // A `--plan-file` runs an ordered set of named mutations as one plan (bulkOperationRunMutations). + // Everything else keeps the single-operation path (query or single mutation). + if (flags['plan-file']) { + const operations = await resolveBulkPlan(flags['plan-file']) + const {appContextResult, store} = await prepareAppStoreContext(flags) + + await executeBulkOperation({ + organization: appContextResult.organization, + remoteApp: appContextResult.remoteApp, + store, + operations, + validate: flags.validate, + watch: flags.watch ?? false, + outputFile: flags['output-file'], + ...(flags.version && {version: flags.version}), + }) + + return {app: appContextResult.app} + } + const {query, appContextResult, store} = await prepareExecuteContext(flags) await executeBulkOperation({ @@ -33,6 +54,7 @@ export default class BulkExecute extends AppLinkedCommand { query, variables: flags.variables, variableFile: flags['variable-file'], + validate: flags.validate, watch: flags.watch ?? false, outputFile: flags['output-file'], ...(flags.version && {version: flags.version}), diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index d9a9776380c..a090921297f 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -41,13 +41,21 @@ export const bulkOperationFlags = { description: 'The GraphQL query or mutation to run as a bulk operation.', env: 'SHOPIFY_FLAG_QUERY', required: false, - exactlyOne: ['query', 'query-file'], + exactlyOne: ['query', 'query-file', 'plan-file'], }), 'query-file': Flags.string({ description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", env: 'SHOPIFY_FLAG_QUERY_FILE', parse: async (input) => resolvePath(input), - exactlyOne: ['query', 'query-file'], + exactlyOne: ['query', 'query-file', 'plan-file'], + }), + 'plan-file': Flags.string({ + description: + 'Path to a JSON file describing an ordered plan of named mutation operations to run together as a single bulk operation. Each entry is {"mutationFile": "...", "variableFile": "..."} (inline "mutation"/"variables" are also accepted). Runs via bulkOperationRunMutations.', + env: 'SHOPIFY_FLAG_PLAN_FILE', + parse: async (input) => resolvePath(input), + exactlyOne: ['query', 'query-file', 'plan-file'], + exclusive: ['variables', 'variable-file'], }), variables: Flags.string({ char: 'v', @@ -55,14 +63,14 @@ export const bulkOperationFlags = { 'The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.', env: 'SHOPIFY_FLAG_VARIABLES', multiple: true, - exclusive: ['variable-file'], + exclusive: ['variable-file', 'plan-file'], }), 'variable-file': Flags.string({ description: "Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.", env: 'SHOPIFY_FLAG_VARIABLE_FILE', parse: async (input) => resolvePath(input), - exclusive: ['variables'], + exclusive: ['variables', 'plan-file'], }), store: Flags.string({ char: 's', @@ -70,6 +78,13 @@ export const bulkOperationFlags = { env: 'SHOPIFY_FLAG_STORE', parse: async (input) => normalizeStoreFqdn(input), }), + validate: Flags.boolean({ + description: + "Validate operations against the store's Admin schema before submitting. Enabled by default; use --no-validate to skip.", + env: 'SHOPIFY_FLAG_VALIDATE', + default: true, + allowNo: true, + }), watch: Flags.boolean({ description: 'Wait for bulk operation results before exiting. Defaults to false.', env: 'SHOPIFY_FLAG_WATCH', 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..e77874489da 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 @@ -4,6 +4,8 @@ import {OrganizationApp, OrganizationSource, OrganizationStore} from '../../mode import { runBulkOperationQuery, runBulkOperationMutation, + runBulkOperationMutations, + validateBulkOperations, watchBulkOperation, shortBulkOperationPoll, downloadBulkOperationResults, @@ -23,6 +25,8 @@ vi.mock('@shopify/cli-kit/node/api/bulk-operations', async () => { ...actual, runBulkOperationQuery: vi.fn(), runBulkOperationMutation: vi.fn(), + runBulkOperationMutations: vi.fn(), + validateBulkOperations: vi.fn(), watchBulkOperation: vi.fn(), shortBulkOperationPoll: vi.fn(), downloadBulkOperationResults: vi.fn(), @@ -98,6 +102,7 @@ describe('executeBulkOperation', () => { vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession) vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation) vi.mocked(resolveApiVersion).mockResolvedValue(BULK_OPERATIONS_MIN_API_VERSION) + vi.mocked(validateBulkOperations).mockResolvedValue([]) }) afterEach(() => { @@ -201,6 +206,144 @@ describe('executeBulkOperation', () => { }) }) + test('runs a single-operation plan via runBulkOperationMutation (not the plural mutation)', async () => { + const mutation = 'mutation SetProducts($input: ProductSetInput!) { productSet(input: $input) { product { id } } }' + const mockResponse: Awaited> = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse) + + await executeBulkOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + store: mockStore, + operations: [{mutation, variablesJsonl: '{"input":{}}'}], + }) + + expect(runBulkOperationMutation).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query: mutation, + variablesJsonl: '{"input":{}}', + version: BULK_OPERATIONS_MIN_API_VERSION, + }) + expect(runBulkOperationMutations).not.toHaveBeenCalled() + }) + + test('runs a multi-operation plan via runBulkOperationMutations', async () => { + const operations = [ + { + mutation: 'mutation SetProducts($input: ProductSetInput!) { productSet(input: $input) { product { id } } }', + variablesJsonl: '{"$key":"a","input":{}}', + }, + { + mutation: + 'mutation Publish($id: ID!) { publishablePublish(id: $id, input: []) { publishable { publishedOnCurrentPublication } } }', + variablesJsonl: '{"id":"$ref:SetProducts[a].product.id"}', + }, + ] + const mockResponse: Awaited> = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationMutations).mockResolvedValue(mockResponse) + + await executeBulkOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + store: mockStore, + operations, + }) + + expect(runBulkOperationMutations).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + operations, + version: BULK_OPERATIONS_MIN_API_VERSION, + }) + expect(runBulkOperationMutation).not.toHaveBeenCalled() + }) + + test('renders plan user errors with the error code prefixed', async () => { + const operations = [ + { + mutation: 'mutation A($input: ProductSetInput!) { productSet(input: $input) { product { id } } }', + variablesJsonl: '{"input":{}}', + }, + { + mutation: + 'mutation B($id: ID!) { publishablePublish(id: $id, input: []) { publishable { publishedOnCurrentPublication } } }', + variablesJsonl: '{"id":"$ref:A[missing].product.id"}', + }, + ] + const mockResponse: Awaited> = { + bulkOperation: null, + userErrors: [ + {code: 'UNKNOWN_KEY_REF', field: ['operations', 'B'], message: 'unknown $key'}, + {code: null, field: null, message: 'plain error'}, + ], + } + vi.mocked(runBulkOperationMutations).mockResolvedValue(mockResponse) + + await executeBulkOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + store: mockStore, + operations, + }) + + expect(renderError).toHaveBeenCalledWith({ + headline: 'Error creating bulk operation.', + body: { + list: { + items: ['[UNKNOWN_KEY_REF] operations.B: unknown $key', 'plain error'], + }, + }, + }) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('aborts before submitting when client-side validation fails', async () => { + vi.mocked(validateBulkOperations).mockResolvedValue([ + {label: 'operation 1', errors: ["Field 'bogusField' doesn't exist on type 'ProductSetInput'"]}, + ]) + + await executeBulkOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + store: mockStore, + operations: [ + { + mutation: 'mutation A($input: ProductSetInput!) { productSet(input: $input) { product { id } } }', + variablesJsonl: '{"input":{}}', + }, + ], + }) + + expect(renderError).toHaveBeenCalledWith(expect.objectContaining({headline: 'Bulk operation validation failed.'})) + expect(runBulkOperationMutation).not.toHaveBeenCalled() + expect(runBulkOperationMutations).not.toHaveBeenCalled() + }) + + test('skips client-side validation when validate is false', async () => { + const mockResponse: Awaited> = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse) + + await executeBulkOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + store: mockStore, + query: 'mutation A($input: ProductSetInput!) { productSet(input: $input) { product { id } } }', + variables: ['{"input":{}}'], + validate: false, + }) + + expect(validateBulkOperations).not.toHaveBeenCalled() + expect(runBulkOperationMutation).toHaveBeenCalled() + }) + test('renders running message when bulk operation returns without user errors', async () => { const query = '{ products { edges { node { id } } } }' const mockResponse: Awaited> = { 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..629da91fec1 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 @@ -9,6 +9,8 @@ import {OrganizationApp, Organization, OrganizationStore} from '../../models/org import { runBulkOperationQuery, runBulkOperationMutation, + runBulkOperationMutations, + validateBulkOperations, watchBulkOperation, shortBulkOperationPoll, formatBulkOperationStatus, @@ -17,6 +19,8 @@ import { extractBulkOperationId, BULK_OPERATIONS_MIN_API_VERSION, type BulkOperation, + type BulkMutationPlanOperation, + type OperationToValidate, } from '@shopify/cli-kit/node/api/bulk-operations' import { renderSuccess, @@ -35,9 +39,14 @@ interface ExecuteBulkOperationInput { organization: Organization remoteApp: OrganizationApp store: OrganizationStore - query: string + // Single-operation path: a query or a single mutation (+ its variables). + query?: string variables?: string[] variableFile?: string + // Plan path: an ordered set of named mutations run together (bulkOperationRunMutations). + operations?: BulkMutationPlanOperation[] + // Validate operations against the store's Admin schema before submitting (defaults to true). + validate?: boolean watch?: boolean outputFile?: string version?: string @@ -68,6 +77,8 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr query, variables, variableFile, + operations, + validate = true, outputFile, watch = false, version: userSpecifiedVersion, @@ -87,34 +98,70 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr renderOptions: {stdout: process.stderr}, }) - const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) + // Fail fast: validate operation documents against the store's Admin schema before submitting. + if (validate) { + const validationOperations = await operationsToValidate({query, variables, variableFile, operations}) + if (validationOperations.length > 0) { + const passed = await renderValidation({adminSession, version, operations: validationOperations}) + if (!passed) return + } + } - validateBulkOperationVariables(query, variablesJsonl) - validateMutationStore(query, store) + const infoItems = formatOperationInfo({organization, remoteApp, storeFqdn: store.shopDomain, version}) - renderInfo({ - headline: 'Starting bulk operation.', - body: [ - { - list: { - items: formatOperationInfo({organization, remoteApp, storeFqdn: store.shopDomain, version}), - }, - }, - ], - }) + let bulkOperationResponse + + if (operations) { + // Plan path. Every operation is a mutation; mutations are only allowed on dev stores. + operations.forEach((operation) => validateMutationStore(operation.mutation, store)) + + renderInfo({ + headline: + operations.length > 1 + ? `Starting bulk operation plan (${operations.length} operations).` + : 'Starting bulk operation.', + body: [{list: {items: infoItems}}], + }) + + // Hybrid routing: a single operation uses the existing bulkOperationRunMutation; 2+ operations + // run as one plan via bulkOperationRunMutations. + if (operations.length === 1) { + const [operation] = operations + bulkOperationResponse = await runBulkOperationMutation({ + adminSession, + query: operation!.mutation, + variablesJsonl: operation!.variablesJsonl, + version, + }) + } else { + bulkOperationResponse = await runBulkOperationMutations({adminSession, operations, version}) + } + } else { + // Single-operation path (query or single mutation). + if (query === undefined) { + throw new BugError('executeBulkOperation requires either a query or operations.') + } + const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) + + validateBulkOperationVariables(query, variablesJsonl) + validateMutationStore(query, store) + + renderInfo({ + headline: 'Starting bulk operation.', + body: [{list: {items: infoItems}}], + }) - const bulkOperationResponse = isMutation(query) - ? await runBulkOperationMutation({adminSession, query, variablesJsonl, version}) - : await runBulkOperationQuery({adminSession, query, version}) + bulkOperationResponse = 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, - ), + items: bulkOperationResponse.userErrors.map((error) => formatUserError(error)), }, }, }) @@ -244,6 +291,63 @@ function validateBulkOperationVariables(graphqlOperation: string, variablesJsonl } } +async function operationsToValidate(input: { + query?: string + variables?: string[] + variableFile?: string + operations?: BulkMutationPlanOperation[] +}): Promise { + const {query, variables, variableFile, operations} = input + if (operations) { + return operations.map((operation, index) => ({ + label: `operation ${index + 1}`, + operation: operation.mutation, + representativeRow: firstJsonlRow(operation.variablesJsonl), + })) + } + if (query === undefined) return [] + const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) + return [{label: 'operation', operation: query, representativeRow: firstJsonlRow(variablesJsonl)}] +} + +async function renderValidation(args: Parameters[0]): Promise { + const results = await validateBulkOperations(args) + const failures = results.filter((result) => result.errors.length > 0) + if (failures.length === 0) return true + + renderError({ + headline: 'Bulk operation validation failed.', + body: { + list: { + items: failures.flatMap((failure) => failure.errors.map((error) => `${failure.label}: ${error}`)), + }, + }, + }) + return false +} + +function firstJsonlRow(variablesJsonl?: string): {[key: string]: unknown} | undefined { + if (!variablesJsonl) return undefined + const line = variablesJsonl + .split('\n') + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0) + if (!line) return undefined + try { + const parsed = JSON.parse(line) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : undefined + } catch (error) { + if (error instanceof SyntaxError) return undefined + throw error + } +} + +function formatUserError(error: {code?: string | null; field?: ReadonlyArray | null; message: string}): string { + const code = error.code ? `[${error.code}] ` : '' + const location = error.field && error.field.length > 0 ? `${error.field.join('.')}: ` : '' + return `${code}${location}${error.message}` +} + function statusCommandHelpMessage(operationId: string): TokenItem { return [ 'Monitor its progress with:\n', diff --git a/packages/app/src/cli/services/bulk-operations/plan.test.ts b/packages/app/src/cli/services/bulk-operations/plan.test.ts new file mode 100644 index 00000000000..6a4a23c1409 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/plan.test.ts @@ -0,0 +1,127 @@ +import {resolveBulkPlan} from './plan.js' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, test, expect} from 'vitest' + +const NAMED_MUTATION = 'mutation SetProducts($input: ProductSetInput!) { productSet(input: $input) { product { id } } }' +const SECOND_MUTATION = + 'mutation Publish($id: ID!) { publishablePublish(id: $id, input: []) { publishable { publishedOnCurrentPublication } } }' + +async function writePlan(dir: string, contents: unknown): Promise { + const planPath = joinPath(dir, 'plan.json') + await writeFile(planPath, typeof contents === 'string' ? contents : JSON.stringify(contents)) + return planPath +} + +describe('resolveBulkPlan', () => { + test('resolves ordered operations from mutation/variable files relative to the plan file', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'SetProducts.graphql'), NAMED_MUTATION) + await writeFile(joinPath(dir, 'SetProducts.jsonl'), '{"$key":"a","input":{}}') + await writeFile(joinPath(dir, 'Publish.graphql'), SECOND_MUTATION) + await writeFile(joinPath(dir, 'Publish.jsonl'), '{"id":"$ref:SetProducts[a].product.id"}') + const planPath = await writePlan(dir, [ + {mutationFile: 'SetProducts.graphql', variableFile: 'SetProducts.jsonl'}, + {mutationFile: 'Publish.graphql', variableFile: 'Publish.jsonl'}, + ]) + + const operations = await resolveBulkPlan(planPath) + + expect(operations).toEqual([ + {mutation: NAMED_MUTATION, variablesJsonl: '{"$key":"a","input":{}}'}, + {mutation: SECOND_MUTATION, variablesJsonl: '{"id":"$ref:SetProducts[a].product.id"}'}, + ]) + }) + }) + + test('resolves inline mutation and variables', async () => { + await inTemporaryDirectory(async (dir) => { + const planPath = await writePlan(dir, [ + {mutation: NAMED_MUTATION, variables: ['{"$key":"a","input":{}}', '{"$key":"b","input":{}}']}, + ]) + + const operations = await resolveBulkPlan(planPath) + + expect(operations).toEqual([ + {mutation: NAMED_MUTATION, variablesJsonl: '{"$key":"a","input":{}}\n{"$key":"b","input":{}}'}, + ]) + }) + }) + + test('throws when the plan file does not exist', async () => { + await inTemporaryDirectory(async (dir) => { + await expect(resolveBulkPlan(joinPath(dir, 'missing.json'))).rejects.toThrowError(/Plan file not found/) + }) + }) + + test('throws when the plan file is not valid JSON', async () => { + await inTemporaryDirectory(async (dir) => { + const planPath = await writePlan(dir, 'not json {') + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/not valid JSON/) + }) + }) + + test('throws when the plan is not a non-empty array', async () => { + await inTemporaryDirectory(async (dir) => { + const planPath = await writePlan(dir, []) + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/non-empty JSON array/) + }) + }) + + test('throws when an operation is missing a mutation', async () => { + await inTemporaryDirectory(async (dir) => { + const planPath = await writePlan(dir, [{variables: ['{"input":{}}']}]) + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/operation 1 is missing a mutation/) + }) + }) + + test('throws when an operation sets both mutation and mutationFile', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'm.graphql'), NAMED_MUTATION) + const planPath = await writePlan(dir, [ + {mutation: NAMED_MUTATION, mutationFile: 'm.graphql', variables: ['{"input":{}}']}, + ]) + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/only one of "mutation" or "mutationFile"/) + }) + }) + + test('throws when a mutation is anonymous', async () => { + await inTemporaryDirectory(async (dir) => { + const planPath = await writePlan(dir, [ + {mutation: 'mutation { productSet(input: {}) { product { id } } }', variables: ['{"input":{}}']}, + ]) + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/must be a named mutation/) + }) + }) + + test('throws when an operation is a query, not a mutation', async () => { + await inTemporaryDirectory(async (dir) => { + const planPath = await writePlan(dir, [ + {mutation: 'query GetProducts { products(first: 1) { nodes { id } } }', variables: ['{}']}, + ]) + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/must be a GraphQL mutation/) + }) + }) + + test('throws when an operation has no variables', async () => { + await inTemporaryDirectory(async (dir) => { + const planPath = await writePlan(dir, [{mutation: NAMED_MUTATION}]) + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/is missing variables/) + }) + }) + + test('throws when the variables are empty', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'empty.jsonl'), ' \n') + const planPath = await writePlan(dir, [{mutation: NAMED_MUTATION, variableFile: 'empty.jsonl'}]) + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/has empty variables/) + }) + }) + + test('throws when a referenced mutation file is missing', async () => { + await inTemporaryDirectory(async (dir) => { + const planPath = await writePlan(dir, [{mutationFile: 'nope.graphql', variables: ['{"input":{}}']}]) + await expect(resolveBulkPlan(planPath)).rejects.toThrowError(/not found at .*nope\.graphql/) + }) + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/plan.ts b/packages/app/src/cli/services/bulk-operations/plan.ts new file mode 100644 index 00000000000..3d2fea78d51 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/plan.ts @@ -0,0 +1,130 @@ +import {BulkMutationPlanOperation, isMutation} from '@shopify/cli-kit/node/api/bulk-operations' +import {readFile, fileExists} from '@shopify/cli-kit/node/fs' +import {dirname, isAbsolutePath, joinPath} from '@shopify/cli-kit/node/path' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' + +/** + * A single entry in a `--plan-file`. Each operation provides its mutation document (inline or via a + * file) and its JSONL variables (inline or via a file). Files are resolved relative to the plan + * file's own directory. + */ +interface PlanFileEntry { + mutation?: string + mutationFile?: string + variables?: string[] + variableFile?: string +} + +const NAMED_MUTATION_RE = /(?:^|\W)mutation\s+[A-Za-z_][A-Za-z0-9_]*/ + +/** + * Reads and validates a `--plan-file`, returning the ordered operations for a + * `bulkOperationRunMutations` plan. Order is significant: `$ref` values resolve against operations + * declared earlier in the list. + * + * @param planFile - Absolute path to the plan JSON file. + * @returns The ordered operations, each with its resolved mutation document and JSONL variables. + */ +export async function resolveBulkPlan(planFile: string): Promise { + if (!(await fileExists(planFile))) { + throw new AbortError( + outputContent`Plan file not found at ${outputToken.path(planFile)}. Please check the path and try again.`, + ) + } + + let parsed: unknown + try { + parsed = JSON.parse(await readFile(planFile, {encoding: 'utf8'})) + } catch (error) { + throw new AbortError( + outputContent`Plan file ${outputToken.path(planFile)} is not valid JSON.`, + (error as Error).message, + ) + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new AbortError( + outputContent`Plan file ${outputToken.path(planFile)} must contain a non-empty JSON array of operations.`, + ) + } + + const baseDir = dirname(planFile) + return Promise.all(parsed.map((entry, index) => resolveEntry(entry as PlanFileEntry, index, baseDir))) +} + +async function resolveEntry(entry: PlanFileEntry, index: number, baseDir: string): Promise { + const position = `operation ${index + 1}` + const mutation = await resolveMutation(entry, position, baseDir) + const variablesJsonl = await resolveVariables(entry, position, baseDir) + return {mutation, variablesJsonl} +} + +async function resolveMutation(entry: PlanFileEntry, position: string, baseDir: string): Promise { + if (entry.mutation && entry.mutationFile) { + throw new AbortError(invalidPlanMessage(position, 'set only one of "mutation" or "mutationFile".')) + } + + let mutation: string + if (entry.mutationFile) { + mutation = await readPlanFile(entry.mutationFile, baseDir, position, 'mutationFile') + } else if (entry.mutation) { + mutation = entry.mutation + } else { + throw new AbortError(invalidPlanMessage(position, 'is missing a mutation. Provide "mutation" or "mutationFile".')) + } + + if (!isMutation(mutation)) { + throw new AbortError(invalidPlanMessage(position, 'must be a GraphQL mutation, not a query.')) + } + if (!NAMED_MUTATION_RE.test(mutation)) { + throw new AbortError( + invalidPlanMessage( + position, + "must be a named mutation (for example `mutation SetProducts(...)`). Anonymous operations aren't allowed in a plan.", + ), + ) + } + return mutation +} + +async function resolveVariables(entry: PlanFileEntry, position: string, baseDir: string): Promise { + if (entry.variables && entry.variableFile) { + throw new AbortError(invalidPlanMessage(position, 'set only one of "variables" or "variableFile".')) + } + + let variablesJsonl: string + if (entry.variableFile) { + variablesJsonl = await readPlanFile(entry.variableFile, baseDir, position, 'variableFile') + } else if (entry.variables) { + variablesJsonl = entry.variables.join('\n') + } else { + throw new AbortError(invalidPlanMessage(position, 'is missing variables. Provide "variableFile" or "variables".')) + } + + if (variablesJsonl.trim().length === 0) { + throw new AbortError( + invalidPlanMessage(position, 'has empty variables. Each operation needs at least one JSONL row.'), + ) + } + return variablesJsonl +} + +async function readPlanFile( + relativeOrAbsolute: string, + baseDir: string, + position: string, + field: string, +): Promise { + const path = isAbsolutePath(relativeOrAbsolute) ? relativeOrAbsolute : joinPath(baseDir, relativeOrAbsolute) + if (!(await fileExists(path))) { + throw new AbortError( + outputContent`${position}: ${outputToken.yellow(field)} not found at ${outputToken.path(path)}.`, + ) + } + return readFile(path, {encoding: 'utf8'}) +} + +function invalidPlanMessage(position: string, detail: string): ReturnType { + return outputContent`Invalid plan: ${position} ${detail}` +} diff --git a/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutations.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutations.ts new file mode 100644 index 00000000000..d011109a5ad --- /dev/null +++ b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutations.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type BulkOperationRunMutationsMutationVariables = Types.Exact<{ + operations: Types.BulkMutationOperationInput[] | Types.BulkMutationOperationInput +}> + +export type BulkOperationRunMutationsMutation = { + bulkOperationRunMutations?: { + bulkOperation?: { + type: Types.BulkOperationType + completedAt?: unknown | null + createdAt: unknown + errorCode?: Types.BulkOperationErrorCode | null + id: string + objectCount: unknown + partialDataUrl?: string | null + status: Types.BulkOperationStatus + url?: string | null + } | null + userErrors: { + code?: Types.BulkOperationRunMutationsUserErrorCode | null + field?: string[] | null + message: string + }[] + } | null +} + +export const BulkOperationRunMutations = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'BulkOperationRunMutations'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'operations'}}, + type: { + kind: 'NonNullType', + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: {kind: 'NamedType', name: {kind: 'Name', value: 'BulkMutationOperationInput'}}, + }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperationRunMutations'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'operations'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'operations'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperation'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'type'}}, + {kind: 'Field', name: {kind: 'Name', value: 'completedAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'errorCode'}}, + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'objectCount'}}, + {kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}}, + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'url'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'userErrors'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'code'}}, + {kind: 'Field', name: {kind: 'Name', value: 'field'}}, + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/types.d.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/types.d.ts index 911c90858e9..198b0a5519d 100644 --- a/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/types.d.ts +++ b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/types.d.ts @@ -143,6 +143,20 @@ export type BulkMutationErrorCode = */ | 'OPERATION_IN_PROGRESS'; +/** Represents an error that happens during execution of a bulk mutation. */ +export type BulkMutationOperationInput = { + /** + * A named GraphQL mutation document. The operation name is the plan-unique + * identity of this operation; anonymous operations are rejected. + */ + mutation: Scalars['String']['input']; + /** + * The staged-upload path of the JSONL variables for this operation. One line per + * invocation. Rows may carry a reserved `$key` and `$ref:` string values. + */ + stagedUploadPath: Scalars['String']['input']; +}; + /** Error codes for failed bulk operations. */ export type BulkOperationErrorCode = /** @@ -162,6 +176,27 @@ export type BulkOperationErrorCode = */ | 'TIMEOUT'; +/** Possible error codes that can be returned by `BulkOperationRunMutationsUserError`. */ +export type BulkOperationRunMutationsUserErrorCode = + /** An operation document must be a named mutation. Anonymous operations are not allowed. */ + | 'ANONYMOUS_OPERATION' + /** The operations' `$ref` dependencies form a cycle and can't be ordered. */ + | 'CYCLIC_DEPENDENCY' + /** Each `$key` must be unique within its operation. */ + | 'DUPLICATE_KEY' + /** Operation names must be unique across the plan. */ + | 'DUPLICATE_OPERATION_NAME' + /** A `$ref` path isn't a valid traversal of the referenced operation's selection set. */ + | 'INVALID_OUTPUT_PATH' + /** An operation document is invalid against the Admin schema. */ + | 'SCHEMA_INVALID' + /** A `$ref` resolves to a type that isn't assignable to the consuming variable. */ + | 'TYPE_MISMATCH' + /** A `$ref` references a `$key` that doesn't exist in the referenced operation. */ + | 'UNKNOWN_KEY_REF' + /** A `$ref` references an operation that isn't declared in the plan. */ + | 'UNKNOWN_OPERATION_REF'; + /** The valid values for the status of a bulk operation. */ export type BulkOperationStatus = /** The bulk operation has been canceled. */ diff --git a/packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutations.graphql b/packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutations.graphql new file mode 100644 index 00000000000..276c9f2fedd --- /dev/null +++ b/packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutations.graphql @@ -0,0 +1,20 @@ +mutation BulkOperationRunMutations($operations: [BulkMutationOperationInput!]!) { + bulkOperationRunMutations(operations: $operations) { + bulkOperation { + type + completedAt + createdAt + errorCode + id + objectCount + partialDataUrl + status + url + } + userErrors { + code + field + message + } + } +} diff --git a/packages/cli-kit/src/public/node/api/bulk-operations.ts b/packages/cli-kit/src/public/node/api/bulk-operations.ts index 6653842c5bf..90e743e76b5 100644 --- a/packages/cli-kit/src/public/node/api/bulk-operations.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations.ts @@ -16,6 +16,12 @@ export { export {resolveBulkOperationQuery} from './bulk-operations/query.js' export {runBulkOperationQuery} from './bulk-operations/run-query.js' export {runBulkOperationMutation} from './bulk-operations/run-mutation.js' +export {runBulkOperationMutations, type BulkMutationPlanOperation} from './bulk-operations/run-mutations.js' +export { + validateBulkOperations, + type OperationToValidate, + type OperationValidationResult, +} from './bulk-operations/validate.js' export {stageFile} from './bulk-operations/stage-file.js' export {fetchBulkOperationById, fetchRecentBulkOperations} from './bulk-operations/fetch.js' export {cancelBulkOperationRequest} from './bulk-operations/cancel.js' diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/download-bulk-operation-results.ts b/packages/cli-kit/src/public/node/api/bulk-operations/download-bulk-operation-results.ts index 0453c835e99..0a956873dcf 100644 --- a/packages/cli-kit/src/public/node/api/bulk-operations/download-bulk-operation-results.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/download-bulk-operation-results.ts @@ -10,6 +10,12 @@ import {AbortError} from '../../error.js' export async function downloadBulkOperationResults(url: string): Promise { const response = await fetch(url) + // Guard against a missing response so a failed download surfaces a real error instead of a cryptic + // "Cannot read properties of undefined (reading 'ok')". + if (!response) { + throw new AbortError(`Failed to download bulk operation results: no response from ${url}.`) + } + if (!response.ok) { throw new AbortError(`Failed to download bulk operation results: ${response.statusText}`) } diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/run-mutations.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/run-mutations.test.ts new file mode 100644 index 00000000000..3ff916b4d1b --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/run-mutations.test.ts @@ -0,0 +1,93 @@ +import {runBulkOperationMutations} from './run-mutations.js' +import {stageFile} from './stage-file.js' +import {adminRequestDoc} from '../admin.js' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('../admin.js') +vi.mock('./stage-file.js') + +describe('runBulkOperationMutations', () => { + const mockSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + const parentBulkOperation = { + id: 'gid://shopify/BulkOperation/789', + status: 'CREATED', + errorCode: null, + createdAt: '2024-01-01T00:00:00Z', + objectCount: '0', + url: null, + } + const mockSuccessResponse = { + bulkOperationRunMutations: { + bulkOperation: parentBulkOperation, + userErrors: [], + }, + } + + const operations = [ + { + mutation: 'mutation SetProducts($input: ProductSetInput!) { productSet(input: $input) { product { id } } }', + variablesJsonl: '{"$key":"a","input":{}}', + }, + { + mutation: + 'mutation Publish($id: ID!) { publishablePublish(id: $id, input: []) { publishable { publishedOnCurrentPublication } } }', + variablesJsonl: '{"id":"$ref:SetProducts[a].product.id"}', + }, + ] + + beforeEach(() => { + // Give each staged upload a path derived from its variables so we can assert the mapping. + vi.mocked(stageFile).mockImplementation(async ({variablesJsonl}) => `staged/${variablesJsonl}`) + }) + + test('returns the plan parent bulk operation when the request succeeds', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + + const result = await runBulkOperationMutations({adminSession: mockSession, operations}) + + expect(result?.bulkOperation).toEqual(parentBulkOperation) + expect(result?.userErrors).toEqual([]) + }) + + test('stages every operation and builds an ordered operations argument pairing each mutation with its staged path', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + + await runBulkOperationMutations({adminSession: mockSession, operations}) + + expect(stageFile).toHaveBeenCalledTimes(2) + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + operations: [ + {mutation: operations[0]!.mutation, stagedUploadPath: `staged/${operations[0]!.variablesJsonl}`}, + {mutation: operations[1]!.mutation, stagedUploadPath: `staged/${operations[1]!.variablesJsonl}`}, + ], + }, + }), + ) + }) + + test('forwards a specific API version when provided', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockSuccessResponse) + + await runBulkOperationMutations({adminSession: mockSession, operations, version: '2025-01'}) + + expect(adminRequestDoc).toHaveBeenCalledWith(expect.objectContaining({version: '2025-01'})) + }) + + test('returns user errors when the plan is rejected', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + bulkOperationRunMutations: { + bulkOperation: null, + userErrors: [{code: 'ANONYMOUS_OPERATION', field: ['operations', '0'], message: 'must be named'}], + }, + }) + + const result = await runBulkOperationMutations({adminSession: mockSession, operations}) + + expect(result?.bulkOperation).toBeNull() + expect(result?.userErrors).toEqual([ + {code: 'ANONYMOUS_OPERATION', field: ['operations', '0'], message: 'must be named'}, + ]) + }) +}) diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/run-mutations.ts b/packages/cli-kit/src/public/node/api/bulk-operations/run-mutations.ts new file mode 100644 index 00000000000..6c5bf9d2d04 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/run-mutations.ts @@ -0,0 +1,63 @@ +import {stageFile} from './stage-file.js' +import { + BulkOperationRunMutations as BulkOperationRunMutationsDoc, + BulkOperationRunMutationsMutation, + BulkOperationRunMutationsMutationVariables, +} from '../../../../cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutations.js' +import {adminRequestDoc} from '../admin.js' +import {AdminSession} from '../../session.js' + +/** + * One operation within a `bulkOperationRunMutations` plan: a named GraphQL mutation document plus + * the JSONL variables (one row per invocation) that operation runs over. + */ +export interface BulkMutationPlanOperation { + mutation: string + variablesJsonl: string +} + +interface BulkOperationRunMutationsOptions { + adminSession: AdminSession + operations: BulkMutationPlanOperation[] + version?: string +} + +/** + * Stages each operation's JSONL variables file, then starts a single bulk mutation *plan* on the + * store via `bulkOperationRunMutations`. The returned bulk operation is the plan parent; each + * operation runs as a hidden child through the existing bulk-mutation pipeline. + * + * Operations are staged sequentially, preserving the caller's order (order is significant: `$ref` + * dependencies resolve against earlier operations). Sequential — not parallel — because each upload + * renders its own progress task, and concurrent interactive renders clobber each other (leaving an + * upload with no response). + * + * @param options - The admin session, the ordered operations (each a named mutation + JSONL + * variables), and an optional API version. + * @returns The bulkOperationRunMutations result, including the created plan parent and any user errors. + */ +export async function runBulkOperationMutations( + options: BulkOperationRunMutationsOptions, +): Promise { + const {adminSession, operations, version} = options + + const stagedOperations: {mutation: string; stagedUploadPath: string}[] = [] + for (const {mutation, variablesJsonl} of operations) { + // Sequential on purpose: each upload renders its own progress task and concurrent interactive + // renders clobber each other, leaving an upload with no response. + // eslint-disable-next-line no-await-in-loop + const stagedUploadPath = await stageFile({adminSession, variablesJsonl}) + stagedOperations.push({mutation, stagedUploadPath}) + } + + const response = await adminRequestDoc( + { + query: BulkOperationRunMutationsDoc, + session: adminSession, + variables: {operations: stagedOperations}, + ...(version && {version}), + }, + ) + + return response.bulkOperationRunMutations +} diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/stage-file.ts b/packages/cli-kit/src/public/node/api/bulk-operations/stage-file.ts index 50fedd55a7e..d7250a6ebf8 100644 --- a/packages/cli-kit/src/public/node/api/bulk-operations/stage-file.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/stage-file.ts @@ -126,6 +126,14 @@ async function uploadFileToStagedUrl( renderOptions: {stdout: process.stderr}, }) + // Guard against a missing response so a failed upload surfaces a real error instead of a cryptic + // "Cannot read properties of undefined (reading 'ok')". + if (!uploadResponse) { + throw new AbortError( + `Failed to upload bulk operation variables: no response from the staged upload target (${uploadUrl}).`, + ) + } + if (!uploadResponse.ok) { const errorText = await uploadResponse.text() throw new AbortError(`Failed to upload file to staged URL: ${uploadResponse.statusText}\n${errorText}`) diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/validate.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/validate.test.ts new file mode 100644 index 00000000000..94f4e206cb8 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/validate.test.ts @@ -0,0 +1,78 @@ +import {validateBulkOperations} from './validate.js' +import {adminRequest} from '../admin.js' +import {buildASTSchema, parse, introspectionFromSchema} from 'graphql' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('../admin.js') + +const schema = buildASTSchema( + parse(` + input ProductSetInput { handle: String, count: Int } + type Product { id: ID! } + type ProductSetPayload { product: Product } + type Mutation { productSet(input: ProductSetInput!): ProductSetPayload } + type Query { _skip: Boolean } +`), +) + +const PRODUCT_SET = 'mutation A($input: ProductSetInput!) { productSet(input: $input) { product { id } } }' + +describe('validateBulkOperations', () => { + const adminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} + + beforeEach(() => { + vi.mocked(adminRequest).mockResolvedValue(introspectionFromSchema(schema)) + }) + + async function validateOne(operation: string, representativeRow?: {[key: string]: unknown}): Promise { + const [result] = await validateBulkOperations({ + adminSession, + operations: [{label: 'op', operation, representativeRow}], + }) + return result!.errors + } + + test('returns no errors for a schema-valid operation', async () => { + await expect(validateOne(PRODUCT_SET, {input: {handle: 'x'}})).resolves.toEqual([]) + }) + + test('reports a schema error for an unknown selection field', async () => { + const errors = await validateOne( + 'mutation A($input: ProductSetInput!) { productSet(input: $input) { product { bogus } } }', + {input: {handle: 'x'}}, + ) + expect(errors.join('\n')).toMatch(/Cannot query field "bogus"/) + }) + + test('reports a syntax error for an invalid document', async () => { + expect((await validateOne('mutation A( { productSet }')).join('\n')).toMatch(/GraphQL syntax error/) + }) + + test('reports a missing required variable from the representative row', async () => { + expect((await validateOne(PRODUCT_SET, {})).join('\n')).toMatch(/missing required variable\(s\): \$input/) + }) + + test('reports an input field not defined by type when inlining the representative row', async () => { + expect((await validateOne(PRODUCT_SET, {input: {bogusField: 1}})).join('\n')).toMatch(/is not defined by type/) + }) + + test('skips coercion for rows containing $ref values to avoid false positives', async () => { + // `count` is an Int; a raw $ref string would be a type error if coerced, but $refs resolve server-side. + await expect(validateOne(PRODUCT_SET, {input: {count: '$ref:A[k].n'}})).resolves.toEqual([]) + }) + + test('ignores the reserved $key row field', async () => { + await expect(validateOne(PRODUCT_SET, {$key: 'k', input: {handle: 'x'}})).resolves.toEqual([]) + }) + + test('introspects the schema once for multiple operations', async () => { + await validateBulkOperations({ + adminSession, + operations: [ + {label: 'op1', operation: PRODUCT_SET, representativeRow: {input: {handle: 'a'}}}, + {label: 'op2', operation: PRODUCT_SET, representativeRow: {input: {handle: 'b'}}}, + ], + }) + expect(adminRequest).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/validate.ts b/packages/cli-kit/src/public/node/api/bulk-operations/validate.ts new file mode 100644 index 00000000000..e866a715ea4 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/validate.ts @@ -0,0 +1,179 @@ +import {adminRequest} from '../admin.js' +import {AdminSession} from '../../session.js' +import { + parse, + validate, + visit, + print, + buildClientSchema, + getIntrospectionQuery, + Kind, + type GraphQLSchema, + type IntrospectionQuery, + type DocumentNode, + type ValueNode, +} from 'graphql' + +/** An operation to validate: its GraphQL document and (optionally) a representative variables row. */ +export interface OperationToValidate { + /** Human-readable label used in error output (for example `operation 1 (SetProducts)`). */ + label: string + /** The GraphQL query or mutation document. */ + operation: string + /** The first JSONL variables row, used to check required variables and coercion. */ + representativeRow?: {[key: string]: unknown} +} + +/** The validation outcome for a single operation. */ +export interface OperationValidationResult { + label: string + errors: string[] +} + +/** Options for {@link validateBulkOperations}. */ +export interface ValidateBulkOperationsOptions { + /** Admin session used to introspect the store's schema. */ + adminSession: AdminSession + /** API version to introspect; defaults to the latest supported. */ + version?: string + /** The operations to validate. */ + operations: OperationToValidate[] +} + +// Reserved row keys / executor-injected variables that aren't real GraphQL variables of the document. +const RESERVED_ROW_KEYS = new Set(['$key']) +const EXECUTOR_INJECTED = new Set(['idempotencyKey']) +const NOT_DEFINED = /is not defined by type/ +const MAX_ERRORS_PER_OPERATION = 12 + +/** + * Validates bulk operations client-side against the store's Admin schema before submitting, so + * obvious mistakes surface immediately instead of after a network round-trip. The Admin schema is + * introspected once and reused for every operation. + * + * This mirrors the server's synchronous structural checks for the shape of each mutation document; + * cross-operation `$ref` resolution and row-level validation still happen server-side. + * + * @param options - The admin session, optional API version, and the operations to validate. + * @returns One result per operation, in the same order (empty `errors` means valid). + */ +export async function validateBulkOperations( + options: ValidateBulkOperationsOptions, +): Promise { + const {adminSession, version, operations} = options + const schema = await introspectAdminSchema(adminSession, version) + return operations.map((operation) => ({label: operation.label, errors: validateOperation(operation, schema)})) +} + +async function introspectAdminSchema(adminSession: AdminSession, version?: string): Promise { + const introspection = await adminRequest( + getIntrospectionQuery(), + adminSession, + undefined, + version, + ) + return buildClientSchema(introspection) +} + +function validateOperation(operation: OperationToValidate, schema: GraphQLSchema): string[] { + const errors: string[] = [] + + let ast: DocumentNode + try { + ast = parse(operation.operation) + } catch (error) { + if (error instanceof Error) return [`GraphQL syntax error: ${error.message}`] + throw error + } + + // The document itself must be valid against the schema (unknown fields, bad argument types, etc.). + for (const error of validate(schema, ast)) errors.push(error.message) + + const row = representativeVariables(operation.representativeRow) + if (row) { + const missing = missingRequiredVariables(ast, row).filter((name) => !EXECUTOR_INJECTED.has(name)) + if (missing.length > 0) { + errors.push(`representative row is missing required variable(s): ${missing.map((name) => `$${name}`).join(', ')}`) + } + + // Inlining the row's values and re-validating catches input-field-level errors (a value that + // "is not defined by type"). Rows carrying `$ref:` values are resolved server-side to typed + // values we can't know here, so skip coercion for them to avoid false positives. The document + // already parsed above, so inlining known JSON values and re-parsing does not throw. + if (!containsRef(row)) { + for (const error of validate(schema, parse(inlineVariables(ast, row)))) { + if (NOT_DEFINED.test(error.message)) errors.push(error.message) + } + } + } + + return [...new Set(errors)].slice(0, MAX_ERRORS_PER_OPERATION) +} + +// Strips reserved row keys (e.g. `$key`) so the remainder can be treated as GraphQL variables. +function representativeVariables(row?: {[key: string]: unknown}): {[key: string]: unknown} | undefined { + if (!row) return undefined + const entries = Object.entries(row).filter(([key]) => !RESERVED_ROW_KEYS.has(key)) + return Object.fromEntries(entries) +} + +function containsRef(value: unknown): boolean { + return JSON.stringify(value)?.includes('$ref:') ?? false +} + +function missingRequiredVariables(ast: DocumentNode, variables: {[key: string]: unknown}): string[] { + const missing: string[] = [] + for (const definition of ast.definitions) { + if (definition.kind !== Kind.OPERATION_DEFINITION) continue + for (const variableDefinition of definition.variableDefinitions ?? []) { + const required = + variableDefinition.type.kind === Kind.NON_NULL_TYPE && variableDefinition.defaultValue === undefined + if (required && !(variableDefinition.variable.name.value in variables)) { + missing.push(variableDefinition.variable.name.value) + } + } + } + return missing +} + +function inlineVariables(ast: DocumentNode, variables: {[key: string]: unknown}): string { + const edited = visit(ast, { + OperationDefinition(node) { + const kept = (node.variableDefinitions ?? []).filter( + (variableDefinition) => !(variableDefinition.variable.name.value in variables), + ) + return {...node, variableDefinitions: kept} + }, + Variable(node) { + return node.name.value in variables ? jsonToValueNode(variables[node.name.value]) : undefined + }, + }) + return print(edited) +} + +function jsonToValueNode(value: unknown): ValueNode { + if (value === null || value === undefined) return {kind: Kind.NULL} + if (Array.isArray(value)) return {kind: Kind.LIST, values: value.map(jsonToValueNode)} + switch (typeof value) { + case 'boolean': + return {kind: Kind.BOOLEAN, value} + case 'number': + return Number.isInteger(value) ? {kind: Kind.INT, value: String(value)} : {kind: Kind.FLOAT, value: String(value)} + case 'string': + return {kind: Kind.STRING, value} + case 'object': { + const fields = Object.entries(value as {[key: string]: unknown}).map(([key, fieldValue]) => ({ + kind: Kind.OBJECT_FIELD as const, + name: {kind: Kind.NAME as const, value: key}, + value: jsonToValueNode(fieldValue), + })) + return {kind: Kind.OBJECT, fields} + } + case 'bigint': + case 'symbol': + case 'function': + case 'undefined': + return {kind: Kind.NULL} + } + return {kind: Kind.NULL} +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 9863affaf6e..e91b81bd1af 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -221,8 +221,8 @@ Execute bulk operations. ``` USAGE $ shopify app bulk execute [--auth-alias ] [--client-id | -c ] [--no-color] [--output-file - --watch] [--path ] [-q ] [--query-file ] [--reset | ] [-s ] [--variable-file - | -v ...] [--verbose] [--version ] + --watch] [--path ] [--plan-file | -v ... | --variable-file ] [-q ] + [--query-file ] [--reset | ] [-s ] [--validate] [--verbose] [--version ] FLAGS -c, --config= @@ -262,6 +262,12 @@ FLAGS The path to your app directory. [env: SHOPIFY_FLAG_PATH] + --plan-file= + Path to a JSON file describing an ordered plan of named mutation operations to run together as a single bulk + operation. Each entry is {"mutationFile": "...", "variableFile": "..."} (inline "mutation"/"variables" are also + accepted). Runs via bulkOperationRunMutations. + [env: SHOPIFY_FLAG_PLAN_FILE] + --query-file= Path to a file containing the GraphQL query or mutation. Can't be used with --query. [env: SHOPIFY_FLAG_QUERY_FILE] @@ -270,6 +276,11 @@ FLAGS Reset all your settings. [env: SHOPIFY_FLAG_RESET] + --[no-]validate + Validate operations against the store's Admin schema before submitting. Enabled by default; use --no-validate to + skip. + [env: SHOPIFY_FLAG_VALIDATE] + --variable-file= Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e95134a3343..79bee029ab3 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -264,6 +264,18 @@ "noCacheDefault": true, "type": "option" }, + "plan-file": { + "description": "Path to a JSON file describing an ordered plan of named mutation operations to run together as a single bulk operation. Each entry is {\"mutationFile\": \"...\", \"variableFile\": \"...\"} (inline \"mutation\"/\"variables\" are also accepted). Runs via bulkOperationRunMutations.", + "env": "SHOPIFY_FLAG_PLAN_FILE", + "exclusive": [ + "variables", + "variable-file" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "plan-file", + "type": "option" + }, "query": { "char": "q", "description": "The GraphQL query or mutation to run as a bulk operation.", @@ -302,11 +314,19 @@ "name": "store", "type": "option" }, + "validate": { + "allowNo": true, + "description": "Validate operations against the store's Admin schema before submitting. Enabled by default; use --no-validate to skip.", + "env": "SHOPIFY_FLAG_VALIDATE", + "name": "validate", + "type": "boolean" + }, "variable-file": { "description": "Path to a file containing GraphQL variables in JSONL format (one JSON object per line). Can't be used with --variables.", "env": "SHOPIFY_FLAG_VARIABLE_FILE", "exclusive": [ - "variables" + "variables", + "plan-file" ], "hasDynamicHelp": false, "multiple": false, @@ -318,7 +338,8 @@ "description": "The values for any GraphQL variables in your mutation, in JSON format. Can be specified multiple times.", "env": "SHOPIFY_FLAG_VARIABLES", "exclusive": [ - "variable-file" + "variable-file", + "plan-file" ], "hasDynamicHelp": false, "multiple": true,