diff --git a/packages/app/src/cli/services/bulk-operations/bulk-operation.test-data.ts b/packages/app/src/cli/services/bulk-operations/bulk-operation.test-data.ts new file mode 100644 index 00000000000..6df5473a7ac --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/bulk-operation.test-data.ts @@ -0,0 +1,39 @@ +import { + testAppLinked, + testOrganization, + testOrganizationApp, + testOrganizationStore, + testProject, +} from '../../models/app/app.test-data.js' +import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' + +export function testBulkOperation(overrides: Partial = {}): BulkOperation { + return { + id: 'gid://shopify/BulkOperation/123', + type: 'QUERY', + status: 'RUNNING', + errorCode: null, + objectCount: '2', + createdAt: '2026-09-01T00:00:00Z', + completedAt: null, + url: null, + partialDataUrl: null, + ...overrides, + } +} + +export function testBulkOperationContext() { + const remoteApp = testOrganizationApp() + return { + appContextResult: { + app: testAppLinked(), + remoteApp, + developerPlatformClient: remoteApp.developerPlatformClient, + organization: testOrganization(), + specifications: [], + project: testProject(), + activeConfig: {} as never, + }, + store: testOrganizationStore({shopDomain: 'shop.myshopify.com'}), + } +} diff --git a/packages/app/src/cli/services/bulk-operations/common.test.ts b/packages/app/src/cli/services/bulk-operations/common.test.ts new file mode 100644 index 00000000000..a5f4720933f --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/common.test.ts @@ -0,0 +1,97 @@ +import {BulkOperationSchema, BulkOperationContextSchema, BulkOperationGidSchema} from './common.js' +import {bulkOperationJsonContext, toBulkOperationJson} from './json.js' +import {testBulkOperation} from './bulk-operation.test-data.js' +import {expect, test} from 'vitest' + +function projectedOperation() { + return { + gid: 'gid://shopify/BulkOperation/123', + type: 'QUERY', + status: 'RUNNING', + errorCode: null, + createdAt: '2026-09-01T00:00:00Z', + completedAt: null, + objectCount: '2', + url: null, + partialDataUrl: null, + } +} + +test('projects only selected fields and normalizes missing values and timestamps', () => { + expect( + toBulkOperationJson({ + id: 'gid://shopify/BulkOperation/123', + type: 'QUERY', + status: 'RUNNING', + objectCount: '2', + createdAt: '2026-09-01T02:00:00.999+02:00', + }), + ).toEqual(projectedOperation()) +}) + +test('preserves large counts and normalizes completion instants without rounding', () => { + const operation = testBulkOperation({ + objectCount: '900719925474099312345', + completedAt: '2026-09-01T02:01:00.999+02:00', + }) + expect(toBulkOperationJson(operation)).toEqual({ + ...projectedOperation(), + objectCount: '900719925474099312345', + completedAt: '2026-09-01T00:01:00Z', + }) +}) + +test('rejects unsafe counts rather than losing integer precision', () => { + expect(() => toBulkOperationJson(testBulkOperation({objectCount: Number.MAX_SAFE_INTEGER + 1}))).toThrow( + "can't be represented exactly", + ) +}) + +test.each([ + {field: 'gid', value: '123'}, + {field: 'createdAt', value: '2026-09-01T00:00:00.100Z'}, + {field: 'createdAt', value: '2026-09-01T02:00:00+02:00'}, + {field: 'completedAt', value: ''}, + {field: 'objectCount', value: 2}, + {field: 'objectCount', value: '-1'}, + {field: 'objectCount', value: '1.5'}, + {field: 'url', value: ''}, +])('rejects invalid $field with all other fields valid', ({field, value}) => { + expect(() => BulkOperationSchema.parse({...projectedOperation(), [field]: value})).toThrow() +}) + +test('rejects unselected fields while accepting upstream enum values', () => { + expect(() => BulkOperationSchema.parse({...projectedOperation(), token: 'secret'})).toThrow() + expect(() => + BulkOperationSchema.parse({ + ...projectedOperation(), + type: 'FUTURE_TYPE', + status: 'FUTURE_STATUS', + errorCode: 'FUTURE_ERROR', + }), + ).not.toThrow() +}) + +test.each([ + { + context: {store: 'shop.myshopify.com', apiVersion: '2026-01'}, + expected: {storeDomain: 'shop.myshopify.com', apiVersion: '2026-01'}, + }, + {context: {store: 'custom.example.com'}, expected: {storeDomain: null, apiVersion: null}}, + {context: {}, expected: {storeDomain: null, apiVersion: null}}, +])('maps context without inventing a canonical store domain: $context', ({context, expected}) => { + expect(bulkOperationJsonContext(context)).toEqual(expected) + expect(BulkOperationContextSchema.parse(expected)).toEqual(expected) +}) + +test('context rejects unknown fields and noncanonical store domains', () => { + expect(() => BulkOperationContextSchema.parse({storeDomain: 'custom.example.com', apiVersion: null})).toThrow() + expect(() => BulkOperationContextSchema.parse({storeDomain: null, apiVersion: null, token: 'secret'})).toThrow() +}) + +test.each(['gid://shopify/Product/123', 'gid://shopify/BulkOperation/123/extra'])( + 'rejects a GID outside the bulk-operation namespace: %s', + (gid) => { + expect(() => BulkOperationGidSchema.parse(gid)).toThrow() + }, +) diff --git a/packages/app/src/cli/services/bulk-operations/common.ts b/packages/app/src/cli/services/bulk-operations/common.ts new file mode 100644 index 00000000000..7726c6c81a6 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/common.ts @@ -0,0 +1,42 @@ +import {jsonOutputTimestampSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const BulkOperationGidSchema = zod + .string() + .regex(/^gid:\/\/shopify\/BulkOperation\/[^/]+$/) + .describe('The Shopify global ID of a bulk operation.') + +export const BulkOperationSchema = zod + .object({ + gid: BulkOperationGidSchema, + type: zod.string(), + status: zod.string(), + errorCode: zod.string().nullable(), + createdAt: jsonOutputTimestampSchema, + completedAt: jsonOutputTimestampSchema.nullable(), + objectCount: zod + .string() + .regex(/^\d+$/) + .describe('A nonnegative decimal count, without loss of integer precision.'), + url: zod.string().url().nullable(), + partialDataUrl: zod.string().url().nullable(), + }) + .strict() + +export const BulkOperationContextSchema = zod + .object({ + storeDomain: zod + .string() + .regex(/^[^.]+\.myshopify\.com$/) + .nullable(), + apiVersion: zod.string().nullable().describe('The API version selected for the request.'), + }) + .strict() + +export type BulkOperationJson = zod.infer +export type ListedBulkOperationJson = Omit + +export interface BulkOperationContext { + store?: string + apiVersion?: string +} diff --git a/packages/app/src/cli/services/bulk-operations/json.ts b/packages/app/src/cli/services/bulk-operations/json.ts new file mode 100644 index 00000000000..c695b67d2ca --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/json.ts @@ -0,0 +1,32 @@ +import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' +import {BugError} from '@shopify/cli-kit/node/error' +import {formatJsonOutputTimestamp} from '@shopify/cli-kit/node/json-output-schema' +import type {BulkOperation} from '@shopify/cli-kit/node/api/bulk-operations' +import type {BulkOperationContext, BulkOperationJson, ListedBulkOperationJson} from './common.js' + +export function bulkOperationJsonContext(result: BulkOperationContext) { + const handle = extractMyshopifyHandle(result.store) + return {storeDomain: handle ? `${handle}.myshopify.com` : null, apiVersion: result.apiVersion ?? null} +} + +export function toBulkOperationJson(operation: BulkOperation): BulkOperationJson +export function toBulkOperationJson(operation: Omit): ListedBulkOperationJson +export function toBulkOperationJson( + operation: BulkOperation | Omit, +): BulkOperationJson | ListedBulkOperationJson { + if (typeof operation.objectCount === 'number' && !Number.isSafeInteger(operation.objectCount)) { + throw new BugError("Shopify returned a bulk operation count that can't be represented exactly.") + } + + return { + gid: operation.id, + ...('type' in operation ? {type: operation.type} : {}), + status: operation.status, + errorCode: operation.errorCode ?? null, + createdAt: formatJsonOutputTimestamp(new Date(String(operation.createdAt))), + completedAt: operation.completedAt ? formatJsonOutputTimestamp(new Date(String(operation.completedAt))) : null, + objectCount: String(operation.objectCount), + url: operation.url ?? null, + partialDataUrl: operation.partialDataUrl ?? null, + } +} diff --git a/packages/app/src/cli/services/bulk-operations/progress.test.ts b/packages/app/src/cli/services/bulk-operations/progress.test.ts new file mode 100644 index 00000000000..06c482e7bd4 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/progress.test.ts @@ -0,0 +1,55 @@ +import {logBulkOperationStart} from './progress.js' +import {testBulkOperationContext} from './bulk-operation.test-data.js' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/ui') + +function context() { + const {appContextResult, store} = testBulkOperationContext() + return { + organization: appContextResult.organization, + remoteApp: appContextResult.remoteApp, + storeFqdn: store.shopDomain, + version: '2026-01', + operationId: 'gid://shopify/BulkOperation/123', + } +} + +test('preserves the text banner and its context', () => { + const input = context() + logBulkOperationStart('Starting bulk operation.', input, 'text') + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'Starting bulk operation.', + body: [ + { + list: { + items: [ + 'ID: gid://shopify/BulkOperation/123', + `Organization: ${input.organization.businessName}`, + `App: ${input.remoteApp.title}`, + 'Store: shop.myshopify.com', + 'API version: 2026-01', + ], + }, + }, + ], + }) +}) + +test('JSON diagnostics use the event channel on stderr without rendering a banner', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + runWithCommandEventsForCommand(['--json'], () => { + logBulkOperationStart('Starting bulk operation.', context(), 'json') + }) + expect(stdout()).toBe('') + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'info', + message: expect.stringContaining('Starting bulk operation.'), + }) + expect(renderInfo).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/bulk-operations/progress.ts b/packages/app/src/cli/services/bulk-operations/progress.ts new file mode 100644 index 00000000000..0f226aeefbc --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/progress.ts @@ -0,0 +1,23 @@ +import {formatOperationInfo} from '../graphql/common.js' +import {Organization, OrganizationApp} from '../../models/organization.js' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {outputInfo} from '@shopify/cli-kit/node/output' + +export function logBulkOperationStart( + headline: string, + context: { + organization: Organization + remoteApp: OrganizationApp + storeFqdn: string + version?: string + operationId?: string + }, + format: 'text' | 'json', +): void { + const items = [...(context.operationId ? [`ID: ${context.operationId}`] : []), ...formatOperationInfo(context)] + if (format === 'json') { + outputInfo(`${headline}\n${items.join('\n')}`) + return + } + renderInfo({headline, body: [{list: {items}}]}) +}