diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts new file mode 100644 index 0000000000..5aa9386801 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type GetBulkOperationByIdQueryVariables = Types.Exact<{ + id: Types.Scalars['ID']['input'] +}> + +export type GetBulkOperationByIdQuery = { + bulkOperation?: { + completedAt?: unknown | null + createdAt: unknown + errorCode?: Types.BulkOperationErrorCode | null + id: string + objectCount: unknown + status: Types.BulkOperationStatus + url?: string | null + } | null +} + +export const GetBulkOperationById = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'GetBulkOperationById'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'bulkOperation'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'id'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'completedAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'errorCode'}}, + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'objectCount'}}, + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'url'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql b/packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql new file mode 100644 index 0000000000..3913561824 --- /dev/null +++ b/packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql @@ -0,0 +1,11 @@ +query GetBulkOperationById($id: ID!) { + bulkOperation(id: $id) { + completedAt + createdAt + errorCode + id + objectCount + status + url + } +} diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 2ee7fee7e4..2b352d3dca 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -40,6 +40,7 @@ export default class Execute extends AppLinkedCommand { query: flags.query, variables: flags.variables, variableFile: flags['variable-file'], + watch: flags.watch, }) return {app: appContextResult.app} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index ee68dbb5b0..3eaade3219 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -63,4 +63,9 @@ export const bulkOperationFlags = { env: 'SHOPIFY_FLAG_STORE', parse: async (input) => normalizeStoreFqdn(input), }), + watch: Flags.boolean({ + description: 'Wait for bulk operation results before exiting.', + env: 'SHOPIFY_FLAG_WATCH', + default: false, + }), } 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 b3e35e8aef..66266963dc 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,8 +1,11 @@ import {executeBulkOperation} from './execute-bulk-operation.js' import {runBulkOperationQuery} from './run-query.js' import {runBulkOperationMutation} from './run-mutation.js' +import {watchBulkOperation} from './watch-bulk-operation.js' import {AppLinkedInterface} from '../../models/app/app.js' -import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +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 {renderSuccess, renderWarning, renderError} from '@shopify/cli-kit/node/ui' import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' @@ -10,6 +13,7 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' vi.mock('./run-query.js') vi.mock('./run-mutation.js') +vi.mock('./watch-bulk-operation.js') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/session') @@ -21,7 +25,9 @@ describe('executeBulkOperation', () => { const storeFqdn = 'test-store.myshopify.com' const mockAdminSession = {token: 'test-token', storeFqdn} - const successfulBulkOperation = { + const createdBulkOperation: NonNullable< + NonNullable['bulkOperation'] + > = { id: 'gid://shopify/BulkOperation/123', status: 'CREATED', errorCode: null, @@ -29,6 +35,11 @@ describe('executeBulkOperation', () => { objectCount: '0', fileSize: '0', url: null, + query: '{ products { edges { node { id } } } }', + rootObjectCount: '0', + type: 'QUERY', + completedAt: null, + partialDataUrl: null, } beforeEach(() => { @@ -37,11 +48,11 @@ describe('executeBulkOperation', () => { test('runs query operation when GraphQL document starts with query', async () => { const query = 'query { products { edges { node { id } } } }' - const mockResponse = { - bulkOperation: successfulBulkOperation, + const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, userErrors: [], } - vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any) + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) await executeBulkOperation({ app: mockApp, @@ -58,11 +69,11 @@ describe('executeBulkOperation', () => { test('runs query operation when GraphQL document starts with curly brace', async () => { const query = '{ products { edges { node { id } } } }' - const mockResponse = { - bulkOperation: successfulBulkOperation, + const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, userErrors: [], } - vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any) + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) await executeBulkOperation({ app: mockApp, @@ -79,11 +90,11 @@ 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 mockResponse = { - bulkOperation: successfulBulkOperation, + const mockResponse: BulkOperationRunMutationMutation['bulkOperationRunMutation'] = { + bulkOperation: createdBulkOperation, userErrors: [], } - vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any) + vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse) await executeBulkOperation({ app: mockApp, @@ -102,11 +113,11 @@ 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 = { - bulkOperation: successfulBulkOperation, + const mockResponse: BulkOperationRunMutationMutation['bulkOperationRunMutation'] = { + bulkOperation: createdBulkOperation, userErrors: [], } - vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any) + vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse) await executeBulkOperation({ app: mockApp, @@ -124,33 +135,34 @@ describe('executeBulkOperation', () => { test('renders success message when bulk operation returns without user errors', async () => { const query = '{ products { edges { node { id } } } }' - const mockResponse = { - bulkOperation: successfulBulkOperation, + const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, userErrors: [], } - vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any) + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) await executeBulkOperation({ app: mockApp, storeFqdn, query, }) - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Bulk operation started successfully!', - body: 'Congrats!', - }) + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'Bulk operation started.', + }), + ) }) test('renders warning with formatted field errors when bulk operation returns user errors', async () => { const query = '{ products { edges { node { id } } } }' - const mockResponse = { + const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { bulkOperation: null, userErrors: [ - {field: ['query'], message: 'Invalid query syntax'}, - {field: null, message: 'Another error'}, + {field: ['query'], message: 'Invalid query syntax', code: null}, + {field: null, message: 'Another error', code: null}, ], } - vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse as any) + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) await executeBulkOperation({ app: mockApp, @@ -229,7 +241,7 @@ describe('executeBulkOperation', () => { const mutation = 'mutation productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' const mockResponse = { - bulkOperation: successfulBulkOperation, + bulkOperation: createdBulkOperation, userErrors: [], } vi.mocked(runBulkOperationMutation).mockResolvedValue(mockResponse as any) @@ -306,4 +318,70 @@ describe('executeBulkOperation', () => { expect(runBulkOperationMutation).not.toHaveBeenCalled() }) }) + + test('waits for operation to finish and renders success when watch is provided and operation finishes with COMPLETED status', async () => { + const query = '{ products { edges { node { id } } } }' + const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + const completedOperation = { + ...createdBulkOperation, + status: 'COMPLETED' as const, + url: 'https://example.com/download', + objectCount: '650', + } + + vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) + vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query, + watch: true, + }) + + expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id) + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: expect.stringContaining('Bulk operation succeeded:'), + body: expect.arrayContaining([expect.stringContaining('https://example.com/download')]), + }), + ) + }) + + test.each(['FAILED', 'CANCELED', 'EXPIRED'] as const)( + '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'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + const finishedOperation = { + ...createdBulkOperation, + status, + objectCount: '100', + } + + vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse) + vi.mocked(watchBulkOperation).mockResolvedValue(finishedOperation) + + await executeBulkOperation({ + app: mockApp, + storeFqdn, + query, + watch: true, + }) + + expect(watchBulkOperation).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id) + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({ + headline: expect.any(String), + customSections: expect.any(Array), + }), + ) + }, + ) }) 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 4e13691ad6..521456ac20 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,7 +1,9 @@ import {runBulkOperationQuery} from './run-query.js' import {runBulkOperationMutation} from './run-mutation.js' +import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js' +import {formatBulkOperationStatus} from './format-bulk-operation-status.js' import {AppLinkedInterface} from '../../models/app/app.js' -import {renderSuccess, renderInfo, renderWarning} from '@shopify/cli-kit/node/ui' +import {renderSuccess, renderInfo, renderError, renderWarning} from '@shopify/cli-kit/node/ui' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session' import {AbortError} from '@shopify/cli-kit/node/error' @@ -14,6 +16,7 @@ interface ExecuteBulkOperationInput { query: string variables?: string[] variableFile?: string + watch?: boolean } async function parseVariablesToJsonl(variables?: string[], variableFile?: string): Promise { @@ -34,7 +37,7 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string } export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { - const {app, storeFqdn, query, variables, variableFile} = input + const {app, storeFqdn, query, variables, variableFile, watch = false} = input renderInfo({ headline: 'Starting bulk operation.', @@ -64,31 +67,45 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr return } - const result = bulkOperationResponse?.bulkOperation - if (result) { - const infoSections = [ - { - title: 'Bulk Operation Created', - body: [ - { - list: { - items: [ - outputContent`ID: ${outputToken.cyan(result.id)}`.value, - outputContent`Status: ${outputToken.yellow(result.status)}`.value, - outputContent`Created: ${outputToken.gray(String(result.createdAt))}`.value, - ], - }, - }, - ], - }, - ] - - renderInfo({customSections: infoSections}) - - renderSuccess({ - headline: 'Bulk operation started successfully!', - body: 'Congrats!', - }) + const createdOperation = bulkOperationResponse?.bulkOperation + if (createdOperation) { + if (watch) { + const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id) + renderBulkOperationResult(finishedOperation) + } else { + renderBulkOperationResult(createdOperation) + } + } +} + +function renderBulkOperationResult(operation: BulkOperation): void { + const headline = formatBulkOperationStatus(operation).value + const items = [ + outputContent`ID: ${outputToken.cyan(operation.id)}`.value, + outputContent`Status: ${outputToken.yellow(operation.status)}`.value, + outputContent`Created at: ${outputToken.gray(String(operation.createdAt))}`.value, + ...(operation.completedAt + ? [outputContent`Completed at: ${outputToken.gray(String(operation.completedAt))}`.value] + : []), + ] + + const customSections = [{body: [{list: {items}}]}] + + switch (operation.status) { + case 'CREATED': + renderSuccess({headline: 'Bulk operation started.', customSections}) + break + case 'COMPLETED': + if (operation.url) { + const downloadMessage = outputContent`Download results ${outputToken.link('here', operation.url)}.`.value + renderSuccess({headline, body: [downloadMessage], customSections}) + } else { + renderSuccess({headline, customSections}) + } + break + default: + renderError({headline, customSections}) + break } } diff --git a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts b/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts new file mode 100644 index 0000000000..a97fbbdadb --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.test.ts @@ -0,0 +1,74 @@ +import {formatBulkOperationStatus} from './format-bulk-operation-status.js' +import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import {describe, test, expect} from 'vitest' + +type BulkOperation = NonNullable + +function createMockOperation(overrides: Partial = {}): BulkOperation { + return { + id: 'gid://shopify/BulkOperation/123', + status: 'CREATED', + errorCode: null, + createdAt: '2024-01-01T00:00:00Z', + completedAt: null, + objectCount: '0', + url: null, + ...overrides, + } +} + +describe('formatBulkOperationStatus', () => { + test('formats RUNNING status with object count', () => { + const result = formatBulkOperationStatus(createMockOperation({status: 'RUNNING', objectCount: 42})) + expect(result.value).toContain('Bulk operation in progress...') + expect(result.value).toContain('(42 objects)') + }) + + test('formats CREATED status', () => { + const result = formatBulkOperationStatus(createMockOperation({status: 'CREATED'})) + expect(result.value).toBe('Starting...') + }) + + test('formats COMPLETED status', () => { + const result = formatBulkOperationStatus(createMockOperation({status: 'COMPLETED', objectCount: 100})) + expect(result.value).toContain('Bulk operation succeeded:') + expect(result.value).toContain('100 objects') + }) + + test('formats FAILED status with error code', () => { + const result = formatBulkOperationStatus( + createMockOperation({status: 'FAILED', objectCount: 10, errorCode: 'ACCESS_DENIED'}), + ) + expect(result.value).toContain('Bulk operation failed.') + expect(result.value).toContain('Error: ACCESS_DENIED') + }) + + test('formats FAILED status without error code', () => { + const result = formatBulkOperationStatus(createMockOperation({status: 'FAILED', objectCount: 10, errorCode: null})) + expect(result.value).toContain('Bulk operation failed.') + expect(result.value).toContain('Error: unknown') + }) + + test('formats CANCELING status', () => { + const result = formatBulkOperationStatus(createMockOperation({status: 'CANCELING', objectCount: 5})) + expect(result.value).toBe('Bulk operation canceling...') + }) + + test('formats CANCELED status', () => { + const result = formatBulkOperationStatus(createMockOperation({status: 'CANCELED', objectCount: 5})) + expect(result.value).toBe('Bulk operation canceled.') + }) + + test('formats EXPIRED status', () => { + const result = formatBulkOperationStatus(createMockOperation({status: 'EXPIRED'})) + expect(result.value).toBe('Bulk operation expired.') + }) + + test('formats unknown status', () => { + const result = formatBulkOperationStatus({ + ...createMockOperation(), + status: 'UNKNOWN_STATUS', + } as unknown as BulkOperation) + expect(result.value).toBe('Bulk operation status: UNKNOWN_STATUS') + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts b/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts new file mode 100644 index 0000000000..08af5687e5 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/format-bulk-operation-status.ts @@ -0,0 +1,29 @@ +import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' +import {outputContent, outputToken, TokenizedString} from '@shopify/cli-kit/node/output' + +export function formatBulkOperationStatus( + operation: NonNullable, +): TokenizedString { + switch (operation.status) { + case 'RUNNING': + return outputContent`Bulk operation in progress... ${outputToken.gray( + `(${String(operation.objectCount)} objects)`, + )}` + case 'CREATED': + return outputContent`Starting...` + case 'COMPLETED': + return outputContent`Bulk operation succeeded: ${outputToken.gray(`${String(operation.objectCount)} objects`)}` + case 'FAILED': + return outputContent`Bulk operation failed. ${outputToken.errorText( + `Error: ${operation.errorCode ?? 'unknown'}`, + )}` + case 'CANCELING': + return outputContent`Bulk operation canceling...` + case 'CANCELED': + return outputContent`Bulk operation canceled.` + case 'EXPIRED': + return outputContent`Bulk operation expired.` + default: + return outputContent`Bulk operation status: ${operation.status}` + } +} diff --git a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts new file mode 100644 index 0000000000..7a94c77151 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts @@ -0,0 +1,116 @@ +import {watchBulkOperation} 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 {describe, test, expect, vi, beforeEach} from 'vitest' +import {outputContent} from '@shopify/cli-kit/node/output' + +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') + +describe('watchBulkOperation', () => { + const mockAdminSession = {token: 'test-token', storeFqdn: 'test.myshopify.com'} + const operationId = 'gid://shopify/BulkOperation/123' + + const runningOperation = { + id: operationId, + status: 'RUNNING', + objectCount: '50', + url: null, + } + + const completedOperation = { + id: operationId, + status: 'COMPLETED', + objectCount: '100', + url: 'https://example.com/download', + } + + beforeEach(() => { + vi.mocked(sleep).mockResolvedValue() + vi.mocked(formatBulkOperationStatus).mockReturnValue(outputContent`formatted status`) + }) + + test('polls until operation completes and returns the final operation', async () => { + vi.mocked(adminRequestDoc) + .mockResolvedValueOnce({bulkOperation: runningOperation}) + .mockResolvedValueOnce({bulkOperation: runningOperation}) + .mockResolvedValueOnce({bulkOperation: completedOperation}) + + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(() => {}) + }) + + const result = await watchBulkOperation(mockAdminSession, operationId) + + expect(result).toEqual(completedOperation) + expect(adminRequestDoc).toHaveBeenCalledTimes(3) + }) + + test.each(['FAILED', 'CANCELED', 'EXPIRED'])( + 'stops polling and returns when operation status is %s', + async (status) => { + const terminalOperation = { + id: operationId, + status, + objectCount: '25', + url: null, + } + + vi.mocked(adminRequestDoc) + .mockResolvedValueOnce({bulkOperation: runningOperation}) + .mockResolvedValueOnce({bulkOperation: runningOperation}) + .mockResolvedValueOnce({bulkOperation: terminalOperation}) + + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(() => {}) + }) + + const result = await watchBulkOperation(mockAdminSession, operationId) + + expect(result).toEqual(terminalOperation) + expect(adminRequestDoc).toHaveBeenCalledTimes(3) + }, + ) + + test('updates the UI with latest operation status as polling progresses', async () => { + const runningOperation1 = {...runningOperation, objectCount: '10'} + const runningOperation2 = {...runningOperation, objectCount: '20'} + const runningOperation3 = {...runningOperation, objectCount: '30'} + + vi.mocked(formatBulkOperationStatus) + .mockReturnValueOnce(outputContent`processed 10 objects`) + .mockReturnValueOnce(outputContent`processed 20 objects`) + .mockReturnValueOnce(outputContent`processed 30 objects`) + + vi.mocked(adminRequestDoc) + .mockResolvedValueOnce({bulkOperation: runningOperation1}) + .mockResolvedValueOnce({bulkOperation: runningOperation2}) + .mockResolvedValueOnce({bulkOperation: runningOperation3}) + .mockResolvedValueOnce({bulkOperation: completedOperation}) + + const mockUpdateStatus = vi.fn() + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(mockUpdateStatus) + }) + + await watchBulkOperation(mockAdminSession, operationId) + + expect(mockUpdateStatus).toHaveBeenNthCalledWith(1, outputContent`processed 10 objects`) + expect(mockUpdateStatus).toHaveBeenNthCalledWith(2, outputContent`processed 20 objects`) + expect(mockUpdateStatus).toHaveBeenNthCalledWith(3, outputContent`processed 30 objects`) + }) + + test('throws when operation not found', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null}) + + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + return task(() => {}) + }) + + await expect(watchBulkOperation(mockAdminSession, operationId)).rejects.toThrow('bulk operation not found') + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts new file mode 100644 index 0000000000..e69e9e0c1b --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts @@ -0,0 +1,69 @@ +import {formatBulkOperationStatus} from './format-bulk-operation-status.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' + +const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED'] +const POLL_INTERVAL_SECONDS = 5 +const API_VERSION = '2026-01' + +export type BulkOperation = NonNullable + +export async function watchBulkOperation(adminSession: AdminSession, operationId: string): Promise { + return renderSingleTask({ + title: outputContent`Polling bulk operation...`, + task: async (updateStatus) => { + const poller = pollBulkOperation(adminSession, operationId) + + while (true) { + // eslint-disable-next-line no-await-in-loop + const {value: latestOperationState, done} = await poller.next() + if (done) { + return latestOperationState + } else { + updateStatus(formatBulkOperationStatus(latestOperationState)) + } + } + }, + }) +} + +async function* pollBulkOperation( + adminSession: AdminSession, + operationId: string, +): AsyncGenerator { + while (true) { + // eslint-disable-next-line no-await-in-loop + const response = await fetchBulkOperation(adminSession, operationId) + + if (!response.bulkOperation) { + throw new Error('bulk operation not found') + } + + const latestOperationState = response.bulkOperation + + if (TERMINAL_STATUSES.includes(latestOperationState.status)) { + return latestOperationState + } else { + yield latestOperationState + } + + // eslint-disable-next-line no-await-in-loop + await sleep(POLL_INTERVAL_SECONDS) + } +} + +async function fetchBulkOperation(adminSession: AdminSession, operationId: string): Promise { + return adminRequestDoc({ + query: GetBulkOperationById, + session: adminSession, + variables: {id: operationId}, + version: API_VERSION, + }) +} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 4a1dcdf50b..6952fe1159 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -915,6 +915,13 @@ "hidden": false, "name": "verbose", "type": "boolean" + }, + "watch": { + "allowNo": false, + "description": "Wait for bulk operation results before exiting.", + "env": "SHOPIFY_FLAG_WATCH", + "name": "watch", + "type": "boolean" } }, "hasDynamicHelp": false,