Skip to content

Commit 3faa882

Browse files
Implement shopify app bulk status subcommand
Allows users to check the status of a bulk operation by ID.
1 parent f00e623 commit 3faa882

File tree

7 files changed

+411
-0
lines changed

7 files changed

+411
-0
lines changed

packages/app/src/cli/api/graphql/bulk-operations/generated/get-bulk-operation-by-id.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type GetBulkOperationByIdQuery = {
1414
errorCode?: Types.BulkOperationErrorCode | null
1515
id: string
1616
objectCount: unknown
17+
partialDataUrl?: string | null
1718
status: Types.BulkOperationStatus
1819
url?: string | null
1920
} | null
@@ -54,6 +55,7 @@ export const GetBulkOperationById = {
5455
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
5556
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
5657
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
58+
{kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}},
5759
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
5860
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
5961
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},

packages/app/src/cli/api/graphql/bulk-operations/queries/get-bulk-operation-by-id.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ query GetBulkOperationById($id: ID!) {
55
errorCode
66
id
77
objectCount
8+
partialDataUrl
89
status
910
url
1011
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {appFlags} from '../../../flags.js'
2+
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
3+
import {linkedAppContext} from '../../../services/app-context.js'
4+
import {storeContext} from '../../../services/store-context.js'
5+
import {getBulkOperationStatus} from '../../../services/bulk-operations/bulk-operation-status.js'
6+
import {Flags} from '@oclif/core'
7+
import {globalFlags} from '@shopify/cli-kit/node/cli'
8+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
9+
10+
export default class BulkStatus extends AppLinkedCommand {
11+
static summary = 'Check the status of a bulk operation.'
12+
13+
static description = 'Check the status of a bulk operation by ID.'
14+
15+
static hidden = true
16+
17+
static flags = {
18+
...globalFlags,
19+
...appFlags,
20+
id: Flags.string({
21+
description: 'The bulk operation ID.',
22+
env: 'SHOPIFY_FLAG_ID',
23+
required: true,
24+
}),
25+
store: Flags.string({
26+
char: 's',
27+
description: 'The store domain. Must be an existing dev store.',
28+
env: 'SHOPIFY_FLAG_STORE',
29+
parse: async (input) => normalizeStoreFqdn(input),
30+
}),
31+
}
32+
33+
async run(): Promise<AppLinkedCommandOutput> {
34+
const {flags} = await this.parse(BulkStatus)
35+
36+
const appContextResult = await linkedAppContext({
37+
directory: flags.path,
38+
clientId: flags['client-id'],
39+
forceRelink: flags.reset,
40+
userProvidedConfigName: flags.config,
41+
})
42+
43+
const store = await storeContext({
44+
appContextResult,
45+
storeFqdn: flags.store,
46+
forceReselectStore: flags.reset,
47+
})
48+
49+
await getBulkOperationStatus({
50+
storeFqdn: store.shopDomain,
51+
operationId: flags.id,
52+
})
53+
54+
return {app: appContextResult.app}
55+
}
56+
}

packages/app/src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Build from './commands/app/build.js'
2+
import BulkStatus from './commands/app/bulk/status.js'
23
import ConfigLink from './commands/app/config/link.js'
34
import ConfigUse from './commands/app/config/use.js'
45
import DemoWatcher from './commands/app/demo/watcher.js'
@@ -36,6 +37,7 @@ import FunctionInfo from './commands/app/function/info.js'
3637
*/
3738
export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlinkedCommand} = {
3839
'app:build': Build,
40+
'app:bulk:status': BulkStatus,
3941
'app:deploy': Deploy,
4042
'app:dev': Dev,
4143
'app:dev:clean': DevClean,
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import {getBulkOperationStatus} from './bulk-operation-status.js'
2+
import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
3+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
4+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
5+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
6+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
7+
8+
vi.mock('@shopify/cli-kit/node/session')
9+
vi.mock('@shopify/cli-kit/node/api/admin')
10+
11+
describe('getBulkOperationStatus', () => {
12+
const storeFqdn = 'test-store.myshopify.com'
13+
const operationId = 'gid://shopify/BulkOperation/123'
14+
15+
beforeEach(() => {
16+
vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue({token: 'test-token', storeFqdn})
17+
})
18+
19+
afterEach(() => {
20+
mockAndCaptureOutput().clear()
21+
})
22+
23+
function mockBulkOperation(
24+
overrides?: Partial<NonNullable<GetBulkOperationByIdQuery['bulkOperation']>>,
25+
): GetBulkOperationByIdQuery {
26+
return {
27+
bulkOperation: {
28+
id: operationId,
29+
status: 'RUNNING',
30+
errorCode: null,
31+
objectCount: 100,
32+
createdAt: new Date(Date.now() - 120000).toISOString(),
33+
completedAt: null,
34+
url: null,
35+
partialDataUrl: null,
36+
...overrides,
37+
},
38+
}
39+
}
40+
41+
test('renders success banner for completed operation', async () => {
42+
vi.mocked(adminRequestDoc).mockResolvedValue(
43+
mockBulkOperation({
44+
status: 'COMPLETED',
45+
completedAt: new Date(Date.now() - 60000).toISOString(),
46+
url: 'https://example.com/results.jsonl',
47+
}),
48+
)
49+
50+
const output = mockAndCaptureOutput()
51+
await getBulkOperationStatus({storeFqdn, operationId})
52+
53+
expect(output.output()).toContain('Bulk operation succeeded:')
54+
expect(output.output()).toContain('100 objects')
55+
expect(output.output()).toContain(operationId)
56+
expect(output.output()).toContain('Finished')
57+
expect(output.output()).toContain('Download results')
58+
})
59+
60+
test('renders info banner for running operation', async () => {
61+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING', objectCount: 500}))
62+
63+
const output = mockAndCaptureOutput()
64+
await getBulkOperationStatus({storeFqdn, operationId})
65+
66+
expect(output.info()).toContain('Bulk operation in progress...')
67+
expect(output.info()).toContain('500 objects')
68+
expect(output.info()).toContain('Started')
69+
})
70+
71+
test('renders error banner for failed operation', async () => {
72+
vi.mocked(adminRequestDoc).mockResolvedValue(
73+
mockBulkOperation({
74+
status: 'FAILED',
75+
errorCode: 'ACCESS_DENIED',
76+
completedAt: new Date(Date.now() - 60000).toISOString(),
77+
partialDataUrl: 'https://example.com/partial.jsonl',
78+
}),
79+
)
80+
81+
const output = mockAndCaptureOutput()
82+
await getBulkOperationStatus({storeFqdn, operationId})
83+
84+
expect(output.error()).toContain('Error: ACCESS_DENIED')
85+
expect(output.error()).toContain('Finished')
86+
expect(output.error()).toContain('Download partial results')
87+
})
88+
89+
test('renders error banner when operation not found', async () => {
90+
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null})
91+
92+
const output = mockAndCaptureOutput()
93+
await getBulkOperationStatus({storeFqdn, operationId})
94+
95+
expect(output.error()).toContain('Bulk operation not found.')
96+
expect(output.error()).toContain(operationId)
97+
})
98+
99+
test('renders info banner for created operation', async () => {
100+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CREATED', objectCount: 0}))
101+
102+
const output = mockAndCaptureOutput()
103+
await getBulkOperationStatus({storeFqdn, operationId})
104+
105+
expect(output.info()).toContain('Starting...')
106+
})
107+
108+
test('renders info banner for canceled operation', async () => {
109+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CANCELED'}))
110+
111+
const output = mockAndCaptureOutput()
112+
await getBulkOperationStatus({storeFqdn, operationId})
113+
114+
expect(output.info()).toContain('Bulk operation canceled.')
115+
})
116+
117+
describe('time formatting', () => {
118+
test('does not pluralize time unit when it is 1', async () => {
119+
vi.mocked(adminRequestDoc).mockResolvedValue(
120+
mockBulkOperation({completedAt: new Date(Date.now() - 60000).toISOString()}),
121+
)
122+
123+
const output = mockAndCaptureOutput()
124+
await getBulkOperationStatus({storeFqdn, operationId})
125+
126+
expect(output.output()).toMatch(/1 minute ago/)
127+
})
128+
129+
test('pluralizes time unit when it is greater than 1', async () => {
130+
vi.mocked(adminRequestDoc).mockResolvedValue(
131+
mockBulkOperation({createdAt: new Date(Date.now() - 180000).toISOString()}),
132+
)
133+
134+
const output = mockAndCaptureOutput()
135+
await getBulkOperationStatus({storeFqdn, operationId})
136+
137+
expect(output.output()).toMatch(/3 minutes ago/)
138+
})
139+
140+
test('uses "Started" for running operations', async () => {
141+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'}))
142+
143+
const output = mockAndCaptureOutput()
144+
await getBulkOperationStatus({storeFqdn, operationId})
145+
146+
expect(output.output()).toContain('Started')
147+
})
148+
149+
test('uses "Finished" for completed operations', async () => {
150+
vi.mocked(adminRequestDoc).mockResolvedValue(
151+
mockBulkOperation({
152+
status: 'COMPLETED',
153+
completedAt: new Date(Date.now() - 60000).toISOString(),
154+
}),
155+
)
156+
157+
const output = mockAndCaptureOutput()
158+
await getBulkOperationStatus({storeFqdn, operationId})
159+
160+
expect(output.output()).toContain('Finished')
161+
})
162+
})
163+
})
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {BulkOperation} from './watch-bulk-operation.js'
2+
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
3+
import {
4+
GetBulkOperationById,
5+
GetBulkOperationByIdQuery,
6+
} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
7+
import {renderInfo, renderSuccess, renderError} from '@shopify/cli-kit/node/ui'
8+
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
9+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
10+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
11+
12+
const API_VERSION = '2026-01'
13+
14+
interface GetBulkOperationStatusOptions {
15+
storeFqdn: string
16+
operationId: string
17+
}
18+
19+
export async function getBulkOperationStatus(options: GetBulkOperationStatusOptions): Promise<void> {
20+
const {storeFqdn, operationId} = options
21+
22+
const adminSession = await ensureAuthenticatedAdmin(storeFqdn)
23+
24+
const response = await adminRequestDoc<GetBulkOperationByIdQuery, {id: string}>({
25+
query: GetBulkOperationById,
26+
session: adminSession,
27+
variables: {id: operationId},
28+
version: API_VERSION,
29+
})
30+
31+
if (response.bulkOperation) {
32+
renderBulkOperationStatus(response.bulkOperation)
33+
} else {
34+
renderError({
35+
headline: 'Bulk operation not found.',
36+
body: outputContent`ID: ${outputToken.yellow(operationId)}`.value,
37+
})
38+
}
39+
}
40+
41+
function renderBulkOperationStatus(operation: BulkOperation): void {
42+
const {id, status, createdAt, completedAt, url, partialDataUrl} = operation
43+
const statusDescription = formatBulkOperationStatus(operation).value
44+
const operationInfo = outputContent`ID: ${outputToken.yellow(id)}\n${formatTimeDifference(String(createdAt), completedAt ? String(completedAt) : undefined)}`.value
45+
46+
if (status === 'COMPLETED') {
47+
renderSuccess({
48+
headline: statusDescription,
49+
body: outputContent`${operationInfo}\n${url ? outputToken.link('Download results', url) : ''}`.value,
50+
})
51+
} else if (status === 'FAILED') {
52+
renderError({
53+
headline: statusDescription,
54+
body: outputContent`${operationInfo}\n${
55+
partialDataUrl ? outputToken.link('Download partial results', partialDataUrl) : ''
56+
}`.value,
57+
})
58+
} else {
59+
renderInfo({headline: statusDescription, body: operationInfo})
60+
}
61+
}
62+
63+
function formatTimeDifference(createdAt: string, completedAt?: string): string {
64+
const created = new Date(createdAt)
65+
const completed = completedAt ? new Date(completedAt) : undefined
66+
const now = new Date()
67+
68+
if (completed) {
69+
return `Finished ${timeAgo(completed, now)}`
70+
} else {
71+
return `Started ${timeAgo(created, now)}`
72+
}
73+
}
74+
75+
function timeAgo(from: Date, to: Date): string {
76+
const seconds = Math.floor((to.getTime() - from.getTime()) / 1000)
77+
if (seconds < 60) return `${formatTimeUnit(seconds, 'second')} ago`
78+
79+
const minutes = Math.floor(seconds / 60)
80+
if (minutes < 60) return `${formatTimeUnit(minutes, 'minute')} ago`
81+
82+
const hours = Math.floor(minutes / 60)
83+
if (hours < 24) return `${formatTimeUnit(hours, 'hour')} ago`
84+
85+
const days = Math.floor(hours / 24)
86+
return `${formatTimeUnit(days, 'day')} ago`
87+
}
88+
89+
function formatTimeUnit(count: number, unit: string): string {
90+
return `${count} ${unit}${count === 1 ? '' : 's'}`
91+
}

0 commit comments

Comments
 (0)