Skip to content

Commit ab6f464

Browse files
sketch out AbortController approach
1 parent a9a3e90 commit ab6f464

File tree

5 files changed

+97
-18
lines changed

5 files changed

+97
-18
lines changed

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

Lines changed: 24 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,19 @@ 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(adminSession, createdOperation.id, abortController.signal, () =>
82+
abortController.abort(),
83+
)
84+
85+
if (abortController.signal.aborted) {
86+
renderSuccess({
87+
headline: `Bulk operation ${operation.id} is still running in the background.`,
88+
body: [statusCommandHelpMessage(operation.id)],
89+
})
90+
} else {
91+
await renderBulkOperationResult(operation, outputFile)
92+
}
8193
} else {
8294
await renderBulkOperationResult(createdOperation, outputFile)
8395
}
@@ -105,7 +117,11 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
105117

106118
switch (operation.status) {
107119
case 'CREATED':
108-
renderSuccess({headline: 'Bulk operation started.', customSections})
120+
renderSuccess({
121+
headline: 'Bulk operation started.',
122+
body: [statusCommandHelpMessage(operation.id)],
123+
customSections,
124+
})
109125
break
110126
case 'COMPLETED':
111127
if (operation.url) {
@@ -147,6 +163,11 @@ function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: stri
147163
}
148164
}
149165

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

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {sleep} from '@shopify/cli-kit/node/system'
55
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
66
import {describe, test, expect, vi, beforeEach} from 'vitest'
77
import {outputContent} from '@shopify/cli-kit/node/output'
8+
import {AbortController} from '@shopify/cli-kit/node/abort'
89

910
vi.mock('./format-bulk-operation-status.js')
1011
vi.mock('@shopify/cli-kit/node/api/admin')
@@ -14,6 +15,7 @@ vi.mock('@shopify/cli-kit/node/ui')
1415
describe('watchBulkOperation', () => {
1516
const mockAdminSession = {token: 'test-token', storeFqdn: 'test.myshopify.com'}
1617
const operationId = 'gid://shopify/BulkOperation/123'
18+
let abortController: AbortController
1719

1820
const runningOperation = {
1921
id: operationId,
@@ -30,6 +32,7 @@ describe('watchBulkOperation', () => {
3032
}
3133

3234
beforeEach(() => {
35+
abortController = new AbortController()
3336
vi.mocked(sleep).mockResolvedValue()
3437
vi.mocked(formatBulkOperationStatus).mockReturnValue(outputContent`formatted status`)
3538
})
@@ -44,7 +47,9 @@ describe('watchBulkOperation', () => {
4447
return task(() => {})
4548
})
4649

47-
const result = await watchBulkOperation(mockAdminSession, operationId)
50+
const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () =>
51+
abortController.abort(),
52+
)
4853

4954
expect(result).toEqual(completedOperation)
5055
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
@@ -69,7 +74,9 @@ describe('watchBulkOperation', () => {
6974
return task(() => {})
7075
})
7176

72-
const result = await watchBulkOperation(mockAdminSession, operationId)
77+
const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () =>
78+
abortController.abort(),
79+
)
7380

7481
expect(result).toEqual(terminalOperation)
7582
expect(adminRequestDoc).toHaveBeenCalledTimes(3)
@@ -97,7 +104,7 @@ describe('watchBulkOperation', () => {
97104
return task(mockUpdateStatus)
98105
})
99106

100-
await watchBulkOperation(mockAdminSession, operationId)
107+
await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => abortController.abort())
101108

102109
expect(mockUpdateStatus).toHaveBeenNthCalledWith(1, outputContent`processed 10 objects`)
103110
expect(mockUpdateStatus).toHaveBeenNthCalledWith(2, outputContent`processed 20 objects`)
@@ -111,6 +118,28 @@ describe('watchBulkOperation', () => {
111118
return task(() => {})
112119
})
113120

114-
await expect(watchBulkOperation(mockAdminSession, operationId)).rejects.toThrow('bulk operation not found')
121+
await expect(
122+
watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => abortController.abort()),
123+
).rejects.toThrow('bulk operation not found')
124+
})
125+
126+
test('returns current state when signal is aborted during polling', async () => {
127+
let callCount = 0
128+
vi.mocked(adminRequestDoc).mockImplementation(async () => {
129+
callCount++
130+
if (callCount === 2) {
131+
abortController.abort()
132+
}
133+
return {bulkOperation: runningOperation}
134+
})
135+
136+
vi.mocked(renderSingleTask).mockImplementation(async ({task}) => {
137+
return task(() => {})
138+
})
139+
140+
const result = await watchBulkOperation(mockAdminSession, operationId, abortController.signal, () => {})
141+
142+
expect(result.status).toBe('RUNNING')
143+
expect(result).toEqual(runningOperation)
115144
})
116145
})

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: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
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, () => onCtrlC())
25+
} else {
26+
handleCtrlC(input, key)
27+
}
28+
},
29+
{isActive: Boolean(isRawModeSupported)},
30+
)
1931

2032
useEffect(() => {
2133
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)