Skip to content

Commit 55d7fed

Browse files
simple progress tracking
1 parent d7e721b commit 55d7fed

File tree

3 files changed

+98
-19
lines changed

3 files changed

+98
-19
lines changed

packages/store/src/cli/commands/store/execute.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,56 @@
1+
import {parseGraphQLOperation} from '../../services/graphql-parser.js'
2+
import {runBulkQuery} from '../../services/bulk-operations.js'
13
import {Command, Flags} from '@oclif/core'
24
import {globalFlags} from '@shopify/cli-kit/node/cli'
3-
import {readFile} from '@shopify/cli-kit/node/fs'
5+
import {readFile, writeFile} from '@shopify/cli-kit/node/fs'
46
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
57
import {adminRequest} from '@shopify/cli-kit/node/api/admin'
6-
import {parseGraphQLOperation} from '../../services/graphql-parser.js'
7-
import {runBulkQuery} from '../../services/bulk-operations.js'
8+
import {outputInfo, outputSuccess} from '@shopify/cli-kit/node/output'
89

910
export default class Execute extends Command {
1011
static summary = 'execute a graphql query or mutation on a store'
1112

12-
static description = 'executes a graphql query or mutation on the specified store, and writes the result to stdout or a file. supports bulk operations.'
13+
static description =
14+
'executes a graphql query or mutation on the specified store, and writes the result to stdout or a file. supports bulk operations.'
1315

1416
static flags = {
1517
...globalFlags,
1618
query: Flags.string({
1719
char: 'q',
1820
description: 'the graphql query or mutation, as a string',
1921
exclusive: ['query-file'],
22+
env: 'SHOPIFY_FLAG_QUERY',
2023
}),
2124
'query-file': Flags.string({
2225
description: 'a file containing the graphql query or mutation',
2326
exclusive: ['query'],
27+
env: 'SHOPIFY_FLAG_QUERY_FILE',
2428
}),
2529
store: Flags.string({
2630
char: 's',
2731
description: 'the myshopify.com domain of the store',
32+
env: 'SHOPIFY_FLAG_STORE',
2833
}),
2934
variables: Flags.string({
3035
char: 'v',
3136
description: 'the values for graphql variables, in json format',
3237
multiple: true,
3338
exclusive: ['variable-file'],
39+
env: 'SHOPIFY_FLAG_VARIABLES',
3440
}),
3541
'variable-file': Flags.string({
3642
description: 'a file containing graphql variables, in jsonl format',
3743
exclusive: ['variables'],
44+
env: 'SHOPIFY_FLAG_VARIABLE_FILE',
3845
}),
3946
'output-file': Flags.string({
4047
description: 'the file name where results should be written',
48+
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
4149
}),
4250
'bulk-operation': Flags.boolean({
4351
description: 'execute as a bulk operation',
4452
default: false,
53+
env: 'SHOPIFY_FLAG_BULK_OPERATION',
4554
}),
4655
}
4756

@@ -63,15 +72,17 @@ export default class Execute extends Command {
6372
this.error('--store is required')
6473
}
6574

66-
let variables: Record<string, unknown> | undefined
75+
let variables: {[key: string]: unknown} | undefined
6776

6877
if (flags.variables && flags.variables.length > 0) {
6978
const variableString = flags.variables[0]
7079
if (variableString) {
7180
try {
7281
variables = JSON.parse(variableString)
82+
/* eslint-disable-next-line no-catch-all/no-catch-all */
7383
} catch (error) {
74-
this.error(`invalid json in --variables: ${error}`)
84+
const message = error instanceof Error ? error.message : String(error)
85+
this.error(`invalid json in --variables: ${message}`)
7586
}
7687
}
7788
} else if (flags['variable-file']) {
@@ -80,8 +91,10 @@ export default class Execute extends Command {
8091
if (firstLine) {
8192
try {
8293
variables = JSON.parse(firstLine)
94+
/* eslint-disable-next-line no-catch-all/no-catch-all */
8395
} catch (error) {
84-
this.error(`invalid json in --variable-file: ${error}`)
96+
const message = error instanceof Error ? error.message : String(error)
97+
this.error(`invalid json in --variable-file: ${message}`)
8598
}
8699
}
87100
}
@@ -92,9 +105,19 @@ export default class Execute extends Command {
92105
const operationType = parseGraphQLOperation(query)
93106

94107
if (operationType === 'query') {
95-
this.log('starting bulk query...')
96-
const results = await runBulkQuery(query, adminSession)
97-
this.log(results)
108+
const result = await runBulkQuery(query, adminSession, (status, objectCount, rate, spinner) => {
109+
const rateStr = rate > 0 ? ` • ${Math.round(rate)} obj/sec` : ''
110+
process.stdout.write(`\r\x1b[K${status.toLowerCase()}: ${objectCount} objects${rateStr} ${spinner}`)
111+
})
112+
113+
const outputFilePath = 'bulk-operation-results.jsonl'
114+
await writeFile(outputFilePath, result.content)
115+
116+
this.log('\n')
117+
outputSuccess(`wrote ${result.totalObjects} objects to ${outputFilePath}`)
118+
outputInfo(
119+
`completed in ${result.totalTimeSeconds.toFixed(1)}s (${Math.round(result.averageRate)} obj/sec average)`,
120+
)
98121
} else {
99122
this.error('bulk mutations not yet implemented')
100123
}

packages/store/src/cli/services/bulk-operations.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import {AdminSession} from '@shopify/cli-kit/node/session'
22
import {adminRequest} from '@shopify/cli-kit/node/api/admin'
33
import {sleep} from '@shopify/cli-kit/node/system'
44
import {fetch} from '@shopify/cli-kit/node/http'
5+
import {outputInfo} from '@shopify/cli-kit/node/output'
56

7+
/* eslint-disable @shopify/cli/no-inline-graphql */
68
const BULK_QUERY_MUTATION = `
79
mutation bulkOperationRunQuery($query: String!) {
810
bulkOperationRunQuery(query: $query) {
@@ -29,6 +31,20 @@ const BULK_OPERATION_STATUS_QUERY = `
2931
}
3032
}
3133
`
34+
/* eslint-enable @shopify/cli/no-inline-graphql */
35+
36+
interface BulkOperationStartResult {
37+
bulkOperationRunQuery: {
38+
bulkOperation: {
39+
id: string
40+
status: string
41+
}
42+
userErrors: {
43+
field: string
44+
message: string
45+
}[]
46+
}
47+
}
3248

3349
interface BulkOperationStatus {
3450
currentBulkOperation: {
@@ -43,30 +59,72 @@ interface BulkOperationStatus {
4359
export async function runBulkQuery(
4460
query: string,
4561
session: AdminSession,
46-
): Promise<string> {
47-
const startResult = await adminRequest<any>(BULK_QUERY_MUTATION, session, {query})
62+
onProgress?: (status: string, objectCount: string, rate: number, spinner: string) => void,
63+
): Promise<{content: string; totalObjects: number; totalTimeSeconds: number; averageRate: number}> {
64+
const startResult = await adminRequest<BulkOperationStartResult>(BULK_QUERY_MUTATION, session, {query})
4865

4966
if (startResult.bulkOperationRunQuery.userErrors.length > 0) {
50-
const errors = startResult.bulkOperationRunQuery.userErrors.map((e: any) => e.message).join(', ')
67+
const errors = startResult.bulkOperationRunQuery.userErrors.map((err) => err.message).join(', ')
5168
throw new Error(`bulk operation failed: ${errors}`)
5269
}
5370

71+
outputInfo(`bulk operation started: ${startResult.bulkOperationRunQuery.bulkOperation.id}`)
72+
73+
const operationStartTime = Date.now()
74+
let lastObjectCount = '0'
75+
let lastUpdateTime = Date.now()
76+
let rate = 0
77+
const spinnerFrames = ['.', '..', '...']
78+
let spinnerIndex = 0
79+
80+
/* eslint-disable no-await-in-loop */
5481
while (true) {
55-
await sleep(1000)
82+
await sleep(1)
5683

5784
const statusResult = await adminRequest<BulkOperationStatus>(BULK_OPERATION_STATUS_QUERY, session)
5885
const operation = statusResult.currentBulkOperation
5986

87+
if (operation.status === 'CREATED' || operation.status === 'RUNNING') {
88+
const currentCount = parseInt(operation.objectCount, 10)
89+
const lastCount = parseInt(lastObjectCount, 10)
90+
const timeDiff = (Date.now() - lastUpdateTime) / 1000
91+
92+
if (timeDiff > 0 && currentCount > lastCount) {
93+
rate = (currentCount - lastCount) / timeDiff
94+
lastUpdateTime = Date.now()
95+
}
96+
97+
lastObjectCount = operation.objectCount
98+
99+
const spinner = spinnerFrames[spinnerIndex] ?? '...'
100+
if (onProgress) {
101+
onProgress(operation.status, operation.objectCount, rate, spinner)
102+
}
103+
spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length
104+
}
105+
60106
if (operation.status === 'COMPLETED') {
61107
if (!operation.url) {
62108
throw new Error('bulk operation completed but no results url')
63109
}
110+
const totalTimeSeconds = (Date.now() - operationStartTime) / 1000
111+
const totalObjects = parseInt(operation.objectCount, 10)
112+
const averageRate = totalObjects / totalTimeSeconds
113+
64114
const response = await fetch(operation.url)
65-
return await response.text()
115+
const content = await response.text()
116+
117+
return {
118+
content,
119+
totalObjects,
120+
totalTimeSeconds,
121+
averageRate,
122+
}
66123
}
67124

68125
if (operation.status === 'FAILED') {
69126
throw new Error(`bulk operation failed: ${operation.errorCode}`)
70127
}
71128
}
129+
/* eslint-enable no-await-in-loop */
72130
}

packages/store/src/cli/services/graphql-parser.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import {parse, OperationDefinitionNode, OperationTypeNode} from 'graphql'
1+
import {parse, OperationTypeNode} from 'graphql'
22

33
export function parseGraphQLOperation(document: string): OperationTypeNode {
44
const ast = parse(document)
5-
const operation = ast.definitions.find(
6-
(def) => def.kind === 'OperationDefinition',
7-
) as OperationDefinitionNode | undefined
5+
const operation = ast.definitions.find((def) => def.kind === 'OperationDefinition')
86

97
if (!operation) {
108
throw new Error('no operation found in graphql document')

0 commit comments

Comments
 (0)