Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */
import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type ListBulkOperationsQueryVariables = Types.Exact<{
query?: Types.InputMaybe<Types.Scalars['String']['input']>
first: Types.Scalars['Int']['input']
sortKey: Types.BulkOperationsSortKeys
reverse: Types.Scalars['Boolean']['input']
}>

export type ListBulkOperationsQuery = {
bulkOperations: {
nodes: {
id: string
status: Types.BulkOperationStatus
errorCode?: Types.BulkOperationErrorCode | null
objectCount: unknown
createdAt: unknown
completedAt?: unknown | null
url?: string | null
partialDataUrl?: string | null
}[]
}
}

export const ListBulkOperations = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: {kind: 'Name', value: 'ListBulkOperations'},
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'query'}},
type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}},
},
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'first'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'Int'}}},
},
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'sortKey'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'BulkOperationsSortKeys'}}},
},
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'reverse'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'Boolean'}}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'bulkOperations'},
arguments: [
{
kind: 'Argument',
name: {kind: 'Name', value: 'first'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'first'}},
},
{
kind: 'Argument',
name: {kind: 'Name', value: 'query'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'query'}},
},
{
kind: 'Argument',
name: {kind: 'Name', value: 'sortKey'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'sortKey'}},
},
{
kind: 'Argument',
name: {kind: 'Name', value: 'reverse'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'reverse'}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'nodes'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
{kind: 'Field', name: {kind: 'Name', value: 'createdAt'}},
{kind: 'Field', name: {kind: 'Name', value: 'completedAt'}},
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
{kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
],
},
},
],
} as unknown as DocumentNode<ListBulkOperationsQuery, ListBulkOperationsQueryVariables>
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ export type BulkOperationUserErrorCode =
/** A bulk operation is already in progress. */
| 'OPERATION_IN_PROGRESS'

/** The set of valid sort keys for the BulkOperations query. */
export type BulkOperationsSortKeys =
/** Sort by the `completed_at` value. */
| 'COMPLETED_AT'
/** Sort by the `created_at` value. */
| 'CREATED_AT'

/**
* The possible HTTP methods that can be used when sending a request to upload a file using information from a
* [StagedMediaUploadTarget](https://shopify.dev/api/admin-graphql/latest/objects/StagedMediaUploadTarget).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
query ListBulkOperations($query: String, $first: Int!, $sortKey: BulkOperationsSortKeys!, $reverse: Boolean!) {
bulkOperations(first: $first, query: $query, sortKey: $sortKey, reverse: $reverse) {
nodes {
id
status
errorCode
objectCount
createdAt
completedAt
url
partialDataUrl
}
}
}
27 changes: 17 additions & 10 deletions packages/app/src/cli/commands/app/bulk/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@ import {appFlags} from '../../../flags.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {storeContext} from '../../../services/store-context.js'
import {getBulkOperationStatus} from '../../../services/bulk-operations/bulk-operation-status.js'
import {getBulkOperationStatus, listBulkOperations} from '../../../services/bulk-operations/bulk-operation-status.js'
import {Flags} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'

export default class BulkStatus extends AppLinkedCommand {
static summary = 'Check the status of a bulk operation.'
static summary = 'Check the status of bulk operations.'

static description = 'Check the status of a bulk operation by ID.'
static description =
'Check the status of a specific bulk operation by ID, or list all bulk operations in the last 7 days.'

static hidden = true

static flags = {
...globalFlags,
...appFlags,
id: Flags.string({
description: 'The bulk operation ID.',
description: 'The bulk operation ID. If not provided, lists all bulk operations in the last 7 days.',
env: 'SHOPIFY_FLAG_ID',
required: true,
}),
store: Flags.string({
char: 's',
Expand All @@ -46,11 +46,18 @@ export default class BulkStatus extends AppLinkedCommand {
forceReselectStore: flags.reset,
})

await getBulkOperationStatus({
storeFqdn: store.shopDomain,
operationId: flags.id,
remoteApp: appContextResult.remoteApp,
})
if (flags.id) {
await getBulkOperationStatus({
storeFqdn: store.shopDomain,
operationId: flags.id,
remoteApp: appContextResult.remoteApp,
})
} else {
await listBulkOperations({
storeFqdn: store.shopDomain,
remoteApp: appContextResult.remoteApp,
})
}

return {app: appContextResult.app}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {getBulkOperationStatus} from './bulk-operation-status.js'
import {getBulkOperationStatus, listBulkOperations} from './bulk-operation-status.js'
import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js'
import {OrganizationApp} from '../../models/organization.js'
import {ListBulkOperationsQuery} from '../../api/graphql/bulk-operations/generated/list-bulk-operations.js'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
Expand Down Expand Up @@ -150,3 +151,126 @@ describe('getBulkOperationStatus', () => {
})
})
})

describe('listBulkOperations', () => {
function mockBulkOperationsList(
operations: Partial<NonNullable<ListBulkOperationsQuery['bulkOperations']['nodes'][0]>>[],
): ListBulkOperationsQuery {
return {
bulkOperations: {
nodes: operations.map((op) => ({
id: 'gid://shopify/BulkOperation/123',
status: 'RUNNING',
errorCode: null,
objectCount: 100,
createdAt: new Date().toISOString(),
completedAt: null,
url: null,
partialDataUrl: null,
...op,
})),
},
}
}

test('renders table with bulk operations', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(
mockBulkOperationsList([
{
id: 'gid://shopify/BulkOperation/1',
status: 'COMPLETED',
objectCount: 123500,
createdAt: '2025-11-10T12:37:52Z',
completedAt: '2025-11-10T16:37:12Z',
url: 'https://example.com/results.jsonl',
},
{
id: 'gid://shopify/BulkOperation/2',
status: 'RUNNING',
objectCount: 100,
createdAt: '2025-11-11T15:37:52Z',
},
]),
)

const output = mockAndCaptureOutput()
await listBulkOperations({storeFqdn, remoteApp})

const outputLinesWithoutTrailingWhitespace = output
.output()
.split('\n')
.map((line) => line.trimEnd())
.join('\n')

// terminal width in test environment is quite narrow, so values in the snapshot get wrapped
expect(outputLinesWithoutTrailingWhitespace).toMatchInlineSnapshot(`
"ID STATUS COU DATE CREATED DATE RESULTS
T FINISHED

──────────────── ────── ─── ──────────── ─────────── ───────────────────────────
──────────── ── ── ─────── ─────── ───────────────────
gid://shopify/Bu COMPLE 123 2025-11-10 2025-11-10 download ( https://example.
kOperation/1 ED 5K 12:37:52 16:37:12 com/results.jsonl )
gid://shopify/Bu RUNNIN 100 2025-11-11
kOperation/2 15:37:52"
`)
})

test('formats large counts as thousands or millions for readability', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(
mockBulkOperationsList([{objectCount: 1200000}, {objectCount: 5500}, {objectCount: 42}]),
)

const output = mockAndCaptureOutput()
await listBulkOperations({storeFqdn, remoteApp})

expect(output.output()).toContain('1.2M')
expect(output.output()).toContain('5.5K')
expect(output.output()).toContain('42')
})

test('shows download for failed operations with partial results', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(
mockBulkOperationsList([
{
status: 'FAILED',
errorCode: 'ACCESS_DENIED',
partialDataUrl: 'https://example.com/partial.jsonl',
completedAt: '2025-11-10T16:37:12Z',
},
]),
)

const output = mockAndCaptureOutput()
await listBulkOperations({storeFqdn, remoteApp})

expect(output.output()).toContain('download')
expect(output.output()).toContain('partial.jsonl')
})

test('shows download for completed operations with results', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(
mockBulkOperationsList([
{
status: 'COMPLETED',
url: 'https://example.com/results.jsonl',
},
]),
)

const output = mockAndCaptureOutput()
await listBulkOperations({storeFqdn, remoteApp})

expect(output.output()).toContain('download')
expect(output.output()).toContain('results.jsonl')
})

test('shows empty state when no bulk operations found', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([]))

const output = mockAndCaptureOutput()
await listBulkOperations({storeFqdn, remoteApp})

expect(output.info()).toContain('no bulk operations found in the last 7 days')
})
})
Loading