Skip to content
Open
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
Expand Up @@ -14,9 +14,9 @@ export type FetchSpecificationsQuery = {
externalIdentifier: string
features: string[]
uidStrategy:
| {appModuleLimit: number; isClientProvided: boolean}
| {appModuleLimit: number; isClientProvided: boolean}
| {appModuleLimit: number; isClientProvided: boolean}
| {__typename: 'UidStrategiesClientProvided'; appModuleLimit: number; isClientProvided: boolean}
| {__typename: 'UidStrategiesDynamic'; appModuleLimit: number; isClientProvided: boolean}
| {__typename: 'UidStrategiesStatic'; appModuleLimit: number; isClientProvided: boolean}
validationSchema?: {jsonSchema: string} | null
}[]
}
Expand Down Expand Up @@ -61,9 +61,9 @@ export const FetchSpecifications = {
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
{kind: 'Field', name: {kind: 'Name', value: 'appModuleLimit'}},
{kind: 'Field', name: {kind: 'Name', value: 'isClientProvided'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ query fetchSpecifications($organizationId: ID!) {
externalIdentifier
features
uidStrategy {
__typename
appModuleLimit
isClientProvided
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {gql} from 'graphql-request'

// eslint-disable-next-line @shopify/cli/no-inline-graphql
export const ExtensionSpecificationsQuery = gql`
query fetchSpecifications($apiKey: String!) {
extensionSpecifications(apiKey: $apiKey) {
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface RemoteSpecification {
managementExperience: 'cli' | 'custom' | 'dashboard'
registrationLimit: number
uidIsClientProvided: boolean
uidStrategy?: 'single' | 'dynamic' | 'uuid'
}
features?: {
argo?: {
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/cli/models/app/app.test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import {SchemaDefinitionByTargetQueryVariables} from '../../api/graphql/function
import {SchemaDefinitionByApiTypeQueryVariables} from '../../api/graphql/functions/generated/schema-definition-by-api-type.js'
import {AppHomeSpecIdentifier} from '../extensions/specifications/app_config_app_home.js'
import {AppProxySpecIdentifier} from '../extensions/specifications/app_config_app_proxy.js'
import {ExtensionSpecification} from '../extensions/specification.js'
import {ExtensionSpecification, isAppConfigSpecification} from '../extensions/specification.js'
import {AppLogsOptions} from '../../services/app-logs/utils.js'
import {AppLogsSubscribeMutationVariables} from '../../api/graphql/app-management/generated/app-logs-subscribe.js'
import {Session} from '@shopify/cli-kit/node/session'
Expand Down Expand Up @@ -1526,5 +1526,5 @@ export async function buildVersionedAppSchema() {
}

export async function configurationSpecifications() {
return (await loadLocalExtensionsSpecifications()).filter((spec) => spec.uidStrategy === 'single')
return (await loadLocalExtensionsSpecifications()).filter(isAppConfigSpecification)
}
2 changes: 1 addition & 1 deletion packages/app/src/cli/models/app/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('load', () => {

// Helper to get only real extensions (not configuration extensions)
function getRealExtensions(app: AppInterface) {
return app.allExtensions.filter((ext) => ext.specification.experience !== 'configuration')
return app.allExtensions.filter((ext) => !ext.isAppConfigExtension)
}

/**
Expand Down
9 changes: 7 additions & 2 deletions packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {configurationFileNames, dotEnvFileNames} from '../../constants.js'
import metadata from '../../metadata.js'
import {ExtensionInstance} from '../extensions/extension-instance.js'
import {ExtensionsArraySchema, UnifiedSchema} from '../extensions/schemas.js'
import {ExtensionSpecification, RemoteAwareExtensionSpecification} from '../extensions/specification.js'
import {
ExtensionSpecification,
RemoteAwareExtensionSpecification,
isAppConfigSpecification,
} from '../extensions/specification.js'
import {getCachedAppInfo} from '../../services/local-storage.js'
import use from '../../services/app/config/use.js'
import {CreateAppOptions, Flag} from '../../utilities/developer-platform-client.js'
Expand Down Expand Up @@ -714,7 +718,8 @@ class AppLoader<TConfig extends CurrentAppConfiguration, TModuleSpec extends Ext
const configPath = this.loadedConfiguration.configPath
const extensionInstancesWithKeys = await Promise.all(
this.specifications
.filter((specification) => specification.uidStrategy === 'single')
.filter((specification) => isAppConfigSpecification(specification))
.filter((specification) => specification.identifier !== WebhookSubscriptionSpecIdentifier)
.map(async (specification) => {
const specConfiguration = parseConfigurationObjectAgainstSpecification(
specification,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {outputDebug} from '@shopify/cli-kit/node/output'
import {extractJSImports, extractImportPathsRecursively} from '@shopify/cli-kit/node/import-extractor'
import {uniq} from '@shopify/cli-kit/common/array'

// DEPRECATED. We should get the experience from the specification instead of hardcoding it based on the identifier.
// This is a temporary solution to avoid breaking changes while we update the API and the specifications query.
export const CONFIG_EXTENSION_IDS: string[] = [
AppAccessSpecIdentifier,
AppHomeSpecIdentifier,
Expand All @@ -44,6 +46,10 @@ export const CONFIG_EXTENSION_IDS: string[] = [
WebhookSubscriptionSpecIdentifier,
WebhooksSpecIdentifier,
EventsSpecIdentifier,

// Hardcoded identifiers that don't exist locally.
'data',
'admin',
]

/**
Expand Down Expand Up @@ -121,7 +127,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
}

get isAppConfigExtension() {
return ['single', 'dynamic'].includes(this.specification.uidStrategy)
return this.specification.experience === 'configuration'
}

get isFlow() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('createContractBasedModuleSpecification', () => {
// When
const got = createContractBasedModuleSpecification({
identifier: 'test',
uidStrategy: 'uuid',
appModuleFeatures: () => ['localization'],
})

Expand Down
18 changes: 16 additions & 2 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export interface CustomTransformationConfig {
}

type ExtensionExperience = 'extension' | 'configuration'

export function isAppConfigSpecification(spec: {experience: string}): boolean {
return spec.experience === 'configuration'
}
type UidStrategy = 'single' | 'dynamic' | 'uuid'

export enum AssetIdentifier {
Expand Down Expand Up @@ -210,7 +214,13 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
return {
...merged,
contributeToAppConfigurationSchema: (appConfigSchema: ZodSchemaType<unknown>) => {
if (merged.uidStrategy !== 'single') {
const isConfig = isAppConfigSpecification(merged)
const hasSchema = merged.schema._def.shape !== undefined
// This filters out webhook subscription specifications from contributing to the app configuration schema
const hasSingleUidStrategy = merged.uidStrategy === 'single'

const canContribute = isConfig && hasSchema && hasSingleUidStrategy
if (!canContribute) {
// no change
return appConfigSchema
}
Expand Down Expand Up @@ -268,13 +278,17 @@ export function createConfigExtensionSpecification<TConfiguration extends BaseCo
}

export function createContractBasedModuleSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(
spec: Pick<CreateExtensionSpecType<TConfiguration>, 'identifier' | 'appModuleFeatures' | 'buildConfig'>,
spec: Pick<
CreateExtensionSpecType<TConfiguration>,
'identifier' | 'appModuleFeatures' | 'buildConfig' | 'uidStrategy'
>,
) {
return createExtensionSpecification({
identifier: spec.identifier,
schema: zod.any({}) as unknown as ZodSchemaType<TConfiguration>,
appModuleFeatures: spec.appModuleFeatures,
buildConfig: spec.buildConfig ?? {mode: 'none'},
uidStrategy: spec.uidStrategy,
deployConfig: async (config, directory) => {
let parsedConfig = configWithoutFirstClassFields(config)
if (spec.appModuleFeatures().includes('localization')) {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/app-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ describe('localAppContext', () => {
})

// Then
const realExtensions = result.allExtensions.filter((ext) => ext.specification.experience !== 'configuration')
const realExtensions = result.allExtensions.filter((ext) => !ext.isAppConfigExtension)
expect(realExtensions).toHaveLength(1)
expect(realExtensions[0]).toEqual(
expect.objectContaining({
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/cli/services/app/select-app.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {MinimalOrganizationApp} from '../../models/organization.js'
import {Flag, AppModuleVersion, DeveloperPlatformClient, AppVersion} from '../../utilities/developer-platform-client.js'
import {ExtensionSpecification} from '../../models/extensions/specification.js'
import {ExtensionSpecification, isAppConfigSpecification} from '../../models/extensions/specification.js'
import {AppConfigurationUsedByCli} from '../../models/extensions/specifications/types/app_config.js'
import {deepMergeObjects} from '@shopify/cli-kit/common/object'

export function extensionTypeStrategy(specs: ExtensionSpecification[], type?: string) {
function extensionTypeStrategy(specs: ExtensionSpecification[], type?: string) {
if (!type) return
const spec = specs.find(
(spec) =>
Expand Down Expand Up @@ -59,7 +59,7 @@ export function remoteAppConfigurationExtensionContent(
flags: Flag[],
) {
let remoteAppConfig: {[key: string]: unknown} = {}
const configSpecifications = specifications.filter((spec) => spec.uidStrategy !== 'uuid')
const configSpecifications = specifications.filter(isAppConfigSpecification)
configRegistrations.forEach((module) => {
const configSpec = configSpecifications.find(
(spec) => spec.identifier === module.specification?.identifier.toLowerCase(),
Expand Down
15 changes: 6 additions & 9 deletions packages/app/src/cli/services/context/breakdown-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,13 @@ import {AppVersionsDiffExtensionSchema} from '../../api/graphql/app_versions_dif
import {AppInterface, CurrentAppConfiguration, filterNonVersionedAppFields} from '../../models/app/app.js'
import {MinimalOrganizationApp} from '../../models/organization.js'
import {IdentifiersExtensions} from '../../models/app/identifiers.js'
import {
extensionTypeStrategy,
fetchAppRemoteConfiguration,
remoteAppConfigurationExtensionContent,
} from '../app/select-app.js'
import {fetchAppRemoteConfiguration, remoteAppConfigurationExtensionContent} from '../app/select-app.js'
import {AppVersion, AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
import {
AllAppExtensionRegistrationsQuerySchema,
RemoteExtensionRegistrations,
} from '../../api/graphql/all_app_extension_registrations.js'
import {ExtensionSpecification} from '../../models/extensions/specification.js'
import {ExtensionSpecification, isAppConfigSpecification} from '../../models/extensions/specification.js'
import {rewriteConfiguration} from '../app/write-app-configuration-file.js'
import {AppConfigurationUsedByCli} from '../../models/extensions/specifications/types/app_config.js'
import {removeTrailingSlash} from '../../models/extensions/specifications/validation/common.js'
Expand Down Expand Up @@ -351,9 +347,10 @@ function loadExtensionsIdentifiersBreakdown(
specs: ExtensionSpecification[],
developerPlatformClient: DeveloperPlatformClient,
) {
const extensionModules = activeAppVersion?.appModuleVersions.filter(
(ext) => extensionTypeStrategy(specs, ext.specification?.identifier) === 'uuid',
)
const extensionModules = activeAppVersion?.appModuleVersions.filter((ext) => {
const spec = specs.find((spec) => spec.identifier === ext.specification?.identifier)
return spec && !isAppConfigSpecification(spec)
})

// In AppManagement, matching has to be via UID, but we acccept UUID matches if the UID is empty (migration pending)
// In Partners, we keep the legacy match of only UUID.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export async function ensureExtensionsIds(
) {
let remoteExtensions = initialRemoteExtensions
const identifiers = options.envIdentifiers.extensions ?? {}
const localExtensions = options.app.allExtensions.filter((ext) => ext.isUUIDStrategyExtension)
const localExtensions = options.app.allExtensions.filter((ext) => !ext.isAppConfigExtension)

const uiExtensionsToMigrate = getModulesToMigrate(localExtensions, remoteExtensions, identifiers, UIModulesMap)
const flowExtensionsToMigrate = getModulesToMigrate(localExtensions, dashboardExtensions, identifiers, FlowModulesMap)
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/generate/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('initialize a extension', async () => {
expect(vi.mocked(addNPMDependenciesIfNeeded)).toHaveBeenCalledTimes(2)

const loadedApp = await loadApp({directory: tmpDir, specifications, userProvidedConfigName: undefined})
const realExtensions = loadedApp.allExtensions.filter((ext) => ext.specification.experience !== 'configuration')
const realExtensions = loadedApp.allExtensions.filter((ext) => !ext.isAppConfigExtension)
expect(realExtensions.length).toEqual(2)
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,28 @@ async function mergeLocalAndRemoteSpecs(
const hasLocalization = normalisedSchema.properties?.localization !== undefined
localSpec = createContractBasedModuleSpecification({
identifier: remoteSpec.identifier,
uidStrategy: remoteSpec.options.uidStrategy,
appModuleFeatures: () => (hasLocalization ? ['localization'] : []),
})
localSpec.uidStrategy = remoteSpec.options.uidIsClientProvided ? 'uuid' : 'single'
// Seed uidStrategy for contract specs using uidIsClientProvided as fallback (Partners API path).
// This will be overridden below if the backend provides a typename-derived value.
localSpec.uidStrategy =
remoteSpec.options.uidStrategy ?? (remoteSpec.options.uidIsClientProvided ? 'uuid' : 'single')
}
if (!localSpec) return undefined

const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification &
FlattenedRemoteSpecification

// Always prefer the backend-derived uidStrategy (from __typename) when available.
// This correctly overrides the local spec's default (e.g. channel_config defaults to 'uuid'
// locally but the backend defines it as 'single').
// Falls back to the local spec value for the Partners API path (no __typename available).
merged.uidStrategy = merged.options.uidStrategy ?? localSpec.uidStrategy ?? 'single'

// If configuration is inside an app.toml -- i.e. single UID mode -- we must be able to parse a partial slice.
// DEPRECATED: not all single specs are config specs.
// Should be removed once we can get the experience from the API.
let handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties
switch (merged.uidStrategy) {
case 'uuid':
Expand All @@ -93,6 +105,11 @@ async function mergeLocalAndRemoteSpecs(
break
}

// If the experience is 'configuration', force strip.
if (merged.experience === 'configuration') {
handleInvalidAdditionalProperties = 'strip'
}

const parseConfigurationObject = await unifiedConfigurationParserFactory(merged, handleInvalidAdditionalProperties)

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
diffAppModules,
encodedGidFromOrganizationIdForBP,
encodedGidFromShopId,
uidStrategyFromTypename,
versionDeepLink,
} from './app-management-client.js'
import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/organization_beta_flags.js'
Expand Down Expand Up @@ -1816,3 +1817,21 @@ describe('singleton pattern', () => {
expect(instance1).not.toBe(instance2)
})
})

describe('uidStrategyFromTypename', () => {
test('maps UidStrategiesDynamic to dynamic', () => {
expect(uidStrategyFromTypename('UidStrategiesDynamic')).toBe('dynamic')
})

test('maps UidStrategiesStatic to single', () => {
expect(uidStrategyFromTypename('UidStrategiesStatic')).toBe('single')
})

test('maps UidStrategiesClientProvided to uuid', () => {
expect(uidStrategyFromTypename('UidStrategiesClientProvided')).toBe('uuid')
})

test('returns undefined for unknown typename', () => {
expect(uidStrategyFromTypename('UnknownStrategy')).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ export class AppManagementClient implements DeveloperPlatformClient {
managementExperience: 'cli',
registrationLimit: spec.uidStrategy.appModuleLimit,
uidIsClientProvided: spec.uidStrategy.isClientProvided,
uidStrategy: uidStrategyFromTypename(spec.uidStrategy.__typename),
},
experience: experience(spec.identifier),
validationSchema: spec.validationSchema,
Expand Down Expand Up @@ -1361,6 +1362,26 @@ function experience(identifier: string): 'configuration' | 'extension' {
return CONFIG_EXTENSION_IDS.includes(identifier) ? 'configuration' : 'extension'
}

/**
* Maps the backend uidStrategy __typename to the CLI UidStrategy value.
* The __typename is always present in the response because the GraphQL document
* selects it, even though the generated TypeScript type doesn't expose it directly.
*
* Type names come from the app-management GraphQL schema's UidStrategy union.
*/
export function uidStrategyFromTypename(typename: string): 'single' | 'dynamic' | 'uuid' | undefined {
switch (typename) {
case 'UidStrategiesDynamic':
return 'dynamic'
case 'UidStrategiesStatic':
return 'single'
case 'UidStrategiesClientProvided':
return 'uuid'
default:
return undefined
}
}

function mapBusinessPlatformStoresToOrganizationStores(
storesArray: ShopNode[],
provisionable: boolean,
Expand Down
11 changes: 10 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading