diff --git a/packages/app/src/cli/api/graphql/app-management/generated/specifications.ts b/packages/app/src/cli/api/graphql/app-management/generated/specifications.ts index da6721c922d..5df63872b2c 100644 --- a/packages/app/src/cli/api/graphql/app-management/generated/specifications.ts +++ b/packages/app/src/cli/api/graphql/app-management/generated/specifications.ts @@ -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 }[] } @@ -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'}}, ], }, }, diff --git a/packages/app/src/cli/api/graphql/app-management/queries/specifications.graphql b/packages/app/src/cli/api/graphql/app-management/queries/specifications.graphql index 8190c6ab80b..49b94c21e5b 100644 --- a/packages/app/src/cli/api/graphql/app-management/queries/specifications.graphql +++ b/packages/app/src/cli/api/graphql/app-management/queries/specifications.graphql @@ -5,6 +5,7 @@ query fetchSpecifications($organizationId: ID!) { externalIdentifier features uidStrategy { + __typename appModuleLimit isClientProvided } diff --git a/packages/app/src/cli/api/graphql/extension_specifications.ts b/packages/app/src/cli/api/graphql/extension_specifications.ts index dded2017f04..c4270727961 100644 --- a/packages/app/src/cli/api/graphql/extension_specifications.ts +++ b/packages/app/src/cli/api/graphql/extension_specifications.ts @@ -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) { @@ -40,6 +41,7 @@ export interface RemoteSpecification { managementExperience: 'cli' | 'custom' | 'dashboard' registrationLimit: number uidIsClientProvided: boolean + uidStrategy?: 'single' | 'dynamic' | 'uuid' } features?: { argo?: { diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index e3822de62b7..2747cd6c5e7 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -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' @@ -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) } diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index dcde9758e94..cfea56f67ea 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -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) } /** diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index f47089eafe0..efa520cc672 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -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' @@ -714,7 +718,8 @@ class AppLoader specification.uidStrategy === 'single') + .filter((specification) => isAppConfigSpecification(specification)) + .filter((specification) => specification.identifier !== WebhookSubscriptionSpecIdentifier) .map(async (specification) => { const specConfiguration = parseConfigurationObjectAgainstSpecification( specification, diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 074d27c5dd0..c292a95f8b2 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -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, @@ -44,6 +46,10 @@ export const CONFIG_EXTENSION_IDS: string[] = [ WebhookSubscriptionSpecIdentifier, WebhooksSpecIdentifier, EventsSpecIdentifier, + + // Hardcoded identifiers that don't exist locally. + 'data', + 'admin', ] /** @@ -121,7 +127,7 @@ export class ExtensionInstance { // When const got = createContractBasedModuleSpecification({ identifier: 'test', + uidStrategy: 'uuid', appModuleFeatures: () => ['localization'], }) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 2f459524386..fe1545edbc8 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -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 { @@ -210,7 +214,13 @@ export function createExtensionSpecification) => { - 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 } @@ -268,13 +278,17 @@ export function createConfigExtensionSpecification( - spec: Pick, 'identifier' | 'appModuleFeatures' | 'buildConfig'>, + spec: Pick< + CreateExtensionSpecType, + 'identifier' | 'appModuleFeatures' | 'buildConfig' | 'uidStrategy' + >, ) { return createExtensionSpecification({ identifier: spec.identifier, schema: zod.any({}) as unknown as ZodSchemaType, appModuleFeatures: spec.appModuleFeatures, buildConfig: spec.buildConfig ?? {mode: 'none'}, + uidStrategy: spec.uidStrategy, deployConfig: async (config, directory) => { let parsedConfig = configWithoutFirstClassFields(config) if (spec.appModuleFeatures().includes('localization')) { diff --git a/packages/app/src/cli/services/app-context.test.ts b/packages/app/src/cli/services/app-context.test.ts index c75f80b9d9d..c1bd6d673aa 100644 --- a/packages/app/src/cli/services/app-context.test.ts +++ b/packages/app/src/cli/services/app-context.test.ts @@ -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({ diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 9340599ec4a..b13ae3bc7b5 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -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) => @@ -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(), diff --git a/packages/app/src/cli/services/context/breakdown-extensions.ts b/packages/app/src/cli/services/context/breakdown-extensions.ts index 84413719791..16569bbb695 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.ts @@ -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' @@ -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. diff --git a/packages/app/src/cli/services/context/identifiers-extensions.ts b/packages/app/src/cli/services/context/identifiers-extensions.ts index b1b2b639642..cab2362f5a4 100644 --- a/packages/app/src/cli/services/context/identifiers-extensions.ts +++ b/packages/app/src/cli/services/context/identifiers-extensions.ts @@ -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) diff --git a/packages/app/src/cli/services/generate/extension.test.ts b/packages/app/src/cli/services/generate/extension.test.ts index 15cfa1e2e67..7b7f7d1882b 100644 --- a/packages/app/src/cli/services/generate/extension.test.ts +++ b/packages/app/src/cli/services/generate/extension.test.ts @@ -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) }) }) diff --git a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts index 37544a1c672..ea4010fdef6 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts @@ -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': @@ -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 { diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 847387e98af..739aaceea47 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -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' @@ -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() + }) +}) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 168fcc8c0b8..33cf9e57719 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -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, @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83a7cf0eef1..c611fe81206 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13842,6 +13842,15 @@ snapshots: msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3) vite: 6.4.1(@types/node@18.19.70)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2) + '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3) + vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -19236,7 +19245,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@6.4.1(@types/node@18.19.70)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(sass@1.97.3)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4