Skip to content

Commit 49a2db2

Browse files
sketch out AbortController approach
1 parent a9a3e90 commit 49a2db2

File tree

5 files changed

+106
-20
lines changed

5 files changed

+106
-20
lines changed

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {renderSuccess, renderInfo, renderError, renderWarning} from '@shopify/cl
88
import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'
99
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
1010
import {AbortError, BugError} from '@shopify/cli-kit/node/error'
11+
import {AbortController} from '@shopify/cli-kit/node/abort'
1112
import {parse} from 'graphql'
1213
import {readFile, writeFile, fileExists} from '@shopify/cli-kit/node/fs'
1314

@@ -38,6 +39,12 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string
3839
}
3940
}
4041

42+
function statusCommandHelpMessage(operationId: string): string {
43+
return outputContent`Monitor its progress with:\n${outputToken.cyan(
44+
`shopify app bulk status --id="${operationId}"`,
45+
)}`.value
46+
}
47+
4148
export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise<void> {
4249
const {remoteApp, storeFqdn, query, variables, variableFile, outputFile, watch = false} = input
4350

@@ -76,8 +83,24 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
7683
const createdOperation = bulkOperationResponse?.bulkOperation
7784
if (createdOperation) {
7885
if (watch) {
79-
const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id)
80-
await renderBulkOperationResult(finishedOperation, outputFile)
86+
const abortController = new AbortController()
87+
88+
const operation = await watchBulkOperation(
89+
adminSession,
90+
createdOperation.id,
91+
abortController.signal,
92+
() => abortController.abort(),
93+
)
94+
95+
if (abortController.signal.aborted) {
96+
renderSuccess({
97+
headline: `Bulk operation ${createdOperation.id} is still running in the background.`,
98+
body: [statusCommandHelpMessage(createdOperation.id)],
99+
})
100+
return
101+
}
102+
103+
await renderBulkOperationResult(operation, outputFile)
81104
} else {
82105
await renderBulkOperationResult(createdOperation, outputFile)
83106
}
@@ -105,7 +128,11 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
105128

106129
switch (operation.status) {
107130
case 'CREATED':
108-
renderSuccess({headline: 'Bulk operation started.', customSections})
131+
renderSuccess({
132+
headline: 'Bulk operation started.',
133+
body: [statusCommandHelpMessage(operation.id)],
134+
customSections,
135+
})
109136
break
110137
case 'COMPLETED':
111138
if (operation.url) {

packages/app/src/cli/services/bulk-operations/watch-bulk-operation.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
33
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
44
import {sleep} from '@shopify/cli-kit/node/system'
55
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
6+
import {AbortController} from '@shopify/cli-kit/node/abort'
67
import {describe, test, expect, vi, beforeEach} from 'vitest'
78
import {outputContent} from '@shopify/cli-kit/node/output'
89

@@ -44,7 +45,8 @@ describe('watchBulkOperation', () => {
4445
return task(() => {})
4546
})
4647

47-
const result = await watchBulkOperation(mockAdminSession, operationId)
48+
const abortController = new AbortController()
49+
const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})
4850

4951
expect(result).toEqual(completedOperation)
5052
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
@@ -69,7 +71,8 @@ describe('watchBulkOperation', () => {
6971
return task(() => {})
7072
})
7173

72-
const result = await watchBulkOperation(mockAdminSession, operationId)
74+
const abortController = new AbortController()
75+
const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})
7376

7477
expect(result).toEqual(terminalOperation)
7578
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
@@ -97,7 +100,8 @@ describe('watchBulkOperation', () => {
97100
return task(mockUpdateStatus)
98101
})
99102

100-
await watchBulkOperation(mockAdminSession, operationId)
103+
const abortController = new AbortController()
104+
await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})
101105

102106
expect(mockUpdateStatus).toHaveBeenNthCalledWith(1, outputContent`processed 10 objects`)
103107
expect(mockUpdateStatus).toHaveBeenNthCalledWith(2, outputContent`processed 20 objects`)
@@ -111,6 +115,9 @@ describe('watchBulkOperation', () => {
111115
return task(() => {})
112116
})
113117

114-
await expect(watchBulkOperation(mockAdminSession, operationId)).rejects.toThrow('bulk operation not found')
118+
const abortController = new AbortController()
119+
await expect(watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})).rejects.toThrow(
120+
'bulk operation not found',
121+
)
115122
})
116123
})

packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,62 @@ import {sleep} from '@shopify/cli-kit/node/system'
88
import {AdminSession} from '@shopify/cli-kit/node/session'
99
import {outputContent} from '@shopify/cli-kit/node/output'
1010
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
11+
import {AbortSignal} from '@shopify/cli-kit/node/abort'
1112

1213
const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED']
1314
const POLL_INTERVAL_SECONDS = 5
1415
const API_VERSION = '2026-01'
1516

1617
export type BulkOperation = NonNullable<GetBulkOperationByIdQuery['bulkOperation']>
1718

18-
export async function watchBulkOperation(adminSession: AdminSession, operationId: string): Promise<BulkOperation> {
19+
export async function watchBulkOperation(
20+
adminSession: AdminSession,
21+
operationId: string,
22+
signal: AbortSignal,
23+
onCtrlC: () => void,
24+
): Promise<BulkOperation> {
1925
return renderSingleTask<BulkOperation>({
2026
title: outputContent`Polling bulk operation...`,
2127
task: async (updateStatus) => {
22-
const poller = pollBulkOperation(adminSession, operationId)
28+
const poller = pollBulkOperation(adminSession, operationId, signal)
2329

2430
while (true) {
31+
if (signal.aborted) {
32+
// eslint-disable-next-line no-await-in-loop
33+
const {value} = await poller.next()
34+
return value
35+
}
36+
2537
// eslint-disable-next-line no-await-in-loop
26-
const {value: latestOperationState, done} = await poller.next()
38+
const {value, done} = await poller.next()
39+
2740
if (done) {
28-
return latestOperationState
41+
return value
2942
} else {
30-
updateStatus(formatBulkOperationStatus(latestOperationState))
43+
updateStatus(formatBulkOperationStatus(value))
3144
}
3245
}
3346
},
47+
signal,
48+
onCtrlC,
3449
})
3550
}
3651

3752
async function* pollBulkOperation(
3853
adminSession: AdminSession,
3954
operationId: string,
55+
signal: AbortSignal,
4056
): AsyncGenerator<BulkOperation, BulkOperation> {
4157
while (true) {
58+
if (signal.aborted) {
59+
// eslint-disable-next-line no-await-in-loop
60+
const response = await fetchBulkOperation(adminSession, operationId)
61+
if (!response.bulkOperation) {
62+
throw new Error('bulk operation not found')
63+
}
64+
return response.bulkOperation
65+
}
66+
4267
// eslint-disable-next-line no-await-in-loop
4368
const response = await fetchBulkOperation(adminSession, operationId)
4469

@@ -55,7 +80,10 @@ async function* pollBulkOperation(
5580
}
5681

5782
// eslint-disable-next-line no-await-in-loop
58-
await sleep(POLL_INTERVAL_SECONDS)
83+
await Promise.race([
84+
sleep(POLL_INTERVAL_SECONDS),
85+
new Promise((resolve) => signal.addEventListener('abort', resolve)),
86+
])
5987
}
6088
}
6189

packages/cli-kit/src/private/node/ui/components/SingleTask.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
11
import {LoadingBar} from './LoadingBar.js'
2-
import {useExitOnCtrlC} from '../hooks/use-exit-on-ctrl-c.js'
2+
import {handleCtrlC} from '../../ui.js'
33
import {TokenizedString} from '../../../../public/node/output.js'
4+
import {AbortSignal} from '../../../../public/node/abort.js'
45
import React, {useEffect, useState} from 'react'
5-
import {useApp} from 'ink'
6+
import {useApp, useInput, useStdin} from 'ink'
67

78
interface SingleTaskProps<T> {
89
title: TokenizedString
910
task: (updateStatus: (status: TokenizedString) => void) => Promise<T>
1011
onComplete?: (result: T) => void
12+
signal?: AbortSignal
13+
onCtrlC?: () => void
1114
noColor?: boolean
1215
}
1316

14-
const SingleTask = <T,>({task, title, onComplete, noColor}: SingleTaskProps<T>) => {
17+
const SingleTask = <T,>({task, title, onComplete, signal: _signal, onCtrlC, noColor}: SingleTaskProps<T>) => {
1518
const [status, setStatus] = useState(title)
1619
const [isDone, setIsDone] = useState(false)
1720
const {exit: unmountInk} = useApp()
18-
useExitOnCtrlC()
21+
const {isRawModeSupported} = useStdin()
22+
23+
useInput(
24+
(input, key) => {
25+
if (onCtrlC) {
26+
handleCtrlC(input, key, () => {
27+
onCtrlC()
28+
})
29+
} else {
30+
handleCtrlC(input, key)
31+
}
32+
},
33+
{isActive: Boolean(isRawModeSupported)},
34+
)
1935

2036
useEffect(() => {
2137
task(setStatus)

packages/cli-kit/src/public/node/ui.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import {AbortError, AbortSilentError, FatalError as Fatal} from './error.js'
44
import {outputContent, outputDebug, outputToken, TokenizedString} from './output.js'
55
import {terminalSupportsPrompting} from './system.js'
6-
import {AbortController} from './abort.js'
6+
import {AbortController, AbortSignal} from './abort.js'
77
import {runWithTimer} from './metadata.js'
88
import {ConcurrentOutput, ConcurrentOutputProps} from '../../private/node/ui/components/ConcurrentOutput.js'
99
import {handleCtrlC, render, renderOnce} from '../../private/node/ui.js'
@@ -490,6 +490,8 @@ export async function renderTasks<TContext>(
490490
export interface RenderSingleTaskOptions<T> {
491491
title: TokenizedString
492492
task: (updateStatus: (status: TokenizedString) => void) => Promise<T>
493+
signal?: AbortSignal
494+
onCtrlC?: () => void
493495
renderOptions?: RenderOptions
494496
}
495497

@@ -504,10 +506,16 @@ export interface RenderSingleTaskOptions<T> {
504506
* ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
505507
* Loading app ...
506508
*/
507-
export async function renderSingleTask<T>({title, task, renderOptions}: RenderSingleTaskOptions<T>): Promise<T> {
509+
export async function renderSingleTask<T>({
510+
title,
511+
task,
512+
signal,
513+
onCtrlC,
514+
renderOptions,
515+
}: RenderSingleTaskOptions<T>): Promise<T> {
508516
// eslint-disable-next-line max-params
509517
return new Promise<T>((resolve, reject) => {
510-
render(<SingleTask title={title} task={task} onComplete={resolve} />, {
518+
render(<SingleTask title={title} task={task} onComplete={resolve} signal={signal} onCtrlC={onCtrlC} />, {
511519
...renderOptions,
512520
exitOnCtrlC: false,
513521
}).catch(reject)

0 commit comments

Comments
 (0)