From 7b0d13afa1557fceec528bbe98cace2b2ca10906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 26 Mar 2026 12:02:54 +0100 Subject: [PATCH] refactor: make RemoteAwareExtensionSpecification type-safe by including remote fields and using a builder function Co-authored-by: Claude Code --- .../cli/models/extensions/specification.ts | 11 +++++ .../fetch-extension-specifications.test.ts | 2 - .../fetch-extension-specifications.ts | 47 +++++++++++++++---- packages/app/src/cli/utilities/json-schema.ts | 3 +- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index a4564c6889f..9e2d4af0d0a 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -142,10 +142,21 @@ export interface ExtensionSpecification = ExtensionSpecification & { loadedRemoteSpecs: true + options: { + managementExperience: 'cli' | 'custom' | 'dashboard' + registrationLimit: number + uidIsClientProvided: boolean + uidStrategy?: 'single' | 'dynamic' | 'uuid' + } + gated: boolean + validationSchema?: { + jsonSchema: string + } | null } /** diff --git a/packages/app/src/cli/services/generate/fetch-extension-specifications.test.ts b/packages/app/src/cli/services/generate/fetch-extension-specifications.test.ts index 3b81982d2d2..3e53ce9a051 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.test.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.test.ts @@ -50,7 +50,6 @@ describe('fetchExtensionSpecifications', () => { expect(got).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'Product Subscription', externalName: 'Subscription UI', identifier: 'product_subscription', externalIdentifier: 'product_subscription_external', @@ -63,7 +62,6 @@ describe('fetchExtensionSpecifications', () => { expect(got).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'Online Store - App Theme Extension', externalName: 'Theme App Extension', identifier: 'theme', externalIdentifier: 'theme_external', 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 ea09719743b..e45de7b9005 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts @@ -61,6 +61,44 @@ export async function fetchSpecifications({ return [...updatedSpecs] } +/** + * Build a RemoteAwareExtensionSpecification by explicitly merging local behavior with remote metadata. + * + * Local spec provides all behavior (methods, build config, schema parsing). + * Remote spec provides authoritative metadata — applied field-by-field to avoid silent overwrites. + */ +function buildRemoteAwareSpec( + localSpec: ExtensionSpecification, + remoteSpec: FlattenedRemoteSpecification, +): RemoteAwareExtensionSpecification { + return { + // All local behavior (methods, build config, schema parsing, etc.) + ...localSpec, + + // Explicit remote metadata overrides — only data fields + identifier: remoteSpec.identifier, + externalIdentifier: remoteSpec.externalIdentifier, + externalName: remoteSpec.externalName, + experience: remoteSpec.experience as ExtensionSpecification['experience'], + registrationLimit: remoteSpec.registrationLimit, + surface: remoteSpec.surface as string, + + // Remote-only fields carried explicitly + options: remoteSpec.options, + gated: remoteSpec.gated, + validationSchema: remoteSpec.validationSchema, + + // 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). + uidStrategy: remoteSpec.options.uidStrategy ?? localSpec.uidStrategy ?? 'single', + + // Marker + loadedRemoteSpecs: true as const, + } +} + async function mergeLocalAndRemoteSpecs( local: ExtensionSpecification[], remote: FlattenedRemoteSpecification[], @@ -84,14 +122,7 @@ async function mergeLocalAndRemoteSpecs( } 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' + const merged = buildRemoteAwareSpec(localSpec, remoteSpec) // 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. diff --git a/packages/app/src/cli/utilities/json-schema.ts b/packages/app/src/cli/utilities/json-schema.ts index 55855eddd4a..8477740a3d3 100644 --- a/packages/app/src/cli/utilities/json-schema.ts +++ b/packages/app/src/cli/utilities/json-schema.ts @@ -1,4 +1,3 @@ -import {FlattenedRemoteSpecification} from '../api/graphql/extension_specifications.js' import {BaseConfigType} from '../models/extensions/schemas.js' import {RemoteAwareExtensionSpecification} from '../models/extensions/specification.js' import {ParseConfigurationResult} from '@shopify/cli-kit/node/schema' @@ -32,7 +31,7 @@ const JsonSchemaBaseProperties = { * @returns A function that can parse a configuration object */ export async function unifiedConfigurationParserFactory( - merged: RemoteAwareExtensionSpecification & FlattenedRemoteSpecification, + merged: RemoteAwareExtensionSpecification, handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties = 'strip', ) { const contractJsonSchema = merged.validationSchema?.jsonSchema