Skip to content

Commit 15e649b

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

File tree

7 files changed

+407
-0
lines changed

7 files changed

+407
-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: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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('throws error when operation not found', async () => {
90+
vi.mocked(adminRequestDoc).mockResolvedValue({bulkOperation: null})
91+
92+
await expect(getBulkOperationStatus({storeFqdn, operationId})).rejects.toThrow(
93+
`Bulk operation with ID ${operationId} not found.`,
94+
)
95+
})
96+
97+
test('handles created status', async () => {
98+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CREATED', objectCount: 0}))
99+
100+
const output = mockAndCaptureOutput()
101+
await getBulkOperationStatus({storeFqdn, operationId})
102+
103+
expect(output.info()).toContain('Starting...')
104+
})
105+
106+
test('handles canceled status', async () => {
107+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'CANCELED'}))
108+
109+
const output = mockAndCaptureOutput()
110+
await getBulkOperationStatus({storeFqdn, operationId})
111+
112+
expect(output.info()).toContain('Bulk operation canceled.')
113+
})
114+
115+
describe('time formatting', () => {
116+
test('formats singular time correctly', async () => {
117+
vi.mocked(adminRequestDoc).mockResolvedValue(
118+
mockBulkOperation({completedAt: new Date(Date.now() - 60000).toISOString()}),
119+
)
120+
121+
const output = mockAndCaptureOutput()
122+
await getBulkOperationStatus({storeFqdn, operationId})
123+
124+
expect(output.output()).toMatch(/1 minute ago/)
125+
})
126+
127+
test('formats plural time correctly', async () => {
128+
vi.mocked(adminRequestDoc).mockResolvedValue(
129+
mockBulkOperation({createdAt: new Date(Date.now() - 180000).toISOString()}),
130+
)
131+
132+
const output = mockAndCaptureOutput()
133+
await getBulkOperationStatus({storeFqdn, operationId})
134+
135+
expect(output.output()).toMatch(/3 minutes ago/)
136+
})
137+
138+
test('uses "Started" for running and "Finished" for completed', async () => {
139+
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'}))
140+
141+
const output = mockAndCaptureOutput()
142+
await getBulkOperationStatus({storeFqdn, operationId})
143+
144+
expect(output.output()).toContain('Started')
145+
expect(output.output()).not.toContain('Finished')
146+
147+
output.clear()
148+
149+
vi.mocked(adminRequestDoc).mockResolvedValue(
150+
mockBulkOperation({
151+
status: 'COMPLETED',
152+
completedAt: new Date(Date.now() - 60000).toISOString(),
153+
}),
154+
)
155+
156+
await getBulkOperationStatus({storeFqdn, operationId})
157+
158+
expect(output.output()).toContain('Finished')
159+
expect(output.output()).not.toContain('Started')
160+
})
161+
})
162+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {BulkOperation} from './watch-bulk-operation.js'
2+
import {
3+
GetBulkOperationById,
4+
GetBulkOperationByIdQuery,
5+
} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
6+
import {renderInfo, renderSuccess, renderError} from '@shopify/cli-kit/node/ui'
7+
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
8+
import {ensureAuthenticatedAdmin} from '@shopify/cli-kit/node/session'
9+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
10+
import {AbortError} from '@shopify/cli-kit/node/error'
11+
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
12+
13+
const API_VERSION = '2026-01'
14+
15+
interface GetBulkOperationStatusOptions {
16+
storeFqdn: string
17+
operationId: string
18+
}
19+
20+
export async function getBulkOperationStatus(options: GetBulkOperationStatusOptions): Promise<void> {
21+
const {storeFqdn, operationId} = options
22+
23+
const adminSession = await ensureAuthenticatedAdmin(storeFqdn)
24+
25+
const response = await adminRequestDoc<GetBulkOperationByIdQuery, {id: string}>({
26+
query: GetBulkOperationById,
27+
session: adminSession,
28+
variables: {id: operationId},
29+
version: API_VERSION,
30+
})
31+
32+
if (!response.bulkOperation) {
33+
throw new AbortError(`Bulk operation with ID ${operationId} not found.`)
34+
}
35+
36+
renderBulkOperationStatus(response.bulkOperation)
37+
}
38+
39+
function renderBulkOperationStatus(operation: BulkOperation): void {
40+
const {id, status, createdAt, completedAt, url, partialDataUrl} = operation
41+
const timeInfo = formatTimeInfo(createdAt, completedAt)
42+
const headline = formatBulkOperationStatus(operation).value
43+
44+
if (status === 'COMPLETED') {
45+
renderSuccess({
46+
headline, body: outputContent`${id}\n${timeInfo}\n${url ? outputToken.link('Download results', url) : ''}`.value,
47+
})
48+
} else if (status === 'FAILED') {
49+
renderError({
50+
headline, body: outputContent`${id}\n${timeInfo}\n${ partialDataUrl ? outputToken.link('Download partial results', partialDataUrl) : '' }`.value,
51+
})
52+
} else {
53+
renderInfo({ headline, body: `${id}\n${timeInfo}` })
54+
}
55+
}
56+
57+
function formatTimeInfo(createdAt: unknown, completedAt?: unknown): string {
58+
const created = new Date(String(createdAt))
59+
const completed = new Date(String(completedAt))
60+
const now = new Date()
61+
62+
if (completedAt) {
63+
return `Finished ${timeAgo(completed, now)}`
64+
} else {
65+
return `Started ${timeAgo(created, now)}`
66+
}
67+
}
68+
69+
function timeAgo(from: Date, to: Date): string {
70+
const seconds = Math.floor((to.getTime() - from.getTime()) / 1000)
71+
72+
if (seconds < 60) {
73+
return `${seconds} second${seconds === 1 ? '' : 's'} ago`
74+
}
75+
76+
const minutes = Math.floor(seconds / 60)
77+
if (minutes < 60) {
78+
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
79+
}
80+
81+
const hours = Math.floor(minutes / 60)
82+
if (hours < 24) {
83+
return `${hours} hour${hours === 1 ? '' : 's'} ago`
84+
}
85+
86+
const days = Math.floor(hours / 24)
87+
return `${days} day${days === 1 ? '' : 's'} ago`
88+
}

0 commit comments

Comments
 (0)