diff --git a/bin/get-graphql-schemas.js b/bin/get-graphql-schemas.js index c4699d0a3e0..7ca6ff1e873 100755 --- a/bin/get-graphql-schemas.js +++ b/bin/get-graphql-schemas.js @@ -81,7 +81,7 @@ const schemas = [ pathToFile: 'areas/core/shopify/db/graphql/admin_schema_unstable_public.graphql', localPaths: [ './packages/cli-kit/src/cli/api/graphql/admin/admin_schema.graphql', - './packages/app/src/cli/api/graphql/bulk-operations/admin_schema.graphql', + './packages/cli-kit/src/cli/api/graphql/bulk-operations/admin_schema.graphql', './packages/app/src/cli/api/graphql/admin/admin_schema.graphql', ], usesLfs: true, diff --git a/graphql.config.ts b/graphql.config.ts index 89fd6303238..215a5f81e62 100644 --- a/graphql.config.ts +++ b/graphql.config.ts @@ -81,7 +81,7 @@ export default { appDev: projectFactory('app-dev', 'app_dev_schema.graphql'), appManagement: projectFactory('app-management', 'app_management_schema.graphql'), admin: projectFactory('admin', 'admin_schema.graphql', 'cli-kit'), - bulkOperations: projectFactory('bulk-operations', 'admin_schema.graphql'), + bulkOperations: projectFactory('bulk-operations', 'admin_schema.graphql', 'cli-kit'), webhooks: projectFactory('webhooks', 'webhooks_schema.graphql'), functions: projectFactory('functions', 'functions_cli_schema.graphql', 'app'), adminAsApp: projectFactory('admin', 'admin_schema.graphql'), diff --git a/packages/app/project.json b/packages/app/project.json index ec2bd2a5962..1137aaef142 100644 --- a/packages/app/project.json +++ b/packages/app/project.json @@ -59,7 +59,6 @@ "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts", - "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" ], "options": { @@ -71,7 +70,6 @@ "pnpm eslint 'src/cli/api/graphql/app-management/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/webhooks/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/functions/generated/**/*.{ts,tsx}' --fix", - "pnpm eslint 'src/cli/api/graphql/bulk-operations/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/admin/generated/**/*.{ts,tsx}' --fix" ], "cwd": "packages/app" @@ -154,17 +152,6 @@ "cwd": "{workspaceRoot}" } }, - "graphql-codegen:generate:bulk-operations": { - "executor": "nx:run-commands", - "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/bulk-operations/**/*.graphql"], - "outputs": ["{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts"], - "options": { - "commands": [ - "pnpm exec graphql-codegen --project=bulkOperations" - ], - "cwd": "{workspaceRoot}" - } - }, "graphql-codegen:generate:admin-as-app": { "executor": "nx:run-commands", "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/admin/**/*.graphql"], @@ -186,7 +173,6 @@ "graphql-codegen:generate:app-management", "graphql-codegen:generate:webhooks", "graphql-codegen:generate:functions", - "graphql-codegen:generate:bulk-operations", "graphql-codegen:generate:admin-as-app" ], "inputs": [{ "dependentTasksOutputFiles": "**/*.ts" }], @@ -198,7 +184,6 @@ "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts", - "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" ], "options": { @@ -210,7 +195,6 @@ "find ./packages/app/src/cli/api/graphql/app-management/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/webhooks/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/functions/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", - "find ./packages/app/src/cli/api/graphql/bulk-operations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/admin/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" ], "cwd": "{workspaceRoot}" diff --git a/packages/app/src/cli/commands/app/bulk/cancel.ts b/packages/app/src/cli/commands/app/bulk/cancel.ts index 23f1cceb19f..7cc9ee43687 100644 --- a/packages/app/src/cli/commands/app/bulk/cancel.ts +++ b/packages/app/src/cli/commands/app/bulk/cancel.ts @@ -2,9 +2,9 @@ import {appFlags} from '../../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js' -import {normalizeBulkOperationId} from '../../../services/bulk-operations/bulk-operation-status.js' import {Flags} from '@oclif/core' import {globalFlags} from '@shopify/cli-kit/node/cli' +import {normalizeBulkOperationId} from '@shopify/cli-kit/node/api/bulk-operations' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' export default class BulkCancel extends AppLinkedCommand { diff --git a/packages/app/src/cli/commands/app/bulk/status.ts b/packages/app/src/cli/commands/app/bulk/status.ts index 73ba683c98e..86881ccc977 100644 --- a/packages/app/src/cli/commands/app/bulk/status.ts +++ b/packages/app/src/cli/commands/app/bulk/status.ts @@ -1,13 +1,10 @@ import {appFlags} from '../../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' -import { - getBulkOperationStatus, - listBulkOperations, - normalizeBulkOperationId, -} from '../../../services/bulk-operations/bulk-operation-status.js' +import {getBulkOperationStatus, listBulkOperations} from '../../../services/bulk-operations/bulk-operation-status.js' import {Flags} from '@oclif/core' import {globalFlags} from '@shopify/cli-kit/node/cli' +import {normalizeBulkOperationId} from '@shopify/cli-kit/node/api/bulk-operations' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' export default class BulkStatus extends AppLinkedCommand { diff --git a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts index fff16e7493d..e6ea52eb522 100644 --- a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts +++ b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts @@ -1,14 +1,7 @@ -import { - getBulkOperationStatus, - listBulkOperations, - normalizeBulkOperationId, - extractBulkOperationId, -} from './bulk-operation-status.js' -import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' -import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import {getBulkOperationStatus, listBulkOperations} from './bulk-operation-status.js' import {OrganizationApp, Organization, OrganizationSource} from '../../models/organization.js' -import {ListBulkOperationsQuery} from '../../api/graphql/bulk-operations/generated/list-bulk-operations.js' import {resolveApiVersion} from '../graphql/common.js' +import {BULK_OPERATIONS_MIN_API_VERSION, type BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' @@ -51,41 +44,8 @@ afterEach(() => { mockAndCaptureOutput().clear() }) -describe('normalizeBulkOperationId', () => { - test('returns GID as-is when already in GID format', () => { - const gid = 'gid://shopify/BulkOperation/123' - expect(normalizeBulkOperationId(gid)).toBe(gid) - }) - - test('converts numeric ID to GID format', () => { - expect(normalizeBulkOperationId('123')).toBe('gid://shopify/BulkOperation/123') - expect(normalizeBulkOperationId('456789')).toBe('gid://shopify/BulkOperation/456789') - }) - - test('returns non-numeric, non-GID string as-is', () => { - const invalidId = 'invalid-id' - expect(normalizeBulkOperationId(invalidId)).toBe(invalidId) - }) -}) - -describe('extractBulkOperationId', () => { - test('extracts numeric ID from GID', () => { - expect(extractBulkOperationId('gid://shopify/BulkOperation/123')).toBe('123') - expect(extractBulkOperationId('gid://shopify/BulkOperation/456789')).toBe('456789') - }) - - test('returns input as-is if not a valid GID format', () => { - expect(extractBulkOperationId('gid://shopify/BulkOperation/ABC')).toBe('gid://shopify/BulkOperation/ABC') - expect(extractBulkOperationId('BulkOperation/123')).toBe('BulkOperation/123') - expect(extractBulkOperationId('invalid-id')).toBe('invalid-id') - expect(extractBulkOperationId('123')).toBe('123') - }) -}) - describe('getBulkOperationStatus', () => { - function mockBulkOperation( - overrides?: Partial>, - ): GetBulkOperationByIdQuery { + function mockBulkOperation(overrides?: Partial): {bulkOperation: BulkOperation | null} { return { bulkOperation: { id: operationId, @@ -243,9 +203,7 @@ describe('getBulkOperationStatus', () => { }) describe('listBulkOperations', () => { - function mockBulkOperationsList( - operations: Partial>[], - ): ListBulkOperationsQuery { + function mockBulkOperationsList(operations: Partial[]): {bulkOperations: {nodes: BulkOperation[]}} { return { bulkOperations: { nodes: operations.map((op) => ({ diff --git a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts index 6162909f37f..df750616803 100644 --- a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts +++ b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts @@ -1,46 +1,20 @@ -import {BulkOperation} from './watch-bulk-operation.js' -import {formatBulkOperationStatus} from './format-bulk-operation-status.js' -import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' -import { - GetBulkOperationById, - GetBulkOperationByIdQuery, -} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' import {formatOperationInfo, resolveApiVersion} from '../graphql/common.js' import {OrganizationApp, Organization} from '../../models/organization.js' import { - ListBulkOperations, - ListBulkOperationsQuery, - ListBulkOperationsQueryVariables, -} from '../../api/graphql/bulk-operations/generated/list-bulk-operations.js' + BULK_OPERATIONS_MIN_API_VERSION, + fetchBulkOperationById, + fetchRecentBulkOperations, + formatBulkOperationStatus, + extractBulkOperationId, + type BulkOperation, +} from '@shopify/cli-kit/node/api/bulk-operations' import {renderInfo, renderSuccess, renderError, renderTable} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken, outputNewline} from '@shopify/cli-kit/node/output' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' import {timeAgo, formatDate} from '@shopify/cli-kit/common/string' import {BugError} from '@shopify/cli-kit/node/error' import colors from '@shopify/cli-kit/node/colors' -export function normalizeBulkOperationId(id: string): string { - // If already a GID, return as-is - if (id.startsWith('gid://')) { - return id - } - - // If numeric, convert to GID - if (/^\d+$/.test(id)) { - return `gid://shopify/BulkOperation/${id}` - } - - // Otherwise return as-is (let API handle any errors) - return id -} - -export function extractBulkOperationId(gid: string): string { - // Extract the numeric ID from a GID like "gid://shopify/BulkOperation/123" - const match = gid.match(/^gid:\/\/shopify\/BulkOperation\/(\d+)$/) - return match?.[1] ?? gid -} - interface GetBulkOperationStatusOptions { organization: Organization storeFqdn: string @@ -73,18 +47,17 @@ export async function getBulkOperationStatus(options: GetBulkOperationStatusOpti const adminSession = await ensureAuthenticatedAdminAsApp(storeFqdn, remoteApp.apiKey, appSecret) - const response = await adminRequestDoc({ - query: GetBulkOperationById, - session: adminSession, - variables: {id: operationId}, + const operation = await fetchBulkOperationById({ + adminSession, + operationId, version: await resolveApiVersion({ adminSession, minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, }), }) - if (response.bulkOperation) { - renderBulkOperationStatus(response.bulkOperation) + if (operation) { + renderBulkOperationStatus(operation) } else { renderError({ headline: 'Bulk operation not found.', @@ -112,23 +85,15 @@ export async function listBulkOperations(options: ListBulkOperationsOptions): Pr const adminSession = await ensureAuthenticatedAdminAsApp(storeFqdn, remoteApp.apiKey, appSecret) - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] - - const response = await adminRequestDoc({ - query: ListBulkOperations, - session: adminSession, - variables: { - query: `created_at:>=${sevenDaysAgo}`, - first: 100, - sortKey: 'CREATED_AT', - }, + const nodes = await fetchRecentBulkOperations({ + adminSession, version: await resolveApiVersion({ adminSession, minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, }), }) - const operations = response.bulkOperations.nodes.map((operation) => ({ + const operations = nodes.map((operation) => ({ id: extractBulkOperationId(operation.id), status: formatStatus(operation.status), count: formatCount(operation.objectCount as number), diff --git a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts index 6badc0d5f26..f16311971f3 100644 --- a/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/cancel-bulk-operation.ts @@ -1,16 +1,13 @@ -import {renderBulkOperationUserErrors, formatBulkOperationCancellationResult} from './format-bulk-operation-status.js' -import { - BulkOperationCancel, - BulkOperationCancelMutation, - BulkOperationCancelMutationVariables, -} from '../../api/graphql/bulk-operations/generated/bulk-operation-cancel.js' import {formatOperationInfo, createAdminSessionAsApp} from '../graphql/common.js' import {OrganizationApp, Organization} from '../../models/organization.js' -import {renderInfo, renderError, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import { + cancelBulkOperationRequest, + renderBulkOperationUserErrors, + formatBulkOperationCancellationResult, + extractBulkOperationId, +} from '@shopify/cli-kit/node/api/bulk-operations' +import {renderInfo, renderError, renderSuccess, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' - -const API_VERSION = '2026-01' interface CancelBulkOperationOptions { organization: Organization @@ -35,24 +32,27 @@ export async function cancelBulkOperation(options: CancelBulkOperationOptions): const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) - const response = await adminRequestDoc({ - query: BulkOperationCancel, - session: adminSession, - variables: {id: operationId}, - version: API_VERSION, - }) + const bulkOperationCancel = await cancelBulkOperationRequest({adminSession, operationId}) - if (response.bulkOperationCancel?.userErrors?.length) { - renderBulkOperationUserErrors(response.bulkOperationCancel.userErrors, 'Failed to cancel bulk operation.') + if (bulkOperationCancel?.userErrors?.length) { + renderBulkOperationUserErrors(bulkOperationCancel.userErrors, 'Failed to cancel bulk operation.') return } - const operation = response.bulkOperationCancel?.bulkOperation + const operation = bulkOperationCancel?.bulkOperation if (operation) { const result = formatBulkOperationCancellationResult(operation) + // The engine is command-agnostic; this command writes its own "check status" hint. + const body: TokenItem | undefined = + operation.status === 'CANCELING' + ? [ + 'This may take a few moments. Check the status with:\n', + {command: `shopify app bulk status --id=${extractBulkOperationId(operation.id)}`}, + ] + : result.body const renderOptions = { headline: result.headline, - ...(result.body && {body: result.body}), + ...(body && {body}), ...(result.customSections && {customSections: result.customSections}), } diff --git a/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.test.ts b/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.test.ts deleted file mode 100644 index 802a7aef6bd..00000000000 --- a/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {downloadBulkOperationResults} from './download-bulk-operation-results.js' -import {fetch} from '@shopify/cli-kit/node/http' -import {describe, test, expect, vi} from 'vitest' - -vi.mock('@shopify/cli-kit/node/http') - -describe('downloadBulkOperationResults', () => { - test('returns text content when fetch is successful', async () => { - const mockUrl = 'https://example.com/results.jsonl' - const mockContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}' - - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: async () => mockContent, - } as Awaited>) - - const result = await downloadBulkOperationResults(mockUrl) - - expect(fetch).toHaveBeenCalledWith(mockUrl) - expect(result).toBe(mockContent) - }) - - test('throws error when fetch fails', async () => { - const mockUrl = 'https://example.com/results.jsonl' - - vi.mocked(fetch).mockResolvedValue({ - ok: false, - statusText: 'Not Found', - } as Awaited>) - - await expect(downloadBulkOperationResults(mockUrl)).rejects.toThrow( - 'Failed to download bulk operation results: Not Found', - ) - }) -}) diff --git a/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.ts b/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.ts deleted file mode 100644 index 0c779cc3425..00000000000 --- a/packages/app/src/cli/services/bulk-operations/download-bulk-operation-results.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {fetch} from '@shopify/cli-kit/node/http' -import {AbortError} from '@shopify/cli-kit/node/error' - -export async function downloadBulkOperationResults(url: string): Promise { - const response = await fetch(url) - - if (!response.ok) { - throw new AbortError(`Failed to download bulk operation results: ${response.statusText}`) - } - - return response.text() -} 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 0e866066d6c..a41807f0e0b 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 @@ -1,13 +1,15 @@ import {executeBulkOperation} from './execute-bulk-operation.js' -import {runBulkOperationQuery} from './run-query.js' -import {runBulkOperationMutation} from './run-mutation.js' -import {watchBulkOperation, shortBulkOperationPoll} from './watch-bulk-operation.js' -import {downloadBulkOperationResults} from './download-bulk-operation-results.js' -import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' import {resolveApiVersion, createAdminSessionAsApp} from '../graphql/common.js' -import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js' -import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js' import {OrganizationApp, OrganizationSource, OrganizationStore} from '../../models/organization.js' +import { + runBulkOperationQuery, + runBulkOperationMutation, + watchBulkOperation, + shortBulkOperationPoll, + downloadBulkOperationResults, + BULK_OPERATIONS_MIN_API_VERSION, + type BulkOperation, +} from '@shopify/cli-kit/node/api/bulk-operations' import {renderSuccess, renderWarning, renderError, renderInfo} from '@shopify/cli-kit/node/ui' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {inTemporaryDirectory, writeFile, readFile} from '@shopify/cli-kit/node/fs' @@ -15,10 +17,17 @@ import {joinPath} from '@shopify/cli-kit/node/path' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -vi.mock('./run-query.js') -vi.mock('./run-mutation.js') -vi.mock('./watch-bulk-operation.js') -vi.mock('./download-bulk-operation-results.js') +vi.mock('@shopify/cli-kit/node/api/bulk-operations', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/bulk-operations') + return { + ...actual, + runBulkOperationQuery: vi.fn(), + runBulkOperationMutation: vi.fn(), + watchBulkOperation: vi.fn(), + shortBulkOperationPoll: vi.fn(), + downloadBulkOperationResults: vi.fn(), + } +}) vi.mock('../graphql/common.js', async () => { const actual = await vi.importActual('../graphql/common.js') return { @@ -72,9 +81,7 @@ describe('executeBulkOperation', () => { } const mockAdminSession = {token: 'test-token', storeFqdn} - const createdBulkOperation: NonNullable< - NonNullable['bulkOperation'] - > = { + const createdBulkOperation: BulkOperation = { id: 'gid://shopify/BulkOperation/123', type: 'QUERY', status: 'CREATED', @@ -99,7 +106,7 @@ describe('executeBulkOperation', () => { test('runs query operation when GraphQL document starts with query', async () => { const query = 'query { products { edges { node { id } } } }' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -122,7 +129,7 @@ describe('executeBulkOperation', () => { test('runs query operation when GraphQL document starts with curly brace', async () => { const query = '{ products { edges { node { id } } } }' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -146,7 +153,7 @@ describe('executeBulkOperation', () => { test('runs mutation operation when GraphQL document starts with mutation', async () => { const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' const variables = ['{"input":{"id":"gid://shopify/Product/123"}}'] - const mockResponse: BulkOperationRunMutationMutation['bulkOperationRunMutation'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -172,7 +179,7 @@ describe('executeBulkOperation', () => { test('passes variables parameter to runBulkOperationMutation when variables are provided', async () => { const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' const variables = ['{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}'] - const mockResponse: BulkOperationRunMutationMutation['bulkOperationRunMutation'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -196,7 +203,7 @@ describe('executeBulkOperation', () => { test('renders running message when bulk operation returns without user errors', async () => { const query = '{ products { edges { node { id } } } }' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -218,7 +225,7 @@ describe('executeBulkOperation', () => { test('renders warning with formatted field errors when bulk operation returns user errors', async () => { const query = '{ products { edges { node { id } } } }' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: null, userErrors: [ {field: ['query'], message: 'Invalid query syntax', code: null}, @@ -359,7 +366,7 @@ describe('executeBulkOperation', () => { test('uses watchBulkOperation (not quickWatchBulkOperation) when watch flag is true', async () => { const query = '{ products { edges { node { id } } } }' - const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const initialResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -400,7 +407,7 @@ describe('executeBulkOperation', () => { test('renders help message in an info banner when watch is provided and user aborts', async () => { const query = '{ products { edges { node { id } } } }' - const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const initialResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -433,7 +440,7 @@ describe('executeBulkOperation', () => { test('uses quickWatchBulkOperation (not watchBulkOperation) when watch flag is false', async () => { const query = '{ products { edges { node { id } } } }' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -460,7 +467,7 @@ describe('executeBulkOperation', () => { status: 'RUNNING' as const, objectCount: '50', } - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -492,7 +499,7 @@ describe('executeBulkOperation', () => { url: 'https://example.com/download', objectCount: '100', } - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -526,7 +533,7 @@ describe('executeBulkOperation', () => { status, objectCount: '0', } - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -558,7 +565,7 @@ describe('executeBulkOperation', () => { const resultsContent = '{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}' - const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const initialResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -592,7 +599,7 @@ describe('executeBulkOperation', () => { const resultsContent = '{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}' - const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const initialResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -624,7 +631,7 @@ describe('executeBulkOperation', () => { 'waits for operation to finish and renders error when watch is provided and operation finishes with %s status', async (status) => { const query = '{ products { edges { node { id } } } }' - const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const initialResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -682,7 +689,7 @@ describe('executeBulkOperation', () => { const query = '{ products { edges { node { id } } } }' const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}' - const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const initialResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -718,7 +725,7 @@ describe('executeBulkOperation', () => { const query = '{ products { edges { node { id } } } }' const resultsWithoutErrors = '{"data":{"productUpdate":{"product":{"id":"123"},"userErrors":[]}},"__lineNumber":0}' - const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const initialResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -756,7 +763,7 @@ describe('executeBulkOperation', () => { const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}' - const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const initialResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -793,7 +800,7 @@ describe('executeBulkOperation', () => { test('calls resolveApiVersion with minimum API version constant', async () => { const query = '{ products { edges { node { id } } } }' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } @@ -816,7 +823,7 @@ describe('executeBulkOperation', () => { test('uses resolved API version when running bulk operation', async () => { vi.mocked(resolveApiVersion).mockResolvedValue('test-api-version') const query = '{ products { edges { node { id } } } }' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + const mockResponse: Awaited> = { bulkOperation: createdBulkOperation, userErrors: [], } 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 41da1dd4ed6..7eb5c158830 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 @@ -1,10 +1,3 @@ -import {runBulkOperationQuery} from './run-query.js' -import {runBulkOperationMutation} from './run-mutation.js' -import {watchBulkOperation, shortBulkOperationPoll, type BulkOperation} from './watch-bulk-operation.js' -import {formatBulkOperationStatus} from './format-bulk-operation-status.js' -import {downloadBulkOperationResults} from './download-bulk-operation-results.js' -import {extractBulkOperationId} from './bulk-operation-status.js' -import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' import { createAdminSessionAsApp, formatOperationInfo, @@ -13,6 +6,18 @@ import { isMutation, } from '../graphql/common.js' import {OrganizationApp, Organization, OrganizationStore} from '../../models/organization.js' +import { + runBulkOperationQuery, + runBulkOperationMutation, + watchBulkOperation, + shortBulkOperationPoll, + formatBulkOperationStatus, + downloadBulkOperationResults, + resultsContainUserErrors, + extractBulkOperationId, + BULK_OPERATIONS_MIN_API_VERSION, + type BulkOperation, +} from '@shopify/cli-kit/node/api/bulk-operations' import { renderSuccess, renderInfo, @@ -221,17 +226,6 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?: } } -function resultsContainUserErrors(results: string): boolean { - const lines = results.trim().split('\n') - - return lines.some((line) => { - const parsed = JSON.parse(line) - if (!parsed.data) return false - const result = Object.values(parsed.data)[0] as {userErrors?: unknown[]} | undefined - return result?.userErrors !== undefined && result.userErrors.length > 0 - }) -} - function validateBulkOperationVariables(graphqlOperation: string, variablesJsonl?: string): void { if (isMutation(graphqlOperation) && !variablesJsonl) { throw new AbortError( diff --git a/packages/app/src/cli/services/graphql/common.test.ts b/packages/app/src/cli/services/graphql/common.test.ts index 9d694a94d20..02257c5d85b 100644 --- a/packages/app/src/cli/services/graphql/common.test.ts +++ b/packages/app/src/cli/services/graphql/common.test.ts @@ -7,7 +7,7 @@ import { isMutation, } from './common.js' import {OrganizationApp, OrganizationStore} from '../../models/organization.js' -import {BULK_OPERATIONS_MIN_API_VERSION} from '../bulk-operations/constants.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from '@shopify/cli-kit/node/api/bulk-operations' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {describe, test, expect, vi, beforeEach} from 'vitest' diff --git a/packages/app/src/cli/services/graphql/common.ts b/packages/app/src/cli/services/graphql/common.ts index 12d237f9e1d..b9492b2e770 100644 --- a/packages/app/src/cli/services/graphql/common.ts +++ b/packages/app/src/cli/services/graphql/common.ts @@ -1,9 +1,11 @@ import {OrganizationApp, OrganizationStore} from '../../models/organization.js' import {ensureAuthenticatedAdminAsApp, AdminSession} from '@shopify/cli-kit/node/session' import {AbortError, BugError} from '@shopify/cli-kit/node/error' -import {outputContent} from '@shopify/cli-kit/node/output' -import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' -import {parse} from 'graphql' +import {isMutation, validateSingleOperation, resolveApiVersion} from '@shopify/cli-kit/node/api/bulk-operations' + +// Generic GraphQL operation helpers live in the shared cli-kit bulk-operations engine; re-export +// them here so existing app call sites keep a single import surface without duplicating logic. +export {isMutation, validateSingleOperation, resolveApiVersion} /** * Creates an Admin API session authenticated as an app using client credentials. @@ -19,78 +21,6 @@ export async function createAdminSessionAsApp(remoteApp: OrganizationApp, storeF return ensureAuthenticatedAdminAsApp(storeFqdn, remoteApp.apiKey, appSecret) } -/** - * Validates that a GraphQL document contains exactly one operation definition. - * - * @param graphqlOperation - The GraphQL query or mutation string to validate. - * @throws AbortError if the document doesn't contain exactly one operation or has syntax errors. - */ -export function validateSingleOperation(graphqlOperation: string): void { - let document - try { - document = parse(graphqlOperation) - } catch (error) { - if (error instanceof Error) { - throw new AbortError(`Invalid GraphQL syntax: ${error.message}`) - } - throw error - } - - const operationDefinitions = document.definitions.filter((def) => def.kind === 'OperationDefinition') - - if (operationDefinitions.length !== 1) { - throw new AbortError( - 'GraphQL document must contain exactly one operation definition. Multiple operations are not supported.', - ) - } -} - -/** - * Options for resolving an API version. - */ -interface ResolveApiVersionOptions { - /** Admin session containing store credentials. */ - adminSession: {token: string; storeFqdn: string} - /** The API version specified by the user. */ - userSpecifiedVersion?: string - /** Optional minimum version to use as a fallback when no version is specified. */ - minimumDefaultVersion?: string -} - -/** - * Determines the API version to use based on the user provided version and the available versions. - * The 'unstable' version is always allowed without validation. - * - * @param options - Options for resolving the API version. - * @throws AbortError if the provided version is not allowed. - */ -export async function resolveApiVersion(options: ResolveApiVersionOptions): Promise { - const {adminSession, userSpecifiedVersion, minimumDefaultVersion} = options - - if (userSpecifiedVersion === 'unstable') return userSpecifiedVersion - - const availableVersions = await fetchApiVersions(adminSession) - - if (!userSpecifiedVersion) { - // Return the most recent supported version, or minimumDefaultVersion if specified, whichever is newer. - const supportedVersions = availableVersions.filter((version) => version.supported).map((version) => version.handle) - if (minimumDefaultVersion) { - supportedVersions.push(minimumDefaultVersion) - } - - return supportedVersions.sort().reverse()[0]! - } - - // Check if the user provided version is allowed. Unsupported versions (RC) are allowed here. - const versionList = availableVersions.map((version) => version.handle) - if (versionList.includes(userSpecifiedVersion)) return userSpecifiedVersion - - // Invalid user provided version. - const firstLine = outputContent`Invalid API version: ${userSpecifiedVersion}`.value - const secondLine = outputContent`Allowed versions: ${versionList.join(', ')}`.value - throw new AbortError(firstLine, secondLine) -} - /** * Creates formatted info list items for GraphQL operations. * Includes organization, app, store, and optionally API version information. @@ -115,19 +45,6 @@ export function formatOperationInfo(options: { return items } -/** - * Checks if a GraphQL operation is a mutation. - * - * @param graphqlOperation - The GraphQL query or mutation string to check. - * @returns True if the operation is a mutation, false otherwise. - */ -export function isMutation(graphqlOperation: string): boolean { - const document = parse(graphqlOperation) - const operationDefinition = document.definitions.find((def) => def.kind === 'OperationDefinition') - - return operationDefinition?.operation === 'mutation' -} - /** * Validates that mutations can only be executed on dev stores. * diff --git a/packages/app/src/cli/utilities/execute-command-helpers.test.ts b/packages/app/src/cli/utilities/execute-command-helpers.test.ts index 73031df7406..9843159bea5 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.test.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.test.ts @@ -1,7 +1,6 @@ import {prepareAppStoreContext, prepareExecuteContext} from './execute-command-helpers.js' import {linkedAppContext} from '../services/app-context.js' import {storeContext} from '../services/store-context.js' -import {validateSingleOperation} from '../services/graphql/common.js' import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {describe, test, expect, vi, beforeEach} from 'vitest' @@ -9,9 +8,6 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' vi.mock('../services/app-context.js') vi.mock('../services/store-context.js') vi.mock('@shopify/cli-kit/node/system') -vi.mock('../services/graphql/common.js', () => ({ - validateSingleOperation: vi.fn(), -})) describe('prepareAppStoreContext', () => { const mockFlags = { @@ -211,9 +207,11 @@ describe('prepareExecuteContext', () => { }) }) - test('validates GraphQL query using validateSingleOperation', async () => { - await prepareExecuteContext(mockFlags) + test('rejects a query that contains multiple operations', async () => { + const flagsWithMultipleOperations = {...mockFlags, query: 'query A { a } query B { b }'} - expect(validateSingleOperation).toHaveBeenCalledWith(mockFlags.query) + await expect(prepareExecuteContext(flagsWithMultipleOperations)).rejects.toThrow( + 'must contain exactly one operation', + ) }) }) diff --git a/packages/app/src/cli/utilities/execute-command-helpers.ts b/packages/app/src/cli/utilities/execute-command-helpers.ts index a4602022b60..f84c8310fe1 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.ts @@ -1,10 +1,7 @@ import {linkedAppContext, LoadedAppContextOutput} from '../services/app-context.js' import {storeContext} from '../services/store-context.js' -import {validateSingleOperation} from '../services/graphql/common.js' import {OrganizationStore} from '../models/organization.js' -import {AbortError, BugError} from '@shopify/cli-kit/node/error' -import {readFile, fileExists} from '@shopify/cli-kit/node/fs' -import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {resolveBulkOperationQuery} from '@shopify/cli-kit/node/api/bulk-operations' interface AppStoreContextFlags { path: string @@ -61,38 +58,7 @@ export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promi * @returns Context object containing query, app context, and store information. */ export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise { - let query: string | undefined - - if (flags.query !== undefined) { - if (!flags.query.trim()) { - throw new AbortError('The --query flag value is empty. Please provide a valid GraphQL query or mutation.') - } - query = flags.query - } else if (flags['query-file']) { - const queryFile = flags['query-file'] - if (!(await fileExists(queryFile))) { - throw new AbortError( - outputContent`Query file not found at ${outputToken.path(queryFile)}. Please check the path and try again.`, - ) - } - query = await readFile(queryFile, {encoding: 'utf8'}) - if (!query.trim()) { - throw new AbortError( - outputContent`Query file at ${outputToken.path( - queryFile, - )} is empty. Please provide a valid GraphQL query or mutation.`, - ) - } - } - - if (!query) { - throw new BugError( - 'Query should have been provided via --query or --query-file flags due to exactlyOne constraint. This indicates the oclif flag validation failed.', - ) - } - - // Validate GraphQL syntax and ensure single operation - validateSingleOperation(query) + const query = await resolveBulkOperationQuery({query: flags.query, queryFile: flags['query-file']}) const {appContextResult, store} = await prepareAppStoreContext(flags) diff --git a/packages/cli-kit/project.json b/packages/cli-kit/project.json index f382e2ebafb..4e3209da8db 100644 --- a/packages/cli-kit/project.json +++ b/packages/cli-kit/project.json @@ -107,11 +107,13 @@ } ], "outputs": [ - "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" + "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts" ], "options": { "commands": [ - "pnpm eslint 'src/cli/api/graphql/admin/generated/**/*.{ts,tsx}' --fix" + "pnpm eslint 'src/cli/api/graphql/admin/generated/**/*.{ts,tsx}' --fix", + "pnpm eslint 'src/cli/api/graphql/bulk-operations/generated/**/*.{ts,tsx}' --fix" ], "cwd": "packages/cli-kit" } @@ -132,10 +134,27 @@ "cwd": "{workspaceRoot}" } }, + "graphql-codegen:generate:bulk-operations": { + "executor": "nx:run-commands", + "inputs": [ + "{workspaceRoot}/graphql.config.ts", + "{projectRoot}/src/cli/api/graphql/bulk-operations/**/*.graphql" + ], + "outputs": [ + "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts" + ], + "options": { + "commands": [ + "pnpm exec graphql-codegen --project=bulkOperations" + ], + "cwd": "{workspaceRoot}" + } + }, "graphql-codegen:postfix": { "executor": "nx:run-commands", "dependsOn": [ - "graphql-codegen:generate:admin" + "graphql-codegen:generate:admin", + "graphql-codegen:generate:bulk-operations" ], "inputs": [ { @@ -143,11 +162,13 @@ } ], "outputs": [ - "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" + "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts" ], "options": { "commands": [ - "find ./packages/cli-kit/src/cli/api/graphql/admin/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" + "find ./packages/cli-kit/src/cli/api/graphql/admin/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", + "find ./packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" ], "cwd": "{workspaceRoot}" } diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-cancel.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-cancel.ts similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-cancel.ts rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-cancel.ts diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutation.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutation.ts similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutation.ts rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutation.ts diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.ts diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/list-bulk-operations.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/list-bulk-operations.ts similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/generated/list-bulk-operations.ts rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/list-bulk-operations.ts diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/staged-uploads-create.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/staged-uploads-create.ts similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/generated/staged-uploads-create.ts rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/staged-uploads-create.ts diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts b/packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/types.d.ts similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/generated/types.d.ts diff --git a/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-cancel.graphql b/packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-cancel.graphql similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-cancel.graphql rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-cancel.graphql diff --git a/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutation.graphql b/packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutation.graphql similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutation.graphql rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-mutation.graphql diff --git a/packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql b/packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/bulk-operation-run-query.graphql diff --git a/packages/app/src/cli/api/graphql/bulk-operations/mutations/staged-uploads-create.graphql b/packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/staged-uploads-create.graphql similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/mutations/staged-uploads-create.graphql rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/mutations/staged-uploads-create.graphql diff --git a/packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql b/packages/cli-kit/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql diff --git a/packages/app/src/cli/api/graphql/bulk-operations/queries/list-bulk-operations.graphql b/packages/cli-kit/src/cli/api/graphql/bulk-operations/queries/list-bulk-operations.graphql similarity index 100% rename from packages/app/src/cli/api/graphql/bulk-operations/queries/list-bulk-operations.graphql rename to packages/cli-kit/src/cli/api/graphql/bulk-operations/queries/list-bulk-operations.graphql diff --git a/packages/cli-kit/src/public/node/api/bulk-operations.ts b/packages/cli-kit/src/public/node/api/bulk-operations.ts new file mode 100644 index 00000000000..6653842c5bf --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations.ts @@ -0,0 +1,38 @@ +/** + * Shared primitives for running Admin API bulk operations. + * + * These are auth-agnostic: callers provide an `AdminSession` (however they obtained it — + * app client credentials, a stored store session, etc.) and these helpers handle starting, + * watching, fetching, cancelling, downloading, and formatting bulk operations. + */ +export {BULK_OPERATIONS_MIN_API_VERSION} from './bulk-operations/constants.js' +export { + normalizeBulkOperationId, + extractBulkOperationId, + isMutation, + validateSingleOperation, + resolveApiVersion, +} from './bulk-operations/helpers.js' +export {resolveBulkOperationQuery} from './bulk-operations/query.js' +export {runBulkOperationQuery} from './bulk-operations/run-query.js' +export {runBulkOperationMutation} from './bulk-operations/run-mutation.js' +export {stageFile} from './bulk-operations/stage-file.js' +export {fetchBulkOperationById, fetchRecentBulkOperations} from './bulk-operations/fetch.js' +export {cancelBulkOperationRequest} from './bulk-operations/cancel.js' +export { + watchBulkOperation, + shortBulkOperationPoll, + QUICK_WATCH_TIMEOUT_MS, + QUICK_WATCH_POLL_INTERVAL_MS, + type BulkOperation, +} from './bulk-operations/watch-bulk-operation.js' +export { + downloadBulkOperationResults, + resultsContainUserErrors, +} from './bulk-operations/download-bulk-operation-results.js' +export { + formatBulkOperationStatus, + renderBulkOperationUserErrors, + formatBulkOperationCancellationResult, + type BulkOperationCancellationResult, +} from './bulk-operations/format-bulk-operation-status.js' diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/cancel.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/cancel.test.ts new file mode 100644 index 00000000000..42ea0ebe403 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/cancel.test.ts @@ -0,0 +1,34 @@ +import {cancelBulkOperationRequest} from './cancel.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' +import {adminRequestDoc} from '../admin.js' +import {describe, test, expect, vi} from 'vitest' + +vi.mock('../admin.js') + +const adminSession = {token: 'token', storeFqdn: 'shop.myshopify.com'} + +describe('cancelBulkOperationRequest', () => { + test('returns the bulkOperationCancel payload', async () => { + const payload = {bulkOperation: {id: 'gid://shopify/BulkOperation/1', status: 'CANCELING'}, userErrors: []} + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperationCancel: payload}) + + const result = await cancelBulkOperationRequest({adminSession, operationId: 'gid://shopify/BulkOperation/1'}) + + expect(result).toEqual(payload) + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + session: adminSession, + variables: {id: 'gid://shopify/BulkOperation/1'}, + version: BULK_OPERATIONS_MIN_API_VERSION, + }), + ) + }) + + test('uses the provided version when given', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperationCancel: null}) + + await cancelBulkOperationRequest({adminSession, operationId: 'gid://shopify/BulkOperation/1', version: '2025-10'}) + + expect(adminRequestDoc).toHaveBeenCalledWith(expect.objectContaining({version: '2025-10'})) + }) +}) diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/cancel.ts b/packages/cli-kit/src/public/node/api/bulk-operations/cancel.ts new file mode 100644 index 00000000000..d2542e8c16d --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/cancel.ts @@ -0,0 +1,35 @@ +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' +import {adminRequestDoc} from '../admin.js' +import {AdminSession} from '../../session.js' +import { + BulkOperationCancel, + BulkOperationCancelMutation, + BulkOperationCancelMutationVariables, +} from '../../../../cli/api/graphql/bulk-operations/generated/bulk-operation-cancel.js' + +interface CancelBulkOperationOptions { + adminSession: AdminSession + operationId: string + version?: string +} + +/** + * Requests cancellation of a bulk operation. + * + * @param options - The admin session, operation ID, and optional API version. + * @returns The bulkOperationCancel result, including any user errors. + */ +export async function cancelBulkOperationRequest( + options: CancelBulkOperationOptions, +): Promise { + const {adminSession, operationId, version} = options + + const response = await adminRequestDoc({ + query: BulkOperationCancel, + session: adminSession, + variables: {id: operationId}, + version: version ?? BULK_OPERATIONS_MIN_API_VERSION, + }) + + return response.bulkOperationCancel +} diff --git a/packages/app/src/cli/services/bulk-operations/constants.ts b/packages/cli-kit/src/public/node/api/bulk-operations/constants.ts similarity index 100% rename from packages/app/src/cli/services/bulk-operations/constants.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/constants.ts diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/download-bulk-operation-results.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/download-bulk-operation-results.test.ts new file mode 100644 index 00000000000..5ebbd7afcb1 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/download-bulk-operation-results.test.ts @@ -0,0 +1,68 @@ +import {downloadBulkOperationResults, resultsContainUserErrors} from './download-bulk-operation-results.js' +import {fetch} from '../../http.js' +import {describe, test, expect, vi} from 'vitest' + +vi.mock('../../http.js') + +describe('downloadBulkOperationResults', () => { + test('returns text content when fetch is successful', async () => { + const mockUrl = 'https://example.com/results.jsonl' + const mockContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}' + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: async () => mockContent, + } as Awaited>) + + const result = await downloadBulkOperationResults(mockUrl) + + expect(fetch).toHaveBeenCalledWith(mockUrl) + expect(result).toBe(mockContent) + }) + + test('throws error when fetch fails', async () => { + const mockUrl = 'https://example.com/results.jsonl' + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + statusText: 'Not Found', + } as Awaited>) + + await expect(downloadBulkOperationResults(mockUrl)).rejects.toThrow( + 'Failed to download bulk operation results: Not Found', + ) + }) +}) + +describe('resultsContainUserErrors', () => { + test('returns false for an empty result file', () => { + expect(resultsContainUserErrors('')).toBe(false) + expect(resultsContainUserErrors(' \n \n')).toBe(false) + }) + + test('returns false when no line reports user errors', () => { + const results = '{"data":{"productUpdate":{"product":{"id":"gid://shopify/Product/1"},"userErrors":[]}}}' + expect(resultsContainUserErrors(results)).toBe(false) + }) + + test('returns true when a line reports user errors', () => { + const results = [ + '{"data":{"productUpdate":{"product":null,"userErrors":[{"message":"Invalid"}]}}}', + '{"data":{"productUpdate":{"product":{"id":"gid://shopify/Product/2"},"userErrors":[]}}}', + ].join('\n') + expect(resultsContainUserErrors(results)).toBe(true) + }) + + test('ignores lines without a data field', () => { + expect(resultsContainUserErrors('{"foo":"bar"}')).toBe(false) + }) + + test('skips malformed JSON lines instead of throwing', () => { + const results = [ + '{"data":{"productUpdate":{"product":{"id":"gid://shopify/Product/1"},"userErrors":[]}}}', + '{"data":{"productUpdate": {truncated', + ].join('\n') + expect(() => resultsContainUserErrors(results)).not.toThrow() + expect(resultsContainUserErrors(results)).toBe(false) + }) +}) 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 new file mode 100644 index 00000000000..0453c835e99 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/download-bulk-operation-results.ts @@ -0,0 +1,49 @@ +import {fetch} from '../../http.js' +import {AbortError} from '../../error.js' + +/** + * Downloads the results of a completed bulk operation. + * + * @param url - The results URL returned by the Admin API. + * @returns The raw JSONL results as a string. + */ +export async function downloadBulkOperationResults(url: string): Promise { + const response = await fetch(url) + + if (!response.ok) { + throw new AbortError(`Failed to download bulk operation results: ${response.statusText}`) + } + + return response.text() +} + +/** + * Checks whether any line of a JSONL bulk operation result reports GraphQL user errors. + * + * Blank result files, such as a completed operation that matched nothing, are treated as having + * no user errors instead of crashing the JSON parser. + * + * @param results - The raw JSONL results string. + * @returns True if any result line reports user errors. + */ +export function resultsContainUserErrors(results: string): boolean { + const lines = results + .trim() + .split('\n') + .filter((line) => line.trim().length > 0) + + return lines.some((line) => { + let parsed + try { + parsed = JSON.parse(line) + } catch (error) { + // A single malformed line (truncated download, partial flush, etc.) shouldn't dictate the + // overall result; skip it rather than throwing an uncontextualized SyntaxError. + if (error instanceof SyntaxError) return false + throw error + } + if (!parsed.data) return false + const result = Object.values(parsed.data)[0] as {userErrors?: unknown[]} | undefined + return result?.userErrors !== undefined && result.userErrors.length > 0 + }) +} diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/fetch.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/fetch.test.ts new file mode 100644 index 00000000000..80a7e121f14 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/fetch.test.ts @@ -0,0 +1,63 @@ +import {fetchBulkOperationById, fetchRecentBulkOperations} from './fetch.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' +import {adminRequestDoc} from '../admin.js' +import {describe, test, expect, vi} from 'vitest' + +vi.mock('../admin.js') + +const adminSession = {token: 'token', storeFqdn: 'shop.myshopify.com'} + +describe('fetchBulkOperationById', () => { + test('returns the operation from the response', async () => { + const operation = {id: 'gid://shopify/BulkOperation/1', status: 'RUNNING'} + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: operation}) + + const result = await fetchBulkOperationById({adminSession, operationId: 'gid://shopify/BulkOperation/1'}) + + expect(result).toEqual(operation) + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + session: adminSession, + variables: {id: 'gid://shopify/BulkOperation/1'}, + version: BULK_OPERATIONS_MIN_API_VERSION, + }), + ) + }) + + test('uses the provided version when given', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null}) + + await fetchBulkOperationById({adminSession, operationId: 'gid://shopify/BulkOperation/1', version: '2025-10'}) + + expect(adminRequestDoc).toHaveBeenCalledWith(expect.objectContaining({version: '2025-10'})) + }) +}) + +describe('fetchRecentBulkOperations', () => { + test('returns the list of nodes', async () => { + const nodes = [{id: 'gid://shopify/BulkOperation/1'}, {id: 'gid://shopify/BulkOperation/2'}] + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperations: {nodes}}) + + const result = await fetchRecentBulkOperations({adminSession}) + + expect(result).toEqual(nodes) + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + session: adminSession, + variables: expect.objectContaining({first: 100, sortKey: 'CREATED_AT'}), + }), + ) + }) + + test('builds a created_at filter from sinceDays', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperations: {nodes: []}}) + + await fetchRecentBulkOperations({adminSession, sinceDays: 1, first: 5}) + + const call = vi.mocked(adminRequestDoc).mock.calls[0]![0] as unknown as { + variables: {query: string; first: number} + } + expect(call.variables.query).toMatch(/^created_at:>=\d{4}-\d{2}-\d{2}$/) + expect(call.variables.first).toBe(5) + }) +}) diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/fetch.ts b/packages/cli-kit/src/public/node/api/bulk-operations/fetch.ts new file mode 100644 index 00000000000..dc0779d665d --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/fetch.ts @@ -0,0 +1,75 @@ +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' +import {adminRequestDoc} from '../admin.js' +import {AdminSession} from '../../session.js' +import { + GetBulkOperationById, + GetBulkOperationByIdQuery, +} from '../../../../cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import { + ListBulkOperations, + ListBulkOperationsQuery, + ListBulkOperationsQueryVariables, +} from '../../../../cli/api/graphql/bulk-operations/generated/list-bulk-operations.js' + +interface FetchBulkOperationByIdOptions { + adminSession: AdminSession + operationId: string + version?: string +} + +/** + * Fetches a single bulk operation by ID. + * + * @param options - The admin session, operation ID, and optional API version. + * @returns The bulk operation, or null if it doesn't exist. + */ +export async function fetchBulkOperationById( + options: FetchBulkOperationByIdOptions, +): Promise { + const {adminSession, operationId, version} = options + + const response = await adminRequestDoc({ + query: GetBulkOperationById, + session: adminSession, + variables: {id: operationId}, + version: version ?? BULK_OPERATIONS_MIN_API_VERSION, + }) + + return response.bulkOperation +} + +interface FetchRecentBulkOperationsOptions { + adminSession: AdminSession + version?: string + /** Number of days back to include. Defaults to 7. */ + sinceDays?: number + /** Maximum number of operations to return. Defaults to 100. */ + first?: number +} + +/** + * Fetches recent bulk operations for the store. + * + * @param options - The admin session, optional API version, look-back window, and page size. + * @returns The list of bulk operation nodes, most recent first. + */ +export async function fetchRecentBulkOperations( + options: FetchRecentBulkOperationsOptions, +): Promise { + const {adminSession, version, sinceDays = 7, first = 100} = options + + const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + + const response = await adminRequestDoc({ + query: ListBulkOperations, + session: adminSession, + variables: { + query: `created_at:>=${since}`, + first, + sortKey: 'CREATED_AT', + }, + version: version ?? BULK_OPERATIONS_MIN_API_VERSION, + }) + + return response.bulkOperations.nodes +} diff --git a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/format-bulk-operation-status.test.ts similarity index 95% rename from packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/format-bulk-operation-status.test.ts index 7c1a8a87369..b807244ab3f 100644 --- a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/format-bulk-operation-status.test.ts @@ -3,9 +3,9 @@ import { renderBulkOperationUserErrors, formatBulkOperationCancellationResult, } from './format-bulk-operation-status.js' -import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import {GetBulkOperationByIdQuery} from '../../../../cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import {mockAndCaptureOutput} from '../../testing/output.js' import {describe, test, expect, afterEach} from 'vitest' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' type BulkOperation = NonNullable @@ -142,7 +142,7 @@ describe('renderBulkOperationUserErrors', () => { }) describe('formatBulkOperationCancellationResult', () => { - test('formats CANCELING status with success render type and status command', () => { + test('formats CANCELING status with success render type and no command-specific body', () => { const operation = createMockOperation({ id: 'gid://shopify/BulkOperation/6578182226092', status: 'CANCELING', @@ -150,10 +150,8 @@ describe('formatBulkOperationCancellationResult', () => { const result = formatBulkOperationCancellationResult(operation) expect(result.headline).toBe('Bulk operation is being cancelled.') - expect(result.body).toEqual([ - 'This may take a few moments. Check the status with:\n', - {command: 'shopify app bulk status --id=6578182226092'}, - ]) + // The engine stays command-agnostic; each command appends its own "check status" hint. + expect(result.body).toBeUndefined() expect(result.customSections).toBeUndefined() expect(result.renderType).toBe('success') }) diff --git a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts b/packages/cli-kit/src/public/node/api/bulk-operations/format-bulk-operation-status.ts similarity index 72% rename from packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/format-bulk-operation-status.ts index e83a0d726dd..6bba165273d 100644 --- a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/format-bulk-operation-status.ts @@ -1,8 +1,13 @@ -import {extractBulkOperationId} from './bulk-operation-status.js' -import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' -import {outputContent, outputToken, TokenizedString} from '@shopify/cli-kit/node/output' -import {renderError, TokenItem} from '@shopify/cli-kit/node/ui' +import {GetBulkOperationByIdQuery} from '../../../../cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import {outputContent, outputToken, TokenizedString} from '../../output.js' +import {renderError, TokenItem} from '../../ui.js' +/** + * Produces a human-readable status line for a bulk operation. + * + * @param operation - The bulk operation. + * @returns A tokenized status string. + */ export function formatBulkOperationStatus( operation: NonNullable, ): TokenizedString { @@ -39,6 +44,12 @@ interface UserError { message: string } +/** + * Renders a list of bulk operation user errors. + * + * @param userErrors - The user errors to render. + * @param headline - The headline for the error block. + */ export function renderBulkOperationUserErrors(userErrors: UserError[], headline: string): void { const errorMessages = userErrors .map((error) => outputContent`${error.field?.join('.') ?? 'unknown'}: ${error.message}`.value) @@ -50,13 +61,23 @@ export function renderBulkOperationUserErrors(userErrors: UserError[], headline: }) } -interface BulkOperationCancellationResult { +export interface BulkOperationCancellationResult { headline: string body?: TokenItem customSections?: {body: {list: {items: string[]}}[]}[] renderType: 'success' | 'warning' | 'info' } +/** + * Classifies the outcome of a cancellation request into a renderable payload. + * + * The engine intentionally stays command-agnostic: for an in-progress cancellation it returns just + * the headline and render type, leaving each command to append its own "check status" hint (which + * references that command's own `bulk status` invocation). + * + * @param operation - The bulk operation after the cancel request. + * @returns The headline, body, sections, and render type to use. + */ export function formatBulkOperationCancellationResult( operation: NonNullable, ): BulkOperationCancellationResult { @@ -66,10 +87,6 @@ export function formatBulkOperationCancellationResult( case 'CANCELING': return { headline: 'Bulk operation is being cancelled.', - body: [ - 'This may take a few moments. Check the status with:\n', - {command: `shopify app bulk status --id=${extractBulkOperationId(operation.id)}`}, - ], renderType: 'success', } case 'CANCELED': diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/helpers.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/helpers.test.ts new file mode 100644 index 00000000000..86c3f543587 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/helpers.test.ts @@ -0,0 +1,114 @@ +import { + normalizeBulkOperationId, + extractBulkOperationId, + isMutation, + validateSingleOperation, + resolveApiVersion, +} from './helpers.js' +import {fetchApiVersions} from '../admin.js' +import {AbortError} from '../../error.js' +import {describe, test, expect, vi} from 'vitest' + +vi.mock('../admin.js') + +const adminSession = {token: 'token', storeFqdn: 'shop.myshopify.com'} + +describe('normalizeBulkOperationId', () => { + test('returns a GID unchanged', () => { + expect(normalizeBulkOperationId('gid://shopify/BulkOperation/123')).toBe('gid://shopify/BulkOperation/123') + }) + + test('converts a numeric ID to a GID', () => { + expect(normalizeBulkOperationId('123')).toBe('gid://shopify/BulkOperation/123') + }) + + test('returns a non-numeric, non-GID string unchanged', () => { + expect(normalizeBulkOperationId('not-an-id')).toBe('not-an-id') + }) +}) + +describe('extractBulkOperationId', () => { + test('extracts the numeric ID from a GID', () => { + expect(extractBulkOperationId('gid://shopify/BulkOperation/123')).toBe('123') + }) + + test('returns the input unchanged when not a recognized GID', () => { + expect(extractBulkOperationId('123')).toBe('123') + expect(extractBulkOperationId('gid://shopify/BulkOperation/abc')).toBe('gid://shopify/BulkOperation/abc') + }) +}) + +describe('isMutation', () => { + test('returns true for a mutation', () => { + expect(isMutation('mutation { foo }')).toBe(true) + }) + + test('returns false for a query', () => { + expect(isMutation('query { foo }')).toBe(false) + expect(isMutation('{ foo }')).toBe(false) + }) + + test('throws an AbortError on invalid syntax', () => { + expect(() => isMutation('mutation {')).toThrow(AbortError) + }) +}) + +describe('validateSingleOperation', () => { + test('accepts a single operation', () => { + expect(() => validateSingleOperation('query { foo }')).not.toThrow() + }) + + test('throws on invalid syntax', () => { + expect(() => validateSingleOperation('query {')).toThrow(AbortError) + }) + + test('throws when multiple operations are present', () => { + expect(() => validateSingleOperation('query A { foo } query B { bar }')).toThrow(AbortError) + }) +}) + +describe('resolveApiVersion', () => { + test('returns unstable without fetching versions', async () => { + const version = await resolveApiVersion({adminSession, userSpecifiedVersion: 'unstable'}) + expect(version).toBe('unstable') + expect(fetchApiVersions).not.toHaveBeenCalled() + }) + + test('returns the latest supported version when none specified', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2025-07', supported: true}, + {handle: '2025-10', supported: true}, + {handle: '2099-01', supported: false}, + ]) + const version = await resolveApiVersion({adminSession}) + expect(version).toBe('2025-10') + }) + + test('falls back to the minimum default version when newer than supported', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([{handle: '2024-01', supported: true}]) + const version = await resolveApiVersion({adminSession, minimumDefaultVersion: '2026-01'}) + expect(version).toBe('2026-01') + }) + + test('prefers a newer supported version over an older minimum default', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([{handle: '2025-10', supported: true}]) + const version = await resolveApiVersion({adminSession, minimumDefaultVersion: '2024-01'}) + expect(version).toBe('2025-10') + }) + + test('returns a valid user-specified version', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([{handle: '2025-10', supported: true}]) + const version = await resolveApiVersion({adminSession, userSpecifiedVersion: '2025-10'}) + expect(version).toBe('2025-10') + }) + + test('throws for an invalid user-specified version', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([{handle: '2025-10', supported: true}]) + await expect(resolveApiVersion({adminSession, userSpecifiedVersion: '1999-01'})).rejects.toThrow(AbortError) + }) + + test('throws when no supported version is available and no default is provided', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([{handle: 'unstable', supported: false}]) + await expect(resolveApiVersion({adminSession})).rejects.toThrow(AbortError) + }) +}) diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/helpers.ts b/packages/cli-kit/src/public/node/api/bulk-operations/helpers.ts new file mode 100644 index 00000000000..6272ba8d786 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/helpers.ts @@ -0,0 +1,137 @@ +import {fetchApiVersions} from '../admin.js' +import {AdminSession} from '../../session.js' +import {AbortError} from '../../error.js' +import {outputContent} from '../../output.js' +import {parse} from 'graphql' + +/** + * Normalizes a bulk operation ID to a GID. + * + * @param id - A numeric ID or a full GID. + * @returns The GID form of the ID. + */ +export function normalizeBulkOperationId(id: string): string { + // If already a GID, return as-is + if (id.startsWith('gid://')) { + return id + } + + // If numeric, convert to GID + if (/^\d+$/.test(id)) { + return `gid://shopify/BulkOperation/${id}` + } + + // Otherwise return as-is (let API handle any errors) + return id +} + +/** + * Extracts the numeric ID from a bulk operation GID. + * + * @param gid - A GID like "gid://shopify/BulkOperation/123". + * @returns The numeric ID, or the original string if it isn't a recognized GID. + */ +export function extractBulkOperationId(gid: string): string { + const match = gid.match(/^gid:\/\/shopify\/BulkOperation\/(\d+)$/) + return match?.[1] ?? gid +} + +/** + * Validates that a GraphQL document contains exactly one operation definition. + * + * @param graphqlOperation - The GraphQL query or mutation string to validate. + * @throws AbortError if the document doesn't contain exactly one operation or has syntax errors. + */ +export function validateSingleOperation(graphqlOperation: string): void { + let document + try { + document = parse(graphqlOperation) + } catch (error) { + if (error instanceof Error) { + throw new AbortError(`Invalid GraphQL syntax: ${error.message}`) + } + throw error + } + + const operationDefinitions = document.definitions.filter((def) => def.kind === 'OperationDefinition') + + if (operationDefinitions.length !== 1) { + throw new AbortError( + 'GraphQL document must contain exactly one operation definition. Multiple operations are not supported.', + ) + } +} + +/** + * Checks if a GraphQL operation is a mutation. + * + * @param graphqlOperation - The GraphQL query or mutation string to check. + * @returns True if the operation is a mutation, false otherwise. + * @throws AbortError if the operation has invalid GraphQL syntax. + */ +export function isMutation(graphqlOperation: string): boolean { + let document + try { + document = parse(graphqlOperation) + } catch (error) { + if (error instanceof Error) { + throw new AbortError(`Invalid GraphQL syntax: ${error.message}`) + } + throw error + } + + const operationDefinition = document.definitions.find((def) => def.kind === 'OperationDefinition') + + return operationDefinition?.operation === 'mutation' +} + +/** + * Options for resolving an API version. + */ +interface ResolveApiVersionOptions { + /** Admin session containing store credentials. */ + adminSession: AdminSession + /** The API version specified by the user. */ + userSpecifiedVersion?: string + /** Optional minimum version to use as a fallback when no version is specified. */ + minimumDefaultVersion?: string +} + +/** + * Determines the API version to use based on the user provided version and the available versions. + * The 'unstable' version is always allowed without validation. + * + * @param options - Options for resolving the API version. + * @returns The resolved API version. + * @throws AbortError if the provided version is not allowed. + */ +export async function resolveApiVersion(options: ResolveApiVersionOptions): Promise { + const {adminSession, userSpecifiedVersion, minimumDefaultVersion} = options + + if (userSpecifiedVersion === 'unstable') return userSpecifiedVersion + + const availableVersions = await fetchApiVersions(adminSession) + + if (!userSpecifiedVersion) { + // Return the most recent supported version, or minimumDefaultVersion if specified, whichever is newer. + const supportedVersions = availableVersions.filter((version) => version.supported).map((version) => version.handle) + if (minimumDefaultVersion) { + supportedVersions.push(minimumDefaultVersion) + } + + const latest = supportedVersions.sort().reverse()[0] + if (!latest) { + throw new AbortError('No supported API versions available for this store.') + } + return latest + } + + // Check if the user provided version is allowed. Unsupported versions (RC) are allowed here. + const versionList = availableVersions.map((version) => version.handle) + if (versionList.includes(userSpecifiedVersion)) return userSpecifiedVersion + + // Invalid user provided version. + const firstLine = outputContent`Invalid API version: ${userSpecifiedVersion}`.value + const secondLine = outputContent`Allowed versions: ${versionList.join(', ')}`.value + throw new AbortError(firstLine, secondLine) +} diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/query.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/query.test.ts new file mode 100644 index 00000000000..edd278f7659 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/query.test.ts @@ -0,0 +1,44 @@ +import {resolveBulkOperationQuery} from './query.js' +import {fileExists, readFile} from '../../fs.js' +import {AbortError, BugError} from '../../error.js' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('../../fs.js') + +describe('resolveBulkOperationQuery', () => { + beforeEach(() => { + vi.mocked(fileExists).mockResolvedValue(true) + }) + + test('returns a valid inline query', async () => { + await expect(resolveBulkOperationQuery({query: 'query { shop { name } }'})).resolves.toBe('query { shop { name } }') + }) + + test('throws when the inline query is blank', async () => { + await expect(resolveBulkOperationQuery({query: ' '})).rejects.toThrow(AbortError) + }) + + test('reads and returns a valid query file', async () => { + vi.mocked(readFile).mockResolvedValue('query { shop { name } }' as never) + await expect(resolveBulkOperationQuery({queryFile: './op.graphql'})).resolves.toBe('query { shop { name } }') + expect(readFile).toHaveBeenCalledWith('./op.graphql', {encoding: 'utf8'}) + }) + + test('throws when the query file does not exist', async () => { + vi.mocked(fileExists).mockResolvedValue(false) + await expect(resolveBulkOperationQuery({queryFile: './missing.graphql'})).rejects.toThrow(AbortError) + }) + + test('throws when the query file is empty', async () => { + vi.mocked(readFile).mockResolvedValue(' \n' as never) + await expect(resolveBulkOperationQuery({queryFile: './empty.graphql'})).rejects.toThrow(AbortError) + }) + + test('throws a BugError when neither query nor query file is provided', async () => { + await expect(resolveBulkOperationQuery({})).rejects.toThrow(BugError) + }) + + test('throws when the query has multiple operations', async () => { + await expect(resolveBulkOperationQuery({query: 'query A { a } query B { b }'})).rejects.toThrow(AbortError) + }) +}) diff --git a/packages/cli-kit/src/public/node/api/bulk-operations/query.ts b/packages/cli-kit/src/public/node/api/bulk-operations/query.ts new file mode 100644 index 00000000000..f4184c1308a --- /dev/null +++ b/packages/cli-kit/src/public/node/api/bulk-operations/query.ts @@ -0,0 +1,59 @@ +import {validateSingleOperation} from './helpers.js' +import {AbortError, BugError} from '../../error.js' +import {fileExists, readFile} from '../../fs.js' +import {outputContent, outputToken} from '../../output.js' + +/** + * Inputs for resolving a bulk operation's GraphQL query. + */ +interface ResolveBulkOperationQueryInput { + /** Inline GraphQL operation string. */ + query?: string + /** Path to a file containing the GraphQL operation. */ + queryFile?: string +} + +/** + * Resolves the GraphQL operation for a bulk command from either an inline `--query` value or a + * `--query-file` path, validating that it's non-empty and contains exactly one operation. + * + * Centralizes the read-and-validate logic shared by the app and store bulk execute commands. + * + * @param input - The inline query and/or the query file path (exactly one is expected). + * @returns The validated GraphQL operation string. + * @throws AbortError if the value/file is empty or missing, or the operation is invalid. + * @throws BugError if neither input was provided (oclif's exactlyOne constraint should prevent this). + */ +export async function resolveBulkOperationQuery(input: ResolveBulkOperationQueryInput): Promise { + let query: string + + if (input.query !== undefined) { + if (!input.query.trim()) { + throw new AbortError('The --query flag value is empty. Please provide a valid GraphQL query or mutation.') + } + query = input.query + } else if (input.queryFile) { + if (!(await fileExists(input.queryFile))) { + throw new AbortError( + outputContent`Query file not found at ${outputToken.path(input.queryFile)}. Please check the path and try again.`, + ) + } + const fileContents = await readFile(input.queryFile, {encoding: 'utf8'}) + if (!fileContents.trim()) { + throw new AbortError( + outputContent`Query file at ${outputToken.path( + input.queryFile, + )} is empty. Please provide a valid GraphQL query or mutation.`, + ) + } + query = fileContents + } else { + throw new BugError( + 'Query should have been provided via --query or --query-file flags due to exactlyOne constraint. This indicates the oclif flag validation failed.', + ) + } + + validateSingleOperation(query) + + return query +} diff --git a/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/run-mutation.test.ts similarity index 94% rename from packages/app/src/cli/services/bulk-operations/run-mutation.test.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/run-mutation.test.ts index 20f320d286c..8a38609d704 100644 --- a/packages/app/src/cli/services/bulk-operations/run-mutation.test.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/run-mutation.test.ts @@ -1,9 +1,9 @@ import {runBulkOperationMutation} from './run-mutation.js' import {stageFile} from './stage-file.js' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {adminRequestDoc} from '../admin.js' import {describe, test, expect, vi, beforeEach} from 'vitest' -vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('../admin.js') vi.mock('./stage-file.js') describe('runBulkOperationMutation', () => { diff --git a/packages/app/src/cli/services/bulk-operations/run-mutation.ts b/packages/cli-kit/src/public/node/api/bulk-operations/run-mutation.ts similarity index 67% rename from packages/app/src/cli/services/bulk-operations/run-mutation.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/run-mutation.ts index d02cd2302f9..315babdbf18 100644 --- a/packages/app/src/cli/services/bulk-operations/run-mutation.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/run-mutation.ts @@ -3,9 +3,9 @@ import { BulkOperationRunMutation as BulkOperationRunMutationDoc, BulkOperationRunMutationMutation, BulkOperationRunMutationMutationVariables, -} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' -import {AdminSession} from '@shopify/cli-kit/node/session' +} from '../../../../cli/api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js' +import {adminRequestDoc} from '../admin.js' +import {AdminSession} from '../../session.js' interface BulkOperationRunMutationOptions { adminSession: AdminSession @@ -14,6 +14,12 @@ interface BulkOperationRunMutationOptions { version?: string } +/** + * Stages a JSONL variables file then starts a bulk mutation operation on the store. + * + * @param options - The admin session, mutation, JSONL variables, and optional API version. + * @returns The bulkOperationRunMutation result, including the created operation and any user errors. + */ export async function runBulkOperationMutation( options: BulkOperationRunMutationOptions, ): Promise { diff --git a/packages/app/src/cli/services/bulk-operations/run-query.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/run-query.test.ts similarity index 93% rename from packages/app/src/cli/services/bulk-operations/run-query.test.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/run-query.test.ts index cf40a9e40a5..83e2111495d 100644 --- a/packages/app/src/cli/services/bulk-operations/run-query.test.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/run-query.test.ts @@ -1,8 +1,8 @@ import {runBulkOperationQuery} from './run-query.js' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {adminRequestDoc} from '../admin.js' import {describe, test, expect, vi} from 'vitest' -vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('../admin.js') describe('runBulkOperationQuery', () => { const mockSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} diff --git a/packages/app/src/cli/services/bulk-operations/run-query.ts b/packages/cli-kit/src/public/node/api/bulk-operations/run-query.ts similarity index 60% rename from packages/app/src/cli/services/bulk-operations/run-query.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/run-query.ts index 404361e11db..537b0c74b97 100644 --- a/packages/app/src/cli/services/bulk-operations/run-query.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/run-query.ts @@ -1,9 +1,9 @@ import { BulkOperationRunQuery, BulkOperationRunQueryMutation, -} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' -import {AdminSession} from '@shopify/cli-kit/node/session' +} from '../../../../cli/api/graphql/bulk-operations/generated/bulk-operation-run-query.js' +import {adminRequestDoc} from '../admin.js' +import {AdminSession} from '../../session.js' interface BulkOperationRunQueryOptions { adminSession: AdminSession @@ -11,6 +11,12 @@ interface BulkOperationRunQueryOptions { version?: string } +/** + * Starts a bulk query operation on the store. + * + * @param options - The admin session, query, and optional API version. + * @returns The bulkOperationRunQuery result, including the created operation and any user errors. + */ export async function runBulkOperationQuery( options: BulkOperationRunQueryOptions, ): Promise { diff --git a/packages/app/src/cli/services/bulk-operations/stage-file.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/stage-file.test.ts similarity index 89% rename from packages/app/src/cli/services/bulk-operations/stage-file.test.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/stage-file.test.ts index 3366598a366..70515518d3e 100644 --- a/packages/app/src/cli/services/bulk-operations/stage-file.test.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/stage-file.test.ts @@ -1,13 +1,13 @@ import {stageFile} from './stage-file.js' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' -import {fetch} from '@shopify/cli-kit/node/http' -import {renderSingleTask, RenderSingleTaskOptions} from '@shopify/cli-kit/node/ui' +import {adminRequestDoc} from '../admin.js' +import {fetch} from '../../http.js' +import {renderSingleTask, RenderSingleTaskOptions} from '../../ui.js' import {describe, test, expect, vi, beforeEach} from 'vitest' -vi.mock('@shopify/cli-kit/node/api/admin') -vi.mock('@shopify/cli-kit/node/session') -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/ui') +vi.mock('../admin.js') +vi.mock('../../session.js') +vi.mock('../../http.js') +vi.mock('../../ui.js') describe('stageFile', () => { const mockSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} diff --git a/packages/app/src/cli/services/bulk-operations/stage-file.ts b/packages/cli-kit/src/public/node/api/bulk-operations/stage-file.ts similarity index 84% rename from packages/app/src/cli/services/bulk-operations/stage-file.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/stage-file.ts index 59526ff022f..50fedd55a7e 100644 --- a/packages/app/src/cli/services/bulk-operations/stage-file.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/stage-file.ts @@ -2,19 +2,25 @@ import { StagedUploadsCreate, StagedUploadsCreateMutation, StagedUploadsCreateMutationVariables, -} from '../../api/graphql/bulk-operations/generated/staged-uploads-create.js' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' -import {AdminSession} from '@shopify/cli-kit/node/session' -import {fetch} from '@shopify/cli-kit/node/http' -import {AbortError} from '@shopify/cli-kit/node/error' -import {outputContent} from '@shopify/cli-kit/node/output' -import {renderSingleTask} from '@shopify/cli-kit/node/ui' +} from '../../../../cli/api/graphql/bulk-operations/generated/staged-uploads-create.js' +import {adminRequestDoc} from '../admin.js' +import {AdminSession} from '../../session.js' +import {fetch} from '../../http.js' +import {AbortError} from '../../error.js' +import {outputContent} from '../../output.js' +import {renderSingleTask} from '../../ui.js' interface StageFileOptions { adminSession: AdminSession variablesJsonl?: string } +/** + * Uploads bulk mutation variables to a staged upload target and returns the staged upload key. + * + * @param options - The admin session and JSONL variables. + * @returns The staged upload key used as `stagedUploadPath` for the bulk mutation. + */ export async function stageFile(options: StageFileOptions): Promise { const {adminSession, variablesJsonl} = options diff --git a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts b/packages/cli-kit/src/public/node/api/bulk-operations/watch-bulk-operation.test.ts similarity index 95% rename from packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/watch-bulk-operation.test.ts index 5dfb9a98c8f..5ffc8d9e0b0 100644 --- a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/watch-bulk-operation.test.ts @@ -5,17 +5,17 @@ import { QUICK_WATCH_TIMEOUT_MS, } from './watch-bulk-operation.js' import {formatBulkOperationStatus} from './format-bulk-operation-status.js' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' -import {sleep} from '@shopify/cli-kit/node/system' -import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {adminRequestDoc} from '../admin.js' +import {sleep} from '../../system.js' +import {renderSingleTask} from '../../ui.js' +import {outputContent} from '../../output.js' +import {AbortController} from '../../abort.js' import {describe, test, expect, vi, beforeEach} from 'vitest' -import {outputContent} from '@shopify/cli-kit/node/output' -import {AbortController} from '@shopify/cli-kit/node/abort' vi.mock('./format-bulk-operation-status.js') -vi.mock('@shopify/cli-kit/node/api/admin') -vi.mock('@shopify/cli-kit/node/system') -vi.mock('@shopify/cli-kit/node/ui') +vi.mock('../admin.js') +vi.mock('../../system.js') +vi.mock('../../ui.js') describe('watchBulkOperation', () => { const mockAdminSession = {token: 'test-token', storeFqdn: 'test.myshopify.com'} diff --git a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts b/packages/cli-kit/src/public/node/api/bulk-operations/watch-bulk-operation.ts similarity index 71% rename from packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts rename to packages/cli-kit/src/public/node/api/bulk-operations/watch-bulk-operation.ts index 7bf52053a8d..b2900bd6b97 100644 --- a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts +++ b/packages/cli-kit/src/public/node/api/bulk-operations/watch-bulk-operation.ts @@ -3,13 +3,13 @@ import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' import { GetBulkOperationById, GetBulkOperationByIdQuery, -} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' -import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' -import {sleep} from '@shopify/cli-kit/node/system' -import {AdminSession} from '@shopify/cli-kit/node/session' -import {outputContent} from '@shopify/cli-kit/node/output' -import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {AbortSignal} from '@shopify/cli-kit/node/abort' +} from '../../../../cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import {adminRequestDoc} from '../admin.js' +import {sleep} from '../../system.js' +import {AdminSession} from '../../session.js' +import {outputContent} from '../../output.js' +import {renderSingleTask} from '../../ui.js' +import {AbortSignal} from '../../abort.js' const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED'] const INITIAL_POLL_INTERVAL_SECONDS = 1 @@ -21,6 +21,14 @@ export const QUICK_WATCH_POLL_INTERVAL_MS = 300 export type BulkOperation = NonNullable +/** + * Polls a bulk operation briefly to surface its initial state, returning quickly so the caller can + * keep working while the operation runs in the background. + * + * @param adminSession - The admin session. + * @param operationId - The bulk operation ID to poll. + * @returns The latest known operation state. + */ export async function shortBulkOperationPoll(adminSession: AdminSession, operationId: string): Promise { return renderSingleTask({ title: outputContent`Starting bulk operation`, @@ -48,6 +56,16 @@ export async function shortBulkOperationPoll(adminSession: AdminSession, operati }) } +/** + * Polls a bulk operation until it reaches a terminal state or is aborted, updating the rendered + * status as it progresses. + * + * @param adminSession - The admin session. + * @param operationId - The bulk operation ID to watch. + * @param abortSignal - Signal used to stop watching early. + * @param onAbort - Callback invoked when the user aborts. + * @returns The latest known operation state. + */ export async function watchBulkOperation( adminSession: AdminSession, operationId: string, @@ -86,11 +104,11 @@ interface PollBulkOperationOptions { adminSession: AdminSession operationId: string pollIntervalSeconds: number - /** When true, polls faster initially then slows to pollIntervalSeconds */ + /** When true, polls faster initially then slows to pollIntervalSeconds. */ useAdaptivePolling?: boolean - /** Poll interval in seconds for initial fast polls (default: 1) */ + /** Poll interval in seconds for initial fast polls (default: 1). */ initialPollIntervalSeconds?: number - /** Number of fast polls before switching to regular interval (default: 10) */ + /** Number of fast polls before switching to regular interval (default: 10). */ initialPollCount?: number abortSignal?: AbortSignal } @@ -129,11 +147,17 @@ async function* pollBulkOperation({ } if (abortSignal) { + // Remove the abort listener after each race so the common case (sleep wins) doesn't leak a + // listener per poll, which would otherwise trigger MaxListenersExceededWarning on long watches. + let removeAbortListener: (() => void) | undefined + const abortPromise = new Promise((resolve) => { + const handler = () => resolve() + abortSignal.addEventListener('abort', handler, {once: true}) + removeAbortListener = () => abortSignal.removeEventListener('abort', handler) + }) // eslint-disable-next-line no-await-in-loop - await Promise.race([ - sleep(pollInterval), - new Promise((resolve) => abortSignal.addEventListener('abort', resolve)), - ]) + await Promise.race([sleep(pollInterval), abortPromise]) + removeAbortListener?.() } else { // eslint-disable-next-line no-await-in-loop await sleep(pollInterval)