Skip to content

Commit 6c091fc

Browse files
sketch out AbortController approach
1 parent a9a3e90 commit 6c091fc

File tree

5 files changed

+80
-18
lines changed

5 files changed

+80
-18
lines changed

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

Lines changed: 27 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

@@ -76,8 +77,22 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
7677
const createdOperation = bulkOperationResponse?.bulkOperation
7778
if (createdOperation) {
7879
if (watch) {
79-
const finishedOperation = await watchBulkOperation(adminSession, createdOperation.id)
80-
await renderBulkOperationResult(finishedOperation, outputFile)
80+
const abortController = new AbortController()
81+
const operation = await watchBulkOperation(
82+
adminSession,
83+
createdOperation.id,
84+
abortController.signal,
85+
() => abortController.abort(),
86+
)
87+
88+
if (abortController.signal.aborted) {
89+
renderSuccess({
90+
headline: `Bulk operation ${operation.id} is still running in the background.`,
91+
body: [statusCommandHelpMessage(operation.id)],
92+
})
93+
} else {
94+
await renderBulkOperationResult(operation, outputFile)
95+
}
8196
} else {
8297
await renderBulkOperationResult(createdOperation, outputFile)
8398
}
@@ -105,7 +120,11 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
105120

106121
switch (operation.status) {
107122
case 'CREATED':
108-
renderSuccess({headline: 'Bulk operation started.', customSections})
123+
renderSuccess({
124+
headline: 'Bulk operation started.',
125+
body: [statusCommandHelpMessage(operation.id)],
126+
customSections,
127+
})
109128
break
110129
case 'COMPLETED':
111130
if (operation.url) {
@@ -147,6 +166,11 @@ function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: stri
147166
}
148167
}
149168

169+
function statusCommandHelpMessage(operationId: string): string {
170+
const command = outputToken.cyan(`shopify app bulk status --id="${operationId}"`)
171+
return outputContent`Monitor its progress with:\n${command}`.value
172+
}
173+
150174
function isMutation(graphqlOperation: string): boolean {
151175
const document = parse(graphqlOperation)
152176
const operation = document.definitions.find((def) => def.kind === 'OperationDefinition')

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: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,43 @@ 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) {
2531
// eslint-disable-next-line no-await-in-loop
2632
const {value: latestOperationState, done} = await poller.next()
27-
if (done) {
33+
if (signal.aborted || done) {
2834
return latestOperationState
2935
} else {
3036
updateStatus(formatBulkOperationStatus(latestOperationState))
3137
}
3238
}
3339
},
40+
onCtrlC,
3441
})
3542
}
3643

3744
async function* pollBulkOperation(
3845
adminSession: AdminSession,
3946
operationId: string,
47+
signal: AbortSignal,
4048
): AsyncGenerator<BulkOperation, BulkOperation> {
4149
while (true) {
4250
// eslint-disable-next-line no-await-in-loop
@@ -48,14 +56,17 @@ async function* pollBulkOperation(
4856

4957
const latestOperationState = response.bulkOperation
5058

51-
if (TERMINAL_STATUSES.includes(latestOperationState.status)) {
59+
if (TERMINAL_STATUSES.includes(latestOperationState.status) || signal.aborted) {
5260
return latestOperationState
5361
} else {
5462
yield latestOperationState
5563
}
5664

5765
// eslint-disable-next-line no-await-in-loop
58-
await sleep(POLL_INTERVAL_SECONDS)
66+
await Promise.race([
67+
sleep(POLL_INTERVAL_SECONDS),
68+
new Promise((resolve) => signal.addEventListener('abort', resolve)),
69+
])
5970
}
6071
}
6172

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
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'
44
import React, {useEffect, useState} from 'react'
5-
import {useApp} from 'ink'
5+
import {useApp, useInput, useStdin} from 'ink'
66

77
interface SingleTaskProps<T> {
88
title: TokenizedString
99
task: (updateStatus: (status: TokenizedString) => void) => Promise<T>
1010
onComplete?: (result: T) => void
11+
onCtrlC?: () => void
1112
noColor?: boolean
1213
}
1314

14-
const SingleTask = <T,>({task, title, onComplete, noColor}: SingleTaskProps<T>) => {
15+
const SingleTask = <T,>({task, title, onComplete, onCtrlC, noColor}: SingleTaskProps<T>) => {
1516
const [status, setStatus] = useState(title)
1617
const [isDone, setIsDone] = useState(false)
1718
const {exit: unmountInk} = useApp()
18-
useExitOnCtrlC()
19+
const {isRawModeSupported} = useStdin()
20+
21+
useInput(
22+
(input, key) => {
23+
if (onCtrlC) {
24+
handleCtrlC(input, key, () => {
25+
onCtrlC()
26+
})
27+
} else {
28+
handleCtrlC(input, key)
29+
}
30+
},
31+
{isActive: Boolean(isRawModeSupported)},
32+
)
1933

2034
useEffect(() => {
2135
task(setStatus)

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ export async function renderTasks<TContext>(
490490
export interface RenderSingleTaskOptions<T> {
491491
title: TokenizedString
492492
task: (updateStatus: (status: TokenizedString) => void) => Promise<T>
493+
onCtrlC?: () => void
493494
renderOptions?: RenderOptions
494495
}
495496

@@ -504,10 +505,15 @@ export interface RenderSingleTaskOptions<T> {
504505
* ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
505506
* Loading app ...
506507
*/
507-
export async function renderSingleTask<T>({title, task, renderOptions}: RenderSingleTaskOptions<T>): Promise<T> {
508+
export async function renderSingleTask<T>({
509+
title,
510+
task,
511+
onCtrlC,
512+
renderOptions,
513+
}: RenderSingleTaskOptions<T>): Promise<T> {
508514
// eslint-disable-next-line max-params
509515
return new Promise<T>((resolve, reject) => {
510-
render(<SingleTask title={title} task={task} onComplete={resolve} />, {
516+
render(<SingleTask title={title} task={task} onComplete={resolve} onCtrlC={onCtrlC} />, {
511517
...renderOptions,
512518
exitOnCtrlC: false,
513519
}).catch(reject)

0 commit comments

Comments
 (0)