From 54e984d5320d2a1c9afc33bebeceec3430c04061 Mon Sep 17 00:00:00 2001 From: David Peacock Date: Fri, 25 Sep 2026 09:14:59 -0400 Subject: [PATCH] Expose module identity to reverse configuration transforms Pass optional readonly module identity through the existing reverse transform options in both remote reconstruction and local deploy comparison. Specifications can use envelope identity without adding feature-specific branches to generic callers. Keep this framework capability independent of Events adoption. A synthetic specification verifies both identity sources and unchanged config output; existing transforms do not need to consume the new context. --- .../cli/models/extensions/specification.ts | 9 ++- .../app/src/cli/services/app/select-app.ts | 5 +- .../deploy-identifier-matching.test.ts | 59 ++++++++++++++++++- .../context/deploy-identifier-matching.ts | 6 +- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index d9e9256d642..247eacf44da 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -26,9 +26,14 @@ export type ExtensionFeature = export type TransformationConfig = Record +export interface TransformRemoteToLocalOptions { + readonly flags?: Flag[] + readonly module?: {readonly handle: string} +} + export interface CustomTransformationConfig { forward?: (obj: object, appConfiguration: AppConfiguration, options?: {flags?: Flag[]}) => object - reverse?: (obj: object, options?: {flags?: Flag[]}) => object + reverse?: (obj: object, options?: TransformRemoteToLocalOptions) => object } type ExtensionExperience = 'extension' | 'configuration' @@ -119,7 +124,7 @@ export interface ExtensionSpecification object + transformRemoteToLocal?: (remoteContent: object, options?: TransformRemoteToLocalOptions) => object uidStrategy: UidStrategy diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 915e88933b3..1213f0a2326 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -68,7 +68,10 @@ export function remoteAppConfigurationExtensionContent( const config = module.config if (!config) return - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) + remoteAppConfig = deepMergeObjects( + remoteAppConfig, + configSpec.transformRemoteToLocal?.(config, {flags, module: {handle: module.registrationTitle}}) ?? config, + ) }) return {...remoteAppConfig} diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts index 1b24ebfc74b..8227d245085 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts @@ -13,7 +13,7 @@ import { } from '../../models/app/app.test-data.js' import {OrganizationApp} from '../../models/organization.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {BaseConfigType} from '../../models/extensions/schemas.js' +import {BaseConfigType, BaseSchemaWithoutHandle} from '../../models/extensions/schemas.js' import {createConfigExtensionSpecification} from '../../models/extensions/specification.js' import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' @@ -623,6 +623,63 @@ describe('classifyDeployExtensionChanges', () => { }) describe('ensureDeployIdentifiersFromAppVersion', () => { + test('passes each module handle to a config reverse transform without changing its output', async () => { + const configuration: BaseConfigType & {synthetic_config: {enabled: boolean}} = {synthetic_config: {enabled: true}} + const reverse = vi.fn((content: object) => content) + const specification = createConfigExtensionSpecification({ + identifier: 'synthetic_config', + schema: BaseSchemaWithoutHandle.extend({synthetic_config: zod.object({enabled: zod.boolean()})}), + transformConfig: {forward: (content) => content, reverse}, + }) + const extension = new ExtensionInstance({ + configuration, + configurationPath: 'shopify.app.toml', + directory: '/app', + specification, + }) + extension.handle = 'Local_Module_Handle' + const app = testApp({ + ...APP, + allExtensions: [extension], + specifications: [specification], + }) + const remote: AppModuleVersion = { + registrationId: extension.uid, + registrationUuid: 'synthetic-uuid', + registrationTitle: 'Remote_Module_Handle', + type: specification.identifier, + config: configuration, + specification: { + identifier: specification.identifier, + name: 'Synthetic config', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } + + await ensureDeployIdentifiersFromAppVersion(deployOptions({app, activeAppVersion: {appModuleVersions: [remote]}})) + + expect(reverse).toHaveBeenCalledTimes(2) + expect(reverse).toHaveBeenNthCalledWith(1, configuration, { + flags: app.remoteFlags, + module: {handle: extension.handle}, + }) + expect(reverse).toHaveBeenNthCalledWith(2, configuration, { + flags: app.remoteFlags, + module: {handle: remote.registrationTitle}, + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['synthetic_config'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + test('prompts with the existing UI breakdown shape and returns deploy identifiers', async () => { const identifiers = await ensureDeployIdentifiersFromAppVersion( deployOptions({ diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.ts index 04236f1173d..4b2bbf2fb3b 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -189,8 +189,10 @@ async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: // eslint-disable-next-line no-await-in-loop const deployConfig = await extension.deployConfig({apiKey, appConfiguration: app.configuration}) const localConfig = - extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? - extension.configuration + extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, { + flags: app.remoteFlags, + module: {handle: extension.handle}, + }) ?? extension.configuration appConfig = deepMergeObjects(appConfig, localConfig) }