From b44cf4a51a98370a67c85d293514bd92a8af45f2 Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Fri, 21 Nov 2025 12:49:00 +0000 Subject: [PATCH] Add declarative definitions import service --- bin/get-graphql-schemas.js | 7 + graphql.config.ts | 1 + packages/app/project.json | 26 +- .../admin/generated/metafield_definitions.ts | 253 +++ .../admin/generated/metaobject_definitions.ts | 342 ++++ .../api/graphql/admin/generated/types.d.ts | 230 +++ .../queries/metafield_definitions.graphql | 36 + .../queries/metaobject_definitions.graphql | 51 + .../services/generate/shop-import/dcdd.d.ts | 123 ++ .../declarative-definitions.test.ts | 1407 +++++++++++++++++ .../shop-import/declarative-definitions.ts | 757 +++++++++ 11 files changed, 3228 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/cli/api/graphql/admin/generated/metafield_definitions.ts create mode 100644 packages/app/src/cli/api/graphql/admin/generated/metaobject_definitions.ts create mode 100644 packages/app/src/cli/api/graphql/admin/generated/types.d.ts create mode 100644 packages/app/src/cli/api/graphql/admin/queries/metafield_definitions.graphql create mode 100644 packages/app/src/cli/api/graphql/admin/queries/metaobject_definitions.graphql create mode 100644 packages/app/src/cli/services/generate/shop-import/dcdd.d.ts create mode 100644 packages/app/src/cli/services/generate/shop-import/declarative-definitions.test.ts create mode 100644 packages/app/src/cli/services/generate/shop-import/declarative-definitions.ts diff --git a/bin/get-graphql-schemas.js b/bin/get-graphql-schemas.js index f67cce34f7..7033028385 100755 --- a/bin/get-graphql-schemas.js +++ b/bin/get-graphql-schemas.js @@ -78,6 +78,13 @@ const schemas = [ localPath: './packages/app/src/cli/api/graphql/bulk-operations/admin_schema.graphql', usesLfs: true, }, + { + owner: 'shop', + repo: 'world', + pathToFile: 'areas/core/shopify/db/graphql/admin_schema_unstable_public.graphql', + localPath: './packages/app/src/cli/api/graphql/admin/admin_schema.graphql', + usesLfs: true, + }, ] diff --git a/graphql.config.ts b/graphql.config.ts index dc34cb0636..5396c4d64b 100644 --- a/graphql.config.ts +++ b/graphql.config.ts @@ -84,5 +84,6 @@ export default { bulkOperations: projectFactory('bulk-operations', 'admin_schema.graphql'), webhooks: projectFactory('webhooks', 'webhooks_schema.graphql'), functions: projectFactory('functions', 'functions_cli_schema.graphql', 'app'), + adminAsApp: projectFactory('admin', 'admin_schema.graphql'), }, } diff --git a/packages/app/project.json b/packages/app/project.json index fe5bbddfc1..6a26cf6d45 100644 --- a/packages/app/project.json +++ b/packages/app/project.json @@ -59,7 +59,8 @@ "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts", - "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts" + "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" ], "options": { "commands": [ @@ -70,7 +71,8 @@ "pnpm eslint 'src/cli/api/graphql/app-management/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/webhooks/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/functions/generated/**/*.{ts,tsx}' --fix", - "pnpm eslint 'src/cli/api/graphql/bulk-operations/generated/**/*.{ts,tsx}' --fix" + "pnpm eslint 'src/cli/api/graphql/bulk-operations/generated/**/*.{ts,tsx}' --fix", + "pnpm eslint 'src/cli/api/graphql/admin/generated/**/*.{ts,tsx}' --fix" ], "cwd": "packages/app" } @@ -163,6 +165,17 @@ "cwd": "{workspaceRoot}" } }, + "graphql-codegen:generate:admin-as-app": { + "executor": "nx:run-commands", + "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/admin/**/*.graphql"], + "outputs": ["{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts"], + "options": { + "commands": [ + "pnpm exec graphql-codegen --project=adminAsApp" + ], + "cwd": "{workspaceRoot}" + } + }, "graphql-codegen:postfix": { "executor": "nx:run-commands", "dependsOn": [ @@ -173,7 +186,8 @@ "graphql-codegen:generate:app-management", "graphql-codegen:generate:webhooks", "graphql-codegen:generate:functions", - "graphql-codegen:generate:bulk-operations" + "graphql-codegen:generate:bulk-operations", + "graphql-codegen:generate:admin-as-app" ], "inputs": [{ "dependentTasksOutputFiles": "**/*.ts" }], "outputs": [ @@ -184,7 +198,8 @@ "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts", - "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts" + "{projectRoot}/src/cli/api/graphql/bulk-operations/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" ], "options": { "commands": [ @@ -195,7 +210,8 @@ "find ./packages/app/src/cli/api/graphql/app-management/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/webhooks/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/functions/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", - "find ./packages/app/src/cli/api/graphql/bulk-operations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" + "find ./packages/app/src/cli/api/graphql/bulk-operations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", + "find ./packages/app/src/cli/api/graphql/admin/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" ], "cwd": "{workspaceRoot}" } diff --git a/packages/app/src/cli/api/graphql/admin/generated/metafield_definitions.ts b/packages/app/src/cli/api/graphql/admin/generated/metafield_definitions.ts new file mode 100644 index 0000000000..d020d31e91 --- /dev/null +++ b/packages/app/src/cli/api/graphql/admin/generated/metafield_definitions.ts @@ -0,0 +1,253 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type MetafieldForImportFragment = { + key: string + name: string + namespace: string + description?: string | null + type: {category: string; name: string} + access: { + admin?: Types.MetafieldAdminAccess | null + storefront?: Types.MetafieldStorefrontAccess | null + customerAccount: Types.MetafieldCustomerAccountAccess + } + capabilities: {adminFilterable: {enabled: boolean}} + validations: {name: string; value?: string | null}[] +} + +export type MetafieldDefinitionsQueryVariables = Types.Exact<{ + ownerType: Types.MetafieldOwnerType + after?: Types.InputMaybe +}> + +export type MetafieldDefinitionsQuery = { + metafieldDefinitions: { + pageInfo: {hasNextPage: boolean; endCursor?: string | null} + nodes: { + key: string + name: string + namespace: string + description?: string | null + type: {category: string; name: string} + access: { + admin?: Types.MetafieldAdminAccess | null + storefront?: Types.MetafieldStorefrontAccess | null + customerAccount: Types.MetafieldCustomerAccountAccess + } + capabilities: {adminFilterable: {enabled: boolean}} + validations: {name: string; value?: string | null}[] + }[] + } +} + +export const MetafieldForImportFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: {kind: 'Name', value: 'MetafieldForImport'}, + typeCondition: {kind: 'NamedType', name: {kind: 'Name', value: 'MetafieldDefinition'}}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'key'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'namespace'}}, + {kind: 'Field', name: {kind: 'Name', value: 'description'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'type'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'category'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'access'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'admin'}}, + {kind: 'Field', name: {kind: 'Name', value: 'storefront'}}, + {kind: 'Field', name: {kind: 'Name', value: 'customerAccount'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'capabilities'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'adminFilterable'}, + selectionSet: { + kind: 'SelectionSet', + selections: [{kind: 'Field', name: {kind: 'Name', value: 'enabled'}}], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'validations'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'value'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode +export const MetafieldDefinitions = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'metafieldDefinitions'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'ownerType'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'MetafieldOwnerType'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'after'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'metafieldDefinitions'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'ownerType'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'ownerType'}}, + }, + {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '30'}}, + { + kind: 'Argument', + name: {kind: 'Name', value: 'after'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'after'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'pageInfo'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'hasNextPage'}}, + {kind: 'Field', name: {kind: 'Name', value: 'endCursor'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'nodes'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'FragmentSpread', name: {kind: 'Name', value: 'MetafieldForImport'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: {kind: 'Name', value: 'MetafieldForImport'}, + typeCondition: {kind: 'NamedType', name: {kind: 'Name', value: 'MetafieldDefinition'}}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'key'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'namespace'}}, + {kind: 'Field', name: {kind: 'Name', value: 'description'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'type'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'category'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'access'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'admin'}}, + {kind: 'Field', name: {kind: 'Name', value: 'storefront'}}, + {kind: 'Field', name: {kind: 'Name', value: 'customerAccount'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'capabilities'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'adminFilterable'}, + selectionSet: { + kind: 'SelectionSet', + selections: [{kind: 'Field', name: {kind: 'Name', value: 'enabled'}}], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'validations'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'value'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/admin/generated/metaobject_definitions.ts b/packages/app/src/cli/api/graphql/admin/generated/metaobject_definitions.ts new file mode 100644 index 0000000000..8b77fd8e4a --- /dev/null +++ b/packages/app/src/cli/api/graphql/admin/generated/metaobject_definitions.ts @@ -0,0 +1,342 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type MetaobjectForImportFragment = { + type: string + name: string + description?: string | null + displayNameKey?: string | null + access: {admin: Types.MetaobjectAdminAccess; storefront: Types.MetaobjectStorefrontAccess} + capabilities: { + publishable: {enabled: boolean} + translatable: {enabled: boolean} + renderable?: { + enabled: boolean + data?: {metaTitleKey?: string | null; metaDescriptionKey?: string | null} | null + } | null + } + fieldDefinitions: { + key: string + name: string + description?: string | null + required: boolean + type: {category: string; name: string} + validations: {name: string; value?: string | null}[] + }[] +} + +export type MetaobjectDefinitionsQueryVariables = Types.Exact<{ + after?: Types.InputMaybe +}> + +export type MetaobjectDefinitionsQuery = { + metaobjectDefinitions: { + pageInfo: {hasNextPage: boolean; endCursor?: string | null} + nodes: { + type: string + name: string + description?: string | null + displayNameKey?: string | null + access: {admin: Types.MetaobjectAdminAccess; storefront: Types.MetaobjectStorefrontAccess} + capabilities: { + publishable: {enabled: boolean} + translatable: {enabled: boolean} + renderable?: { + enabled: boolean + data?: {metaTitleKey?: string | null; metaDescriptionKey?: string | null} | null + } | null + } + fieldDefinitions: { + key: string + name: string + description?: string | null + required: boolean + type: {category: string; name: string} + validations: {name: string; value?: string | null}[] + }[] + }[] + } +} + +export const MetaobjectForImportFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: {kind: 'Name', value: 'MetaobjectForImport'}, + typeCondition: {kind: 'NamedType', name: {kind: 'Name', value: 'MetaobjectDefinition'}}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'type'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'description'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'access'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'admin'}}, + {kind: 'Field', name: {kind: 'Name', value: 'storefront'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: 'displayNameKey'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'capabilities'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'publishable'}, + selectionSet: { + kind: 'SelectionSet', + selections: [{kind: 'Field', name: {kind: 'Name', value: 'enabled'}}], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'translatable'}, + selectionSet: { + kind: 'SelectionSet', + selections: [{kind: 'Field', name: {kind: 'Name', value: 'enabled'}}], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'renderable'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'enabled'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'data'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'metaTitleKey'}}, + {kind: 'Field', name: {kind: 'Name', value: 'metaDescriptionKey'}}, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'fieldDefinitions'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'key'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'type'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'category'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: 'description'}}, + {kind: 'Field', name: {kind: 'Name', value: 'required'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'validations'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'value'}}, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode +export const MetaobjectDefinitions = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'metaobjectDefinitions'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'after'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'metaobjectDefinitions'}, + arguments: [ + {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '10'}}, + { + kind: 'Argument', + name: {kind: 'Name', value: 'after'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'after'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'pageInfo'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'hasNextPage'}}, + {kind: 'Field', name: {kind: 'Name', value: 'endCursor'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'nodes'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'FragmentSpread', name: {kind: 'Name', value: 'MetaobjectForImport'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: {kind: 'Name', value: 'MetaobjectForImport'}, + typeCondition: {kind: 'NamedType', name: {kind: 'Name', value: 'MetaobjectDefinition'}}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'type'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'description'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'access'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'admin'}}, + {kind: 'Field', name: {kind: 'Name', value: 'storefront'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: 'displayNameKey'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'capabilities'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'publishable'}, + selectionSet: { + kind: 'SelectionSet', + selections: [{kind: 'Field', name: {kind: 'Name', value: 'enabled'}}], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'translatable'}, + selectionSet: { + kind: 'SelectionSet', + selections: [{kind: 'Field', name: {kind: 'Name', value: 'enabled'}}], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'renderable'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'enabled'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'data'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'metaTitleKey'}}, + {kind: 'Field', name: {kind: 'Name', value: 'metaDescriptionKey'}}, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'fieldDefinitions'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'key'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'type'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'category'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: 'description'}}, + {kind: 'Field', name: {kind: 'Name', value: 'required'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'validations'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'value'}}, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/admin/generated/types.d.ts b/packages/app/src/cli/api/graphql/admin/generated/types.d.ts new file mode 100644 index 0000000000..a15582d784 --- /dev/null +++ b/packages/app/src/cli/api/graphql/admin/generated/types.d.ts @@ -0,0 +1,230 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax */ +import {JsonMapType} from '@shopify/cli-kit/node/toml' + +export type Maybe = T | null +export type InputMaybe = Maybe +export type Exact = {[K in keyof T]: T[K]} +export type MakeOptional = Omit & {[SubKey in K]?: Maybe} +export type MakeMaybe = Omit & {[SubKey in K]: Maybe} +export type MakeEmpty = {[_ in K]?: never} +export type Incremental = T | {[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never} +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: {input: string; output: string} + String: {input: string; output: string} + Boolean: {input: boolean; output: boolean} + Int: {input: number; output: number} + Float: {input: number; output: number} + /** + * An Amazon Web Services Amazon Resource Name (ARN), including the Region and account ID. + * For more information, refer to [Amazon Resource Names](https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html). + */ + ARN: {input: any; output: any} + /** + * Represents non-fractional signed whole numeric values. Since the value may + * exceed the size of a 32-bit integer, it's encoded as a string. + */ + BigInt: {input: any; output: any} + /** + * A string containing a hexadecimal representation of a color. + * + * For example, "#6A8D48". + */ + Color: {input: any; output: any} + /** + * Represents an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)-encoded date string. + * For example, September 7, 2019 is represented as `"2019-07-16"`. + */ + Date: {input: any; output: any} + /** + * Represents an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)-encoded date and time string. + * For example, 3:50 pm on September 7, 2019 in the time zone of UTC (Coordinated Universal Time) is + * represented as `"2019-09-07T15:50:00Z`". + */ + DateTime: {input: any; output: any} + /** + * A signed decimal number, which supports arbitrary precision and is serialized as a string. + * + * Example values: `"29.99"`, `"29.999"`. + */ + Decimal: {input: any; output: any} + /** + * A string containing a strict subset of HTML code. Non-allowed tags will be stripped out. + * Allowed tags: + * * `a` (allowed attributes: `href`, `target`) + * * `b` + * * `br` + * * `em` + * * `i` + * * `strong` + * * `u` + * Use [HTML](https://shopify.dev/api/admin-graphql/latest/scalars/HTML) instead if you need to + * include other HTML tags. + * + * Example value: `"Your current domain is example.myshopify.com."` + */ + FormattedString: {input: any; output: any} + /** + * A string containing HTML code. Refer to the [HTML spec](https://html.spec.whatwg.org/#elements-3) for a + * complete list of HTML elements. + * + * Example value: `"

Grey cotton knit sweater.

"` + */ + HTML: {input: any; output: any} + /** + * A [JSON](https://www.json.org/json-en.html) object. + * + * Example value: + * `{ + * "product": { + * "id": "gid://shopify/Product/1346443542550", + * "title": "White T-shirt", + * "options": [{ + * "name": "Size", + * "values": ["M", "L"] + * }] + * } + * }` + */ + JSON: {input: JsonMapType | string; output: JsonMapType} + /** A monetary value string without a currency symbol or code. Example value: `"100.57"`. */ + Money: {input: any; output: any} + /** A scalar value. */ + Scalar: {input: any; output: any} + /** + * Represents a unique identifier in the Storefront API. A `StorefrontID` value can + * be used wherever an ID is expected in the Storefront API. + * + * Example value: `"Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0LzEwMDc5Nzg1MTAw"`. + */ + StorefrontID: {input: any; output: any} + /** + * Represents an [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) and + * [RFC 3987](https://datatracker.ietf.org/doc/html/rfc3987)-compliant URI string. + * + * For example, `"https://example.myshopify.com"` is a valid URL. It includes a scheme (`https`) and a host + * (`example.myshopify.com`). + */ + URL: {input: string; output: string} + /** + * An unsigned 64-bit integer. Represents whole numeric values between 0 and 2^64 - 1 encoded as a string of base-10 digits. + * + * Example value: `"50"`. + */ + UnsignedInt64: {input: any; output: any} + /** + * Time between UTC time and a location's observed time, in the format `"+HH:MM"` or `"-HH:MM"`. + * + * Example value: `"-07:00"`. + */ + UtcOffset: {input: any; output: any} +} + +/** Metafield access permissions for the Admin API. */ +export type MetafieldAdminAccess = + /** The merchant has read-only access. No other apps have access. */ + | 'MERCHANT_READ' + /** The merchant has read and write access. No other apps have access. */ + | 'MERCHANT_READ_WRITE' + /** The merchant and other apps have no access. */ + | 'PRIVATE' + /** The merchant and other apps have read-only access. */ + | 'PUBLIC_READ' + /** The merchant and other apps have read and write access. */ + | 'PUBLIC_READ_WRITE' + +/** Metafield access permissions for the Customer Account API. */ +export type MetafieldCustomerAccountAccess = + /** No access. */ + | 'NONE' + /** Read-only access. */ + | 'READ' + /** Read and write access. */ + | 'READ_WRITE' + +/** Possible types of a metafield's owner resource. */ +export type MetafieldOwnerType = + /** The Api Permission metafield owner type. */ + | 'API_PERMISSION' + /** The Article metafield owner type. */ + | 'ARTICLE' + /** The Blog metafield owner type. */ + | 'BLOG' + /** The Cart Transform metafield owner type. */ + | 'CARTTRANSFORM' + /** The Collection metafield owner type. */ + | 'COLLECTION' + /** The Company metafield owner type. */ + | 'COMPANY' + /** The Company Location metafield owner type. */ + | 'COMPANY_LOCATION' + /** The Customer metafield owner type. */ + | 'CUSTOMER' + /** The Delivery Customization metafield owner type. */ + | 'DELIVERY_CUSTOMIZATION' + /** The Delivery Method metafield owner type. */ + | 'DELIVERY_METHOD' + /** The Delivery Option Generator metafield owner type. */ + | 'DELIVERY_OPTION_GENERATOR' + /** The Discount metafield owner type. */ + | 'DISCOUNT' + /** The draft order metafield owner type. */ + | 'DRAFTORDER' + /** The Fulfillment Constraint Rule metafield owner type. */ + | 'FULFILLMENT_CONSTRAINT_RULE' + /** The GiftCardTransaction metafield owner type. */ + | 'GIFT_CARD_TRANSACTION' + /** The Location metafield owner type. */ + | 'LOCATION' + /** The Market metafield owner type. */ + | 'MARKET' + /** The Media Image metafield owner type. */ + | 'MEDIA_IMAGE' + /** The Order metafield owner type. */ + | 'ORDER' + /** The Order Routing Location Rule metafield owner type. */ + | 'ORDER_ROUTING_LOCATION_RULE' + /** The Page metafield owner type. */ + | 'PAGE' + /** The Payment Customization metafield owner type. */ + | 'PAYMENT_CUSTOMIZATION' + /** The Product metafield owner type. */ + | 'PRODUCT' + /** The Product Variant metafield owner type. */ + | 'PRODUCTVARIANT' + /** The Selling Plan metafield owner type. */ + | 'SELLING_PLAN' + /** The Shop metafield owner type. */ + | 'SHOP' + /** The Validation metafield owner type. */ + | 'VALIDATION' + +/** Metafield access permissions for the Storefront API. */ +export type MetafieldStorefrontAccess = + /** No access. */ + | 'NONE' + /** Read-only access. */ + | 'PUBLIC_READ' + +/** + * Metaobject access permissions for the Admin API. When the metaobject is app-owned, the owning app always has + * full access. + */ +export type MetaobjectAdminAccess = + /** The merchant has read-only access. No other apps have access. */ + | 'MERCHANT_READ' + /** The merchant has read and write access. No other apps have access. */ + | 'MERCHANT_READ_WRITE' + /** The merchant and other apps have no access. */ + | 'PRIVATE' + /** The merchant and other apps have read-only access. */ + | 'PUBLIC_READ' + /** The merchant and other apps have read and write access. */ + | 'PUBLIC_READ_WRITE' + +/** Metaobject access permissions for the Storefront API. */ +export type MetaobjectStorefrontAccess = + /** No access. */ + | 'NONE' + /** Read-only access. */ + | 'PUBLIC_READ' diff --git a/packages/app/src/cli/api/graphql/admin/queries/metafield_definitions.graphql b/packages/app/src/cli/api/graphql/admin/queries/metafield_definitions.graphql new file mode 100644 index 0000000000..2bb2162d36 --- /dev/null +++ b/packages/app/src/cli/api/graphql/admin/queries/metafield_definitions.graphql @@ -0,0 +1,36 @@ +fragment MetafieldForImport on MetafieldDefinition { + key + name + namespace + description + type { + category + name + } + access { + admin + storefront + customerAccount + } + capabilities { + adminFilterable { + enabled + } + } + validations { + name + value + } +} + +query metafieldDefinitions($ownerType: MetafieldOwnerType!, $after: String) { + metafieldDefinitions(ownerType: $ownerType, first: 30, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ...MetafieldForImport + } + } +} diff --git a/packages/app/src/cli/api/graphql/admin/queries/metaobject_definitions.graphql b/packages/app/src/cli/api/graphql/admin/queries/metaobject_definitions.graphql new file mode 100644 index 0000000000..a0e411db28 --- /dev/null +++ b/packages/app/src/cli/api/graphql/admin/queries/metaobject_definitions.graphql @@ -0,0 +1,51 @@ +fragment MetaobjectForImport on MetaobjectDefinition { + type + name + description + access { + admin + storefront + } + displayNameKey + capabilities { + publishable { + enabled + } + translatable { + enabled + } + renderable { + enabled + data { + metaTitleKey + metaDescriptionKey + } + } + } + fieldDefinitions { + key + name + type { + category + name + } + description + required + validations { + name + value + } + } +} + +query metaobjectDefinitions($after: String) { + metaobjectDefinitions(first: 10, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ...MetaobjectForImport + } + } +} diff --git a/packages/app/src/cli/services/generate/shop-import/dcdd.d.ts b/packages/app/src/cli/services/generate/shop-import/dcdd.d.ts new file mode 100644 index 0000000000..80d08fd467 --- /dev/null +++ b/packages/app/src/cli/services/generate/shop-import/dcdd.d.ts @@ -0,0 +1,123 @@ +interface MetafieldsOptions { + api_version: string +} + +export interface ValidationRule { + [key: string]: unknown +} + +interface MetafieldCapabilities { + admin_filterable?: boolean +} + +interface MetafieldAccessControls { + admin?: 'merchant_read' | 'merchant_read_write' + storefront?: 'none' | 'public_read' + customer_account?: 'none' | 'read' | 'read_write' +} + +export interface Metafield { + type: MetaFieldDefinitionTypes + name?: string + description?: string + validations?: ValidationRule + capabilities?: MetafieldCapabilities + access?: MetafieldAccessControls +} + +interface MetafieldsCollection { + [key: string]: Metafield +} + +interface OwnerTypeMetafieldNamespaces { + app?: MetafieldsCollection + [key: string]: MetafieldsCollection | undefined +} + +interface OwnerTypeWithMetafields { + metafields: OwnerTypeMetafieldNamespaces +} + +interface MetaobjectFieldCapabilities { + admin_filterable?: boolean +} + +interface MetaobjectAccessControls { + admin?: 'merchant_read' | 'merchant_read_write' + storefront?: 'none' | 'public_read' +} + +interface MetaobjectCapabilities { + publishable?: boolean + translatable?: boolean + renderable?: boolean + renderable_meta_title_field?: string + renderable_meta_description_field?: string +} + +// To handle all metafield types including pattern-based types like "metaobject_reference<...>" +type MetaFieldDefinitionTypes = string + +export interface FieldObject { + type: MetaFieldDefinitionTypes + name?: string + description?: string + required?: boolean + validations?: ValidationRule + capabilities?: MetaobjectFieldCapabilities +} + +interface MetaobjectFieldCollection { + [key: string]: MetaFieldDefinitionTypes | FieldObject +} + +export interface MetaObject { + name?: string + description?: string + display_name_field?: string + fields: MetaobjectFieldCollection + capabilities?: MetaobjectCapabilities + access?: MetaobjectAccessControls +} + +interface MetaobjectsCollection { + [key: string]: MetaObject +} + +interface MetaobjectNamespaces { + app?: MetaobjectsCollection + standard_metaobjects?: string[] +} + +// Root type - this is what you can cast parsed JSON to +export interface DeclarativeCustomDataDefinition { + metafields?: MetafieldsOptions + company?: OwnerTypeWithMetafields + company_location?: OwnerTypeWithMetafields + payment_customization?: OwnerTypeWithMetafields + validation?: OwnerTypeWithMetafields + customer?: OwnerTypeWithMetafields + delivery_customization?: OwnerTypeWithMetafields + draft_order?: OwnerTypeWithMetafields + market?: OwnerTypeWithMetafields + cart_transform?: OwnerTypeWithMetafields + collection?: OwnerTypeWithMetafields + product?: OwnerTypeWithMetafields + variant?: OwnerTypeWithMetafields + article?: OwnerTypeWithMetafields + blog?: OwnerTypeWithMetafields + page?: OwnerTypeWithMetafields + fulfillment_constraint_rule?: OwnerTypeWithMetafields + order_routing_location_rule?: OwnerTypeWithMetafields + discount?: OwnerTypeWithMetafields + order?: OwnerTypeWithMetafields + location?: OwnerTypeWithMetafields + shop?: OwnerTypeWithMetafields + selling_plan?: OwnerTypeWithMetafields + gift_card_transaction?: OwnerTypeWithMetafields + delivery_method?: OwnerTypeWithMetafields + delivery_option_generator?: OwnerTypeWithMetafields + metaobjects?: MetaobjectNamespaces +} + +export type MetafieldOwners = Exclude diff --git a/packages/app/src/cli/services/generate/shop-import/declarative-definitions.test.ts b/packages/app/src/cli/services/generate/shop-import/declarative-definitions.test.ts new file mode 100644 index 0000000000..72151743a0 --- /dev/null +++ b/packages/app/src/cli/services/generate/shop-import/declarative-definitions.test.ts @@ -0,0 +1,1407 @@ +import { + processDeclarativeDefinitionNodes, + MetafieldNodesInput, + renderTomlStringWithFormatting, + paginatedQuery, + importDeclarativeDefinitions, +} from './declarative-definitions.js' +import { + MetaobjectDefinitions, + MetaobjectDefinitionsQuery, + MetaobjectForImportFragment, +} from '../../../api/graphql/admin/generated/metaobject_definitions.js' +import { + MetafieldDefinitions, + MetafieldDefinitionsQuery, + MetafieldForImportFragment, +} from '../../../api/graphql/admin/generated/metafield_definitions.js' +import {adminAsAppRequestDoc} from '../../../api/admin-as-app.js' +import {describe, expect, test, vi} from 'vitest' +import * as output from '@shopify/cli-kit/node/output' +import {stringifyMessage} from '@shopify/cli-kit/node/output' +import {TypedDocumentNode} from '@graphql-typed-document-node/core' +import {AdminSession, ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../../api/admin-as-app.js') +vi.mock('@shopify/cli-kit/node/session') + +const defaultMetaobjectFragment = { + name: 'test', + access: { + admin: 'MERCHANT_READ', + storefront: 'NONE', + }, + capabilities: { + publishable: { + enabled: false, + }, + translatable: { + enabled: false, + }, + }, + fieldDefinitions: [ + { + key: 'foo', + name: 'foo', + required: false, + type: { + category: 'string', + name: 'single_line_text_field', + }, + validations: [], + }, + ], +} as const satisfies Partial + +const defaultMetafieldFragment = { + name: 'Color', + key: 'color', + description: 'The color of the product', + access: { + admin: 'MERCHANT_READ', + storefront: 'NONE', + customerAccount: 'NONE', + }, + capabilities: { + adminFilterable: { + enabled: false, + }, + }, + type: { + category: 'string', + name: 'single_line_text_field', + }, + validations: [], +} as const satisfies Partial + +describe('processDeclarativeDefinitionNodes', () => { + test('returns empty TOML when given empty inputs', () => { + const result = processDeclarativeDefinitionNodes([], []) + + expect(result.metafieldCount).toBe(0) + expect(result.metaobjectCount).toBe(0) + expect(result.tomlContent).toMatchInlineSnapshot(`""`) + }) + + test('processes single metafield correctly', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.metafieldCount).toBe(1) + expect(result.metaobjectCount).toBe(0) + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + " + `) + }) + + test('processes metafields with custom namespace', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456--custom', + ...defaultMetafieldFragment, + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.metafieldCount).toBe(1) + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app:custom key: color owner_type: PRODUCT + [product.metafields.custom.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + " + `) + }) + + test('skips non-app-reserved metafields', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'custom', + ...defaultMetafieldFragment, + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.metafieldCount).toBe(0) + expect(result.tomlContent).toMatchInlineSnapshot(`""`) + }) + + test('processes metafields with validations', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + validations: [ + {name: 'min', value: '5'}, + {name: 'max', value: '50'}, + {name: 'choices', value: '["red", "blue", "green"]'}, + ], + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.metafieldCount).toBe(1) + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + + [product.metafields.app.color.validations] + min = 5 + max = 50 + choices = [\\"red\\", \\"blue\\", \\"green\\"] + " + `) + }) + + test('processes metafields with access controls', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + access: { + admin: 'MERCHANT_READ_WRITE', + storefront: 'PUBLIC_READ', + customerAccount: 'READ', + }, + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + access.admin = \\"merchant_read_write\\" + access.storefront = \\"public_read\\" + access.customer_account = \\"read\\" + " + `) + }) + + test('processes metafields with capabilities', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + capabilities: { + adminFilterable: { + enabled: true, + }, + }, + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + capabilities.admin_filterable = true + " + `) + }) + + test('processes multiple metafields across different owner types', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + key: 'color', + name: 'Color', + } as MetafieldForImportFragment, + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + key: 'size', + name: 'Size', + } as MetafieldForImportFragment, + ], + }, + { + ownerType: 'customer', + graphQLOwner: 'CUSTOMER', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + key: 'vip_status', + name: 'VIP Status', + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.metafieldCount).toBe(3) + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + + # namespace: $app key: size owner_type: PRODUCT + [product.metafields.app.size] + name = \\"Size\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + + # namespace: $app key: vip_status owner_type: CUSTOMER + [customer.metafields.app.vip_status] + name = \\"VIP Status\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + " + `) + }) + + test('processes single metaobject correctly', () => { + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + type: 'app--345678--test', + ...defaultMetaobjectFragment, + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.metafieldCount).toBe(0) + expect(result.metaobjectCount).toBe(1) + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:test + [metaobjects.app.test.fields] + foo = \\"single_line_text_field\\" + " + `) + }) + + test('processes metaobjects with full configuration', () => { + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + access: { + admin: 'MERCHANT_READ_WRITE', + storefront: 'PUBLIC_READ', + }, + capabilities: { + publishable: { + enabled: true, + }, + translatable: { + enabled: true, + }, + renderable: { + enabled: true, + data: { + metaTitleKey: 'title', + metaDescriptionKey: 'description', + }, + }, + }, + name: 'A test', + type: 'app--345678--test', + description: 'A test metaobject', + displayNameKey: 'title', + fieldDefinitions: [ + { + key: 'title', + name: 'Title', + required: true, + type: { + category: 'string', + name: 'single_line_text_field', + }, + description: 'The title of the test metaobject', + validations: [ + {name: 'min', value: '1'}, + {name: 'max', value: '100'}, + ], + }, + ], + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.metaobjectCount).toBe(1) + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:test + [metaobjects.app.test] + name = \\"A test\\" + description = \\"A test metaobject\\" + display_name_field = \\"title\\" + access.admin = \\"merchant_read_write\\" + access.storefront = \\"public_read\\" + capabilities.translatable = true + capabilities.publishable = true + capabilities.renderable = true + capabilities.renderable_meta_title_field = \\"title\\" + capabilities.renderable_meta_description_field = \\"description\\" + + [metaobjects.app.test.fields.title] + type = \\"single_line_text_field\\" + description = \\"The title of the test metaobject\\" + name = \\"Title\\" + required = true + + [metaobjects.app.test.fields.title.validations] + min = 1 + max = 100 + " + `) + }) + + test('skips non-app-reserved metaobjects', () => { + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + type: 'custom', + ...defaultMetaobjectFragment, + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.metaobjectCount).toBe(0) + expect(result.tomlContent).toMatchInlineSnapshot(`""`) + }) + + test('processes both metafields and metaobjects together', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + } as MetafieldForImportFragment, + ], + }, + ] + + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + type: 'app--345678--test', + ...defaultMetaobjectFragment, + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, metaobjectNodes) + + expect(result.metafieldCount).toBe(1) + expect(result.metaobjectCount).toBe(1) + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:test + [metaobjects.app.test.fields] + foo = \\"single_line_text_field\\" + + # namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + " + `) + }) + + test('handles metafields where name equals key', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + key: 'samename', + name: 'samename', + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: samename owner_type: PRODUCT + [product.metafields.app.samename] + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + " + `) + // Should not contain name since it equals key + expect(result.tomlContent).not.toContain('name = "samename"') + }) + + test('handles complex validation types', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + validations: [ + {name: 'simple_string', value: '"test"'}, + {name: 'simple_number', value: '42'}, + {name: 'simple_boolean', value: 'true'}, + {name: 'array_strings', value: '["a", "b"]'}, + {name: 'complex_object', value: '{"nested": {"value": 123}}'}, + ], + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + + [product.metafields.app.color.validations] + simple_string = \\"test\\" + simple_number = 42 + simple_boolean = true + array_strings = [\\"a\\", \\"b\\"] + complex_object = '{\\"nested\\":{\\"value\\":123}}' + " + `) + }) + + test('handles metafields with undefined or null descriptions', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + description: null, + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + " + `) + expect(result.tomlContent).not.toContain('description =') + }) + + test('handles metafields with empty validations array', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + validations: [], + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + " + `) + }) + + test('handles metaobject fields with different types', () => { + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + type: 'app--345678--test', + ...defaultMetaobjectFragment, + fieldDefinitions: [ + { + key: 'price_field', + name: 'Price', + required: false, + type: { + category: 'money', + name: 'money', + }, + validations: [], + }, + ], + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.metaobjectCount).toBe(1) + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:test + [metaobjects.app.test.fields.price_field] + type = \\"money\\" + name = \\"Price\\" + " + `) + }) + + test('handles metafields with validations that have null values', () => { + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + validations: [ + {name: 'min', value: '5'}, + {name: 'max', value: null}, + ], + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + + [product.metafields.app.color.validations] + min = 5 + " + `) + // Should skip validations with null values + expect(result.tomlContent).not.toContain('max =') + }) + + test('handles metaobjects with all access set to null', () => { + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + type: 'app--345678--test', + ...defaultMetaobjectFragment, + access: { + admin: 'MERCHANT_READ', + storefront: 'NONE', + }, + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:test + [metaobjects.app.test.fields] + foo = \\"single_line_text_field\\" + " + `) + }) + + test('handles metaobjects without renderable capability', () => { + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + type: 'app--345678--test', + ...defaultMetaobjectFragment, + capabilities: { + ...defaultMetaobjectFragment.capabilities, + renderable: null, + }, + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:test + [metaobjects.app.test.fields] + foo = \\"single_line_text_field\\" + " + `) + }) + + test('handles fields with metaobject reference validations', () => { + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + type: 'app--345678--test', + ...defaultMetaobjectFragment, + fieldDefinitions: [ + { + key: 'price_field', + name: 'Price', + required: false, + type: { + category: 'reference', + name: 'list.metaobject_reference', + }, + validations: [ + { + name: 'metaobject_definition_type', + value: 'app--297771827201--referenced_into', + }, + { + name: 'metaobject_definition_id', + value: 'gid://shopify/MetaobjectDefinition/29112238410', + }, + { + name: 'list.max', + value: '3', + }, + ], + }, + ], + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:test + [metaobjects.app.test.fields.price_field] + type = \\"list.metaobject_reference<$app:referenced_into>\\" + name = \\"Price\\" + + [metaobjects.app.test.fields.price_field.validations] + \\"list.max\\" = 3 + " + `) + }) + + test('handles fields with mixed reference validations', () => { + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + type: 'app--345678--test', + ...defaultMetaobjectFragment, + fieldDefinitions: [ + { + key: 'price_field', + name: 'Price', + required: false, + type: { + category: 'reference', + name: 'mixed_reference', + }, + validations: [ + { + name: 'metaobject_definition_types', + value: '["app--297771827201--referenced_into","app--297771827201--referenced_into2"]', + }, + { + name: 'metaobject_definition_ids', + value: + '["gid://shopify/MetaobjectDefinition/29112238410","gid://shopify/MetaobjectDefinition/29113090378"]', + }, + ], + }, + ], + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:test + [metaobjects.app.test.fields.price_field] + type = \\"mixed_reference<$app:referenced_into,$app:referenced_into2>\\" + name = \\"Price\\" + " + `) + }) + + test('handles metafields with app--123456 namespace (no custom segment)', () => { + // This tests the case where namespace is just "app--123456" without a trailing custom segment + // The simplifyAppReservedNamespace function should return 'app' in this case + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + // No trailing segment like "--custom" + namespace: 'app--999999', + ...defaultMetafieldFragment, + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.metafieldCount).toBe(1) + // Should use 'app' as the namespace key since there's no custom segment + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + " + `) + }) + + test('handles metaobjects with app--123456 type (no custom segment)', () => { + // Test metaobject where type is "app--123456" without trailing segment + const metaobjectNodes: MetaobjectForImportFragment[] = [ + { + // No trailing segment + type: 'app--999999', + ...defaultMetaobjectFragment, + } as MetaobjectForImportFragment, + ] + + const result = processDeclarativeDefinitionNodes([], metaobjectNodes) + + expect(result.metaobjectCount).toBe(1) + // Should use 'app' as the type name. Name is included since it differs from typeName ('app') + expect(result.tomlContent).toMatchInlineSnapshot(` + "# type: $app:app + [metaobjects.app.app] + name = \\"test\\" + + [metaobjects.app.app.fields] + foo = \\"single_line_text_field\\" + " + `) + }) + + test('handles validations with invalid JSON values', () => { + // This tests the catch block in validationsNodeToObject + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + validations: [ + {name: 'valid_json', value: '42'}, + // Invalid JSON - should be returned as-is + {name: 'invalid_json', value: 'this is not valid json {{'}, + ], + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.metafieldCount).toBe(1) + // Invalid JSON should be returned as the raw string + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + + [product.metafields.app.color.validations] + valid_json = 42 + invalid_json = \\"this is not valid json {{\\" + " + `) + }) + + test('handles metafields with customerAccount READ_WRITE access', () => { + // This tests the READ_WRITE case in graphQLToCustomerAccountAccess + const metafieldNodes: MetafieldNodesInput[] = [ + { + ownerType: 'product', + graphQLOwner: 'PRODUCT', + items: [ + { + namespace: 'app--123456', + ...defaultMetafieldFragment, + access: { + admin: 'MERCHANT_READ_WRITE', + storefront: 'PUBLIC_READ', + // Testing the READ_WRITE case + customerAccount: 'READ_WRITE', + }, + } as MetafieldForImportFragment, + ], + }, + ] + + const result = processDeclarativeDefinitionNodes(metafieldNodes, []) + + expect(result.tomlContent).toMatchInlineSnapshot(` + "# namespace: $app key: color owner_type: PRODUCT + [product.metafields.app.color] + name = \\"Color\\" + type = \\"single_line_text_field\\" + description = \\"The color of the product\\" + access.admin = \\"merchant_read_write\\" + access.storefront = \\"public_read\\" + access.customer_account = \\"read_write\\" + " + `) + }) +}) + +describe('renderTomlStringWithFormatting', () => { + test('renders TOML with colored formatting for different line types', () => { + const capturedOutput: string[] = [] + const outputInfoSpy = vi.spyOn(output, 'outputInfo').mockImplementation((content) => { + capturedOutput.push(stringifyMessage(content)) + }) + + const tomlContent = `# comment line +[section.header] +key = "value" +plain line` + + renderTomlStringWithFormatting(tomlContent) + + expect(capturedOutput).toMatchInlineSnapshot(` + [ + "# comment line", + "[section.header]", + "key = \\"value\\"", + "plain line", + ] + `) + + outputInfoSpy.mockRestore() + }) + + test('handles empty TOML content', () => { + const capturedOutput: string[] = [] + const outputInfoSpy = vi.spyOn(output, 'outputInfo').mockImplementation((content) => { + capturedOutput.push(stringifyMessage(content)) + }) + + renderTomlStringWithFormatting('') + + expect(capturedOutput).toMatchInlineSnapshot(` + [ + "", + ] + `) + + outputInfoSpy.mockRestore() + }) + + test('handles TOML with indented headers and comments', () => { + const capturedOutput: string[] = [] + const outputInfoSpy = vi.spyOn(output, 'outputInfo').mockImplementation((content) => { + capturedOutput.push(stringifyMessage(content)) + }) + + const tomlContent = ` [indented.header] + # indented comment +regular line` + + renderTomlStringWithFormatting(tomlContent) + + expect(capturedOutput).toMatchInlineSnapshot(` + [ + " [indented.header]", + " # indented comment", + "regular line", + ] + `) + + outputInfoSpy.mockRestore() + }) +}) + +describe('paginatedQuery', () => { + // Mock query and session for testing + const mockQuery = {} as TypedDocumentNode + const mockSession = {storeFqdn: 'test.myshopify.com'} as AdminSession + + test('returns items from a single page result', async () => { + const mockPerformQuery = vi.fn().mockResolvedValue({data: ['item1', 'item2']}) + + const result = await paginatedQuery({ + query: mockQuery, + session: mockSession, + toNodes: (res) => ({ + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: (res as {data: string[]}).data, + }), + toVariables: (cursor) => ({cursor}), + performQuery: mockPerformQuery, + }) + + expect(result).toEqual({status: 'ok', items: ['item1', 'item2']}) + expect(mockPerformQuery).toHaveBeenCalledTimes(1) + }) + + test('aggregates items from multiple pages', async () => { + const mockPerformQuery = vi + .fn() + .mockResolvedValueOnce({data: ['page1-item1', 'page1-item2'], hasNext: true, cursor: 'cursor1'}) + .mockResolvedValueOnce({data: ['page2-item1'], hasNext: false, cursor: null}) + + const result = await paginatedQuery({ + query: mockQuery, + session: mockSession, + toNodes: (res) => { + const response = res as {data: string[]; hasNext: boolean; cursor: string | null} + return { + pageInfo: {hasNextPage: response.hasNext, endCursor: response.cursor}, + nodes: response.data, + } + }, + toVariables: (cursor) => ({cursor}), + performQuery: mockPerformQuery, + }) + + expect(result).toEqual({status: 'ok', items: ['page1-item1', 'page1-item2', 'page2-item1']}) + expect(mockPerformQuery).toHaveBeenCalledTimes(2) + expect(mockPerformQuery).toHaveBeenNthCalledWith(1, {cursor: undefined}) + expect(mockPerformQuery).toHaveBeenNthCalledWith(2, {cursor: 'cursor1'}) + }) + + test('returns scope_error when ACCESS_DENIED error occurs', async () => { + const mockPerformQuery = vi.fn().mockRejectedValue(new Error('ACCESS_DENIED: Missing required scope')) + + const result = await paginatedQuery({ + query: mockQuery, + session: mockSession, + toNodes: (res) => ({pageInfo: {hasNextPage: false}, nodes: [res]}), + toVariables: (cursor) => ({cursor}), + performQuery: mockPerformQuery, + }) + + expect(result).toEqual({status: 'scope_error'}) + }) + + test('rethrows non-ACCESS_DENIED errors', async () => { + const mockPerformQuery = vi.fn().mockRejectedValue(new Error('Some other error')) + + await expect( + paginatedQuery({ + query: mockQuery, + session: mockSession, + toNodes: (res) => ({pageInfo: {hasNextPage: false}, nodes: [res]}), + toVariables: (cursor) => ({cursor}), + performQuery: mockPerformQuery, + }), + ).rejects.toThrow('Some other error') + }) + + test('handles non-Error rejections gracefully', async () => { + // When the rejection is not an Error instance, it should be rethrown + const mockPerformQuery = vi.fn().mockRejectedValue('string rejection') + + await expect( + paginatedQuery({ + query: mockQuery, + session: mockSession, + toNodes: (res) => ({pageInfo: {hasNextPage: false}, nodes: [res]}), + toVariables: (cursor) => ({cursor}), + performQuery: mockPerformQuery, + }), + ).rejects.toBe('string rejection') + }) +}) + +describe('importDeclarativeDefinitions', () => { + test('imports metafields and metaobjects from a shop and outputs TOML', async () => { + const outputMock = mockAndCaptureOutput() + + vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({ + storeFqdn: 'test-shop.myshopify.com', + token: 'test-token', + }) + + // Mock the paginated queries - metafields for each owner type and metaobjects + vi.mocked(adminAsAppRequestDoc).mockImplementation(async ({query}) => { + if (query === MetafieldDefinitions) { + return { + metafieldDefinitions: { + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: [], + }, + } + } + if (query === MetaobjectDefinitions) { + return { + metaobjectDefinitions: { + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: [], + }, + } + } + return {} + }) + + await importDeclarativeDefinitions({ + remoteApp: { + apiKey: 'test-api-key', + apiSecretKeys: [{secret: 'test-secret'}], + }, + store: { + shopDomain: 'test-shop.myshopify.com', + }, + appConfiguration: {}, + } as any) + + expect(outputMock.info()).toMatchInlineSnapshot(` + "╭─ info ───────────────────────────────────────────────────────────────────────╮ + │ │ + │ Conversion to TOML complete. │ + │ │ + │ Converted 0 metafields and 0 metaobjects from test-shop.myshopify.com into │ + │ TOML, ready for you to copy. │ + │ │ + │ Next steps │ + │ 1. Review the suggested TOML carefully before applying. │ + │ 2. Missing sections? Make sure your app has the required access scopes │ + │ to load metafields and metaobjects (e.g. \`read_customers\` to load │ + │ customer metafields, \`read_metaobject_definitions\` to load │ + │ metaobjects.) │ + │ 3. Missing definitions? Only metafields and metaobjects that are │ + │ app-reserved (using \`$app\` ) will be converted. │ + │ 4. When you're ready, add the generated TOML to your app's configuration │ + │ file and test out changes with the \`shopify app dev\` command. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + " + `) + outputMock.clear() + }) + + test('throws BugError when app has no API secret keys', async () => { + const importPromised = importDeclarativeDefinitions({ + remoteApp: { + apiKey: 'test-api-key', + apiSecretKeys: [], + }, + store: { + shopDomain: 'test-shop.myshopify.com', + }, + } as any) + await expect(importPromised).rejects.toThrow('No API secret keys found for app') + }) + + test('existing definitions are not included in the output', async () => { + const outputMock = mockAndCaptureOutput() + + vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({ + storeFqdn: 'test-shop.myshopify.com', + token: 'test-token', + }) + + vi.mocked(adminAsAppRequestDoc).mockImplementation(async ({query, variables}) => { + if (query === MetafieldDefinitions && variables?.ownerType === 'PRODUCT') { + return { + metafieldDefinitions: { + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: [ + { + key: 'existing', + name: 'existing', + namespace: 'app--345678', + type: { + category: 'string', + name: 'single_line_text_field', + }, + access: { + customerAccount: 'NONE', + }, + capabilities: { + adminFilterable: { + enabled: false, + }, + }, + validations: [], + }, + { + key: 'new', + name: 'new', + namespace: 'app--345678', + type: { + category: 'string', + name: 'single_line_text_field', + }, + access: { + customerAccount: 'NONE', + }, + capabilities: { + adminFilterable: { + enabled: false, + }, + }, + validations: [], + }, + ], + }, + } as MetafieldDefinitionsQuery + } + if (query === MetafieldDefinitions) { + return { + metafieldDefinitions: { + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: [], + }, + } as MetafieldDefinitionsQuery + } + if (query === MetaobjectDefinitions) { + return { + metaobjectDefinitions: { + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: [ + { + type: 'app--345678--existing', + name: 'existing', + access: { + admin: 'MERCHANT_READ', + storefront: 'NONE', + }, + capabilities: { + publishable: { + enabled: false, + }, + translatable: { + enabled: false, + }, + }, + fieldDefinitions: [ + { + key: 'field', + name: 'field', + required: false, + type: { + category: 'string', + name: 'single_line_text_field', + }, + validations: [], + }, + ], + }, + { + type: 'app--345678--new', + name: 'new', + access: { + admin: 'MERCHANT_READ', + storefront: 'NONE', + }, + capabilities: { + publishable: { + enabled: false, + }, + translatable: { + enabled: false, + }, + }, + fieldDefinitions: [ + { + key: 'field', + name: 'field', + required: false, + type: { + category: 'string', + name: 'single_line_text_field', + }, + validations: [], + }, + ], + }, + ], + }, + } as MetaobjectDefinitionsQuery + } + return {} + }) + + const options = { + remoteApp: { + apiKey: 'test-api-key', + apiSecretKeys: [{secret: 'test-secret'}], + }, + store: { + shopDomain: 'test-shop.myshopify.com', + }, + appConfiguration: { + product: { + metafields: { + app: { + existing: { + type: 'single_line_text_field', + }, + }, + }, + }, + metaobjects: { + app: { + existing: { + fields: { + existing: 'single_line_text_field', + }, + }, + }, + }, + }, + } + + await importDeclarativeDefinitions({ + ...options, + includeExistingDeclaredDefinitions: false, + } as any) + + expect(outputMock.info()).toMatchInlineSnapshot(` + "╭─ info ───────────────────────────────────────────────────────────────────────╮ + │ │ + │ Conversion to TOML complete. │ + │ │ + │ Converted 1 metafields and 1 metaobjects from test-shop.myshopify.com into │ + │ TOML, ready for you to copy. │ + │ │ + │ Next steps │ + │ 1. Review the suggested TOML carefully before applying. │ + │ 2. Missing sections? Make sure your app has the required access scopes │ + │ to load metafields and metaobjects (e.g. \`read_customers\` to load │ + │ customer metafields, \`read_metaobject_definitions\` to load │ + │ metaobjects.) │ + │ 3. Missing definitions? Only metafields and metaobjects that are │ + │ app-reserved (using \`$app\` ) will be converted. │ + │ 4. When you're ready, add the generated TOML to your app's configuration │ + │ file and test out changes with the \`shopify app dev\` command. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + # type: $app:new + [metaobjects.app.new.fields] + field = \\"single_line_text_field\\" + + # namespace: $app key: new owner_type: PRODUCT + [product.metafields.app.new] + type = \\"single_line_text_field\\" + " + `) + outputMock.clear() + + await importDeclarativeDefinitions({ + ...options, + includeExistingDeclaredDefinitions: true, + } as any) + + expect(outputMock.info()).toMatchInlineSnapshot(` + "╭─ info ───────────────────────────────────────────────────────────────────────╮ + │ │ + │ Conversion to TOML complete. │ + │ │ + │ Converted 2 metafields and 2 metaobjects from test-shop.myshopify.com into │ + │ TOML, ready for you to copy. │ + │ │ + │ Next steps │ + │ 1. Review the suggested TOML carefully before applying. │ + │ 2. Missing sections? Make sure your app has the required access scopes │ + │ to load metafields and metaobjects (e.g. \`read_customers\` to load │ + │ customer metafields, \`read_metaobject_definitions\` to load │ + │ metaobjects.) │ + │ 3. Missing definitions? Only metafields and metaobjects that are │ + │ app-reserved (using \`$app\` ) will be converted. │ + │ 4. When you're ready, add the generated TOML to your app's configuration │ + │ file and test out changes with the \`shopify app dev\` command. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + # type: $app:existing + [metaobjects.app.existing.fields] + field = \\"single_line_text_field\\" + + # type: $app:new + [metaobjects.app.new.fields] + field = \\"single_line_text_field\\" + + # namespace: $app key: existing owner_type: PRODUCT + [product.metafields.app.existing] + type = \\"single_line_text_field\\" + + # namespace: $app key: new owner_type: PRODUCT + [product.metafields.app.new] + type = \\"single_line_text_field\\" + " + `) + outputMock.clear() + }) +}) diff --git a/packages/app/src/cli/services/generate/shop-import/declarative-definitions.ts b/packages/app/src/cli/services/generate/shop-import/declarative-definitions.ts new file mode 100644 index 0000000000..76618c3408 --- /dev/null +++ b/packages/app/src/cli/services/generate/shop-import/declarative-definitions.ts @@ -0,0 +1,757 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +import {FieldObject, Metafield, MetafieldOwners, MetaObject, ValidationRule} from './dcdd.js' +import {OrganizationApp, OrganizationStore} from '../../../models/organization.js' +import { + MetafieldDefinitions, + MetafieldForImportFragment, +} from '../../../api/graphql/admin/generated/metafield_definitions.js' +import {adminAsAppRequestDoc} from '../../../api/admin-as-app.js' +import { + MetaobjectForImportFragment, + MetaobjectDefinitions, +} from '../../../api/graphql/admin/generated/metaobject_definitions.js' +import { + MetafieldAdminAccess, + MetafieldCustomerAccountAccess, + MetafieldOwnerType, + MetafieldStorefrontAccess, + MetaobjectAdminAccess, + MetaobjectStorefrontAccess, +} from '../../../api/graphql/admin/generated/types.js' +import {CurrentAppConfiguration} from '../../../models/app/app.js' +import {BugError} from '@shopify/cli-kit/node/error' +import {AdminSession, ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' +import {outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {TypedDocumentNode} from '@graphql-typed-document-node/core' +import {Variables} from 'graphql-request' +import {updateTomlValues} from '@shopify/toml-patch' +import {renderInfo, renderSingleTask, renderTasks} from '@shopify/cli-kit/node/ui' +import {isEmpty} from '@shopify/cli-kit/common/object' + +interface ImportDeclarativeDefinitionsOptions { + remoteApp: OrganizationApp + appConfiguration: CurrentAppConfiguration + store: OrganizationStore + includeExistingDeclaredDefinitions: boolean +} + +interface ProcessNodesResult { + tomlContent: string + metafieldCount: number + metaobjectCount: number +} + +export interface MetafieldNodesInput { + ownerType: MetafieldOwners + graphQLOwner: MetafieldOwnerType + items: MetafieldForImportFragment[] +} + +interface ResultWithPageInfo { + pageInfo: {hasNextPage: boolean; endCursor?: string | null} + nodes: TNodes[] +} + +interface PaginatedQueryOptions { + query: TypedDocumentNode + session: AdminSession + toNodes: (res: TResult) => ResultWithPageInfo + toVariables: (cursor: string | undefined | null) => TVariables + performQuery?: (variables: TVariables) => Promise +} + +type PaginatedQueryResult = + | { + status: 'ok' + items: TNodes[] + } + | { + status: 'scope_error' + } + +const DCDD_OWNER_TO_GRAPHQL_MAPPING: { + [key in MetafieldOwners]?: MetafieldOwnerType +} = { + article: 'ARTICLE', + blog: 'BLOG', + collection: 'COLLECTION', + company: 'COMPANY', + company_location: 'COMPANY_LOCATION', + location: 'LOCATION', + market: 'MARKET', + order: 'ORDER', + page: 'PAGE', + product: 'PRODUCT', + customer: 'CUSTOMER', + delivery_customization: 'DELIVERY_CUSTOMIZATION', + delivery_method: 'DELIVERY_METHOD', + delivery_option_generator: 'DELIVERY_OPTION_GENERATOR', + discount: 'DISCOUNT', + draft_order: 'DRAFTORDER', + fulfillment_constraint_rule: 'FULFILLMENT_CONSTRAINT_RULE', + gift_card_transaction: 'GIFT_CARD_TRANSACTION', + order_routing_location_rule: 'ORDER_ROUTING_LOCATION_RULE', + payment_customization: 'PAYMENT_CUSTOMIZATION', + selling_plan: 'SELLING_PLAN', + shop: 'SHOP', + validation: 'VALIDATION', + variant: 'PRODUCTVARIANT', + cart_transform: 'CARTTRANSFORM', +} + +export async function paginatedQuery({ + query, + session, + toNodes, + toVariables, + performQuery = (variables) => adminAsAppRequestDoc({query, session, variables, autoRateLimitRestore: true}), +}: PaginatedQueryOptions): Promise> { + let cursor: string | undefined | null + try { + let res = toNodes(await performQuery(toVariables(cursor))) + + const allResults: TNodes[] = [] + allResults.push(...res.nodes) + while (res.pageInfo.hasNextPage) { + cursor = res.pageInfo.endCursor + + // eslint-disable-next-line no-await-in-loop + res = toNodes(await performQuery(toVariables(cursor))) + allResults.push(...res.nodes) + } + return {status: 'ok', items: allResults} + } catch (error) { + if (error instanceof Error && error.message.includes('ACCESS_DENIED')) { + return {status: 'scope_error'} + } + throw error + } +} + +function patchesToToml(patches: Patch[], comment: string) { + let tomlContent = '' + for (const patch of patches) { + tomlContent = updateTomlValues(tomlContent, patch) + } + return `# ${comment}\n${tomlContent}` +} + +export function processDeclarativeDefinitionNodes( + metafieldNodes: MetafieldNodesInput[], + metaobjectNodes: MetaobjectForImportFragment[], +): ProcessNodesResult { + const metaobjectTomls = metaobjectNodes + .map(convertMetaobject) + .map((result) => { + switch (result.status) { + case 'ok': { + const {typeName, patches} = result + return patchesToToml(patches, `type: $app:${typeName}`) + } + case 'not_app_reserved': { + return null + } + } + }) + .filter((item) => item !== null) + + const metafieldTomls = metafieldNodes + .flatMap(({items, ownerType, graphQLOwner}) => + items.map((node) => ({ + result: convertMetafield(node, ownerType), + graphQLOwner, + })), + ) + .map(({result, graphQLOwner}) => { + switch (result.status) { + case 'ok': { + const {namespace, key, patches: metafieldPatches} = result + return patchesToToml( + metafieldPatches, + `namespace: ${namespace === 'app' ? '$app' : `$app:${namespace}`} key: ${key} owner_type: ${graphQLOwner}`, + ) + } + case 'not_app_reserved': { + return null + } + } + }) + .filter((item) => item !== null) + + const tomlContent = [...metaobjectTomls, ...metafieldTomls].join('\n') + + return { + tomlContent, + metafieldCount: metafieldTomls.length, + metaobjectCount: metaobjectTomls.length, + } +} + +export async function importDeclarativeDefinitions(options: ImportDeclarativeDefinitionsOptions) { + const adminSession = await createAdminApiSessionForShop(options) + const shopName = adminSession.storeFqdn + + let metafieldNodes: MetafieldNodesInput[] = await loadMetafieldNodes(adminSession) + let metaobjectNodes: MetaobjectForImportFragment[] = await loadMetaobjectNodes(adminSession) + + if (!options.includeExistingDeclaredDefinitions) { + metafieldNodes = filterOutDeclaredMetafields(metafieldNodes, options.appConfiguration) + metaobjectNodes = filterOutDeclaredMetaobjects(metaobjectNodes, options.appConfiguration) + } + + const {tomlContent, metafieldCount, metaobjectCount} = processDeclarativeDefinitionNodes( + metafieldNodes, + metaobjectNodes, + ) + + renderConversionSummary(metafieldCount, metaobjectCount, shopName, tomlContent) +} + +type ConvertedMetafield = + | { + status: 'ok' + namespace: string + key: string + patches: Patch[] + } + | { + status: 'not_app_reserved' + } + +function renderConversionSummary( + metafieldCount: number, + metaobjectCount: number, + shopName: string, + tomlContent: string, +) { + renderInfo({ + headline: 'Conversion to TOML complete.', + body: [ + 'Converted', + { + warn: `${metafieldCount} metafields`, + }, + 'and', + { + warn: `${metaobjectCount} metaobjects`, + }, + 'from', + { + warn: shopName, + }, + 'into TOML, ready for you to copy.', + ], + orderedNextSteps: true, + nextSteps: [ + 'Review the suggested TOML carefully before applying.', + [ + 'Missing sections? Make sure your app has the required access scopes to load metafields and metaobjects (e.g.', + { + command: 'read_customers', + }, + 'to load customer metafields,', + { + command: 'read_metaobject_definitions', + }, + 'to load metaobjects.)', + ], + [ + 'Missing definitions? Only metafields and metaobjects that are app-reserved (using', + { + command: '$app', + }, + ') will be converted.', + ], + [ + "When you're ready, add the generated TOML to your app's configuration file and test out changes with the", + { + command: 'shopify app dev', + }, + 'command.', + ], + ], + }) + + renderTomlStringWithFormatting(tomlContent) +} + +async function loadMetafieldNodes(adminSession: AdminSession): Promise { + const metafieldLoadResults: { + metafields: PaginatedQueryResult + ownerType: MetafieldOwners + graphQLOwner: MetafieldOwnerType + }[] = [] + + await renderTasks( + Object.entries(DCDD_OWNER_TO_GRAPHQL_MAPPING).map(([dcddOwner, graphQLOwner]) => ({ + title: outputContent`Loading ${outputToken.green(dcddOwner)} metafields`, + task: async () => { + const metafields = await paginatedQuery({ + query: MetafieldDefinitions, + session: adminSession, + toNodes: (res) => res.metafieldDefinitions, + toVariables: (cursor) => ({ + ownerType: graphQLOwner, + after: cursor, + }), + }) + metafieldLoadResults.push({ + metafields, + ownerType: dcddOwner as MetafieldOwners, + graphQLOwner, + }) + }, + })), + ) + + return metafieldLoadResults + .map(({metafields, ownerType, graphQLOwner}) => { + if (metafields.status === 'ok') { + return { + ownerType, + items: metafields.items, + graphQLOwner, + } + } + return null + }) + .filter((item) => item !== null) +} + +async function loadMetaobjectNodes(adminSession: AdminSession): Promise { + const metaobjects = await renderSingleTask({ + title: outputContent`Loading ${outputToken.green('metaobjects')}`, + task: async () => { + return paginatedQuery({ + query: MetaobjectDefinitions, + session: adminSession, + toNodes: (res) => res.metaobjectDefinitions, + toVariables: (cursor) => ({ + after: cursor, + }), + }) + }, + }) + return metaobjects.status === 'ok' ? metaobjects.items : [] +} + +function filterOutDeclaredMetafields( + metafields: MetafieldNodesInput[], + appConfiguration: unknown, +): MetafieldNodesInput[] { + return metafields + .map((input) => { + const filteredItems = input.items.filter((item) => { + return !appConfigurationContains(appConfiguration, [ + input.ownerType, + 'metafields', + simplifyAppReservedNamespace(item.namespace) ?? '', + item.key, + ]) + }) + if (filteredItems.length > 0) { + return { + ...input, + items: filteredItems, + } + } + return null + }) + .filter((item) => item !== null) +} + +function filterOutDeclaredMetaobjects( + metaobjects: MetaobjectForImportFragment[], + appConfiguration: unknown, +): MetaobjectForImportFragment[] { + return metaobjects.filter((input) => { + return !appConfigurationContains(appConfiguration, [ + 'metaobjects', + 'app', + simplifyAppReservedNamespace(input.type) ?? '', + ]) + }) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function appConfigurationContains(appConfiguration: any, path: string[]): boolean { + let current = appConfiguration + for (const key of path) { + if (!current[key]) { + return false + } + current = current[key] + } + return true +} + +async function createAdminApiSessionForShop(options: ImportDeclarativeDefinitionsOptions) { + const {remoteApp, store} = options + + const appSecret = remoteApp.apiSecretKeys[0]?.secret + + if (!appSecret) { + throw new BugError('No API secret keys found for app') + } + + const adminSession = await ensureAuthenticatedAdminAsApp(store.shopDomain, remoteApp.apiKey, appSecret) + return adminSession +} + +function simplifyAppReservedNamespace(namespace: string): string | null { + if (!namespace.match(/^app--\d+/)) { + return null + } + let result = 'app' + if (namespace.match(/^app--\d+--/)) { + result = namespace.replace(/^app--\d+--/, '') + } + return result +} + +function convertMetafield(node: MetafieldForImportFragment, ownerType: MetafieldOwners): ConvertedMetafield { + const namespace = simplifyAppReservedNamespace(node.namespace) + if (!namespace) { + return {status: 'not_app_reserved'} + } + + const key = node.key + const metafield = nodeToMetafield(node) + const patches = metafieldToPatches(metafield, ownerType, namespace, key) + + return { + status: 'ok', + key, + namespace, + patches, + } +} + +type ConvertedMetaobject = + | { + status: 'ok' + typeName: string + patches: Patch[] + } + | { + status: 'not_app_reserved' + } + +function convertMetaobject(node: MetaobjectForImportFragment): ConvertedMetaobject { + const typeName = simplifyAppReservedNamespace(node.type) + if (!typeName) { + return {status: 'not_app_reserved'} + } + + const metaobject = nodeToMetaobject(node, typeName) + const patches = metaobjectToPatches(metaobject, typeName) + + return { + status: 'ok', + typeName, + patches, + } +} + +function nodeToMetafield(node: MetafieldForImportFragment): Metafield { + const res = { + name: node.name === node.key ? undefined : node.name, + type: node.type.name, + description: node.description ?? undefined, + capabilities: undefinedIfEmptyObject({ + admin_filterable: node.capabilities.adminFilterable.enabled || undefined, + }), + access: undefinedIfEmptyObject({ + admin: graphQLToAdminAccess(node.access.admin), + storefront: graphQLToStorefrontAccess(node.access.storefront), + customer_account: graphQLToCustomerAccountAccess(node.access.customerAccount), + }), + validations: validationsNodeToObject(node.validations), + } + + const {type, validations} = convertFieldTypeForReferenceValidations(res.type, res.validations) + res.type = type + res.validations = validations + + return res +} + +function nodeToMetaobject(node: MetaobjectForImportFragment, typeName: string): MetaObject { + return { + name: node.name === typeName ? undefined : node.name, + description: node.description ?? undefined, + display_name_field: node.displayNameKey ?? undefined, + access: undefinedIfEmptyObject({ + admin: graphQLToAdminAccess(node.access.admin), + storefront: graphQLToStorefrontAccess(node.access.storefront), + }), + capabilities: undefinedIfEmptyObject({ + translatable: node.capabilities.translatable.enabled || undefined, + publishable: node.capabilities.publishable.enabled || undefined, + renderable: node.capabilities.renderable?.enabled || undefined, + renderable_meta_title_field: node.capabilities.renderable?.data?.metaTitleKey ?? undefined, + renderable_meta_description_field: node.capabilities.renderable?.data?.metaDescriptionKey ?? undefined, + }), + fields: Object.fromEntries( + node.fieldDefinitions.map((field) => { + const fieldObject: FieldObject = { + type: field.type.name, + description: field.description ?? undefined, + name: field.name === field.key ? undefined : field.name, + required: field.required || undefined, + validations: validationsNodeToObject(field.validations), + } + + const {type, validations} = convertFieldTypeForReferenceValidations(fieldObject.type, fieldObject.validations) + fieldObject.type = type + fieldObject.validations = validations + + return [field.key, fieldObject] + }), + ), + } +} + +function convertFieldTypeForReferenceValidations( + type: string, + validations: {[key: string]: unknown} | undefined, +): { + type: string + validations: {[key: string]: unknown} | undefined +} { + if (!validations) { + return { + type, + validations: undefined, + } + } + + if (validations.metaobject_definition_type && typeof validations.metaobject_definition_type === 'string') { + const referencedType = simplifyAppReservedNamespace(validations.metaobject_definition_type) + return { + type: `${type}<$app:${referencedType}>`, + validations: undefinedIfEmptyObject({ + ...validations, + metaobject_definition_type: undefined, + }), + } + } + + if ( + validations.metaobject_definition_types && + Array.isArray(validations.metaobject_definition_types) && + validations.metaobject_definition_types.every((item) => typeof item === 'string') + ) { + const referencedTypes = validations.metaobject_definition_types.map((item) => simplifyAppReservedNamespace(item)) + return { + type: `${type}<${referencedTypes.map((item) => `$app:${item}`).join(',')}>`, + validations: undefinedIfEmptyObject({ + ...validations, + metaobject_definition_types: undefined, + }), + } + } + + return {type, validations} +} + +function validationsNodeToObject(validations: {name: string; value?: string | null}[]) { + const safelyJsonParse = (value: string) => { + try { + return JSON.parse(value) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + return value + } + } + + return undefinedIfEmptyObject( + Object.fromEntries( + validations + .filter( + (validation) => + validation.value !== undefined && + validation.name !== 'metaobject_definition_id' && + validation.name !== 'metaobject_definition_ids', + ) + .map((validation) => [validation.name, validation.value ? safelyJsonParse(validation.value) : undefined]), + ), + ) +} + +function metafieldToPatches(metafield: Metafield, ownerType: MetafieldOwners, namespace: string, key: string): Patch[] { + const firstBatch: Patch = [ + [[ownerType, 'metafields', namespace, key, 'name'], metafield.name], + [[ownerType, 'metafields', namespace, key, 'type'], metafield.type], + [[ownerType, 'metafields', namespace, key, 'description'], metafield.description], + ] + const secondBatch: Patch = [ + [[ownerType, 'metafields', namespace, key, 'access', 'admin'], metafield.access?.admin], + [[ownerType, 'metafields', namespace, key, 'access', 'storefront'], metafield.access?.storefront], + [[ownerType, 'metafields', namespace, key, 'access', 'customer_account'], metafield.access?.customer_account], + [ + [ownerType, 'metafields', namespace, key, 'capabilities', 'admin_filterable'], + metafield.capabilities?.admin_filterable || undefined, + ], + ] + + if (metafield.validations) { + const validationKeysAndValuesToPush = getValidationValuesForPatch(metafield.validations) + + validationKeysAndValuesToPush.forEach(({validationKey, actualValue}) => { + firstBatch.push([[ownerType, 'metafields', namespace, key, 'validations', validationKey], actualValue]) + }) + } + return [cleanPatch(firstBatch), cleanPatch(secondBatch)] +} + +function metaobjectToPatches(metaobject: MetaObject, typeName: string): Patch[] { + const firstBatch: Patch = [ + [['metaobjects', 'app', typeName, 'name'], metaobject.name === typeName ? undefined : metaobject.name], + [['metaobjects', 'app', typeName, 'description'], metaobject.description], + [['metaobjects', 'app', typeName, 'display_name_field'], metaobject.display_name_field], + ] + const secondBatch: Patch = [ + [['metaobjects', 'app', typeName, 'access', 'admin'], metaobject.access?.admin], + [['metaobjects', 'app', typeName, 'access', 'storefront'], metaobject.access?.storefront], + ] + + if (metaobject.capabilities?.translatable) { + secondBatch.push([['metaobjects', 'app', typeName, 'capabilities', 'translatable'], true]) + } + if (metaobject.capabilities?.publishable) { + secondBatch.push([['metaobjects', 'app', typeName, 'capabilities', 'publishable'], true]) + } + if (metaobject.capabilities?.renderable) { + secondBatch.push([['metaobjects', 'app', typeName, 'capabilities', 'renderable'], true]) + if (metaobject.capabilities?.renderable_meta_title_field) { + secondBatch.push([ + ['metaobjects', 'app', typeName, 'capabilities', 'renderable_meta_title_field'], + metaobject.capabilities.renderable_meta_title_field, + ]) + } + if (metaobject.capabilities?.renderable_meta_description_field) { + secondBatch.push([ + ['metaobjects', 'app', typeName, 'capabilities', 'renderable_meta_description_field'], + metaobject.capabilities.renderable_meta_description_field, + ]) + } + } + Object.entries(metaobject.fields).forEach(([key, value]) => { + if (typeof value === 'string') { + firstBatch.push([['metaobjects', 'app', typeName, 'fields', key], value]) + } else { + // if the only thing we have is a type, then we can just use short hand + const valuePropertiesThatAreDefined = Object.fromEntries( + Object.entries(value).filter(([_key, value]) => value !== undefined), + ) + if (Object.keys(valuePropertiesThatAreDefined).length === 1) { + firstBatch.push([['metaobjects', 'app', typeName, 'fields', key], value.type]) + } else { + firstBatch.push([['metaobjects', 'app', typeName, 'fields', key, 'type'], value.type]) + firstBatch.push([['metaobjects', 'app', typeName, 'fields', key, 'description'], value.description]) + firstBatch.push([['metaobjects', 'app', typeName, 'fields', key, 'name'], value.name]) + firstBatch.push([['metaobjects', 'app', typeName, 'fields', key, 'required'], value.required || undefined]) + if (value.validations) { + const validationKeysAndValuesToPush = getValidationValuesForPatch(value.validations) + + validationKeysAndValuesToPush.forEach(({validationKey, actualValue}) => { + firstBatch.push([ + ['metaobjects', 'app', typeName, 'fields', key, 'validations', validationKey], + actualValue, + ]) + }) + } + } + } + }) + return [cleanPatch(firstBatch), cleanPatch(secondBatch)] +} + +type Patch = [string[], number | string | boolean | undefined | (number | string | boolean)[]][] + +function getValidationValuesForPatch(validations: ValidationRule) { + return Object.entries(validations).map(([validationKey, value]) => { + let actualValue: number | string | boolean | string[] + if (typeof value === 'string') { + actualValue = value + } else if (typeof value === 'number') { + actualValue = value + } else if (typeof value === 'boolean') { + actualValue = value + } else if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { + actualValue = value + } else { + actualValue = JSON.stringify(value) + } + return { + validationKey, + actualValue, + } + }) +} + +export function renderTomlStringWithFormatting(tomlContent: string) { + const lines = tomlContent.split('\n') + for (const line of lines) { + if (line.match(/^\s*\[/)) { + outputInfo(outputContent`${outputToken.green(line)}`) + } else if (line.match(/^\s*#/)) { + outputInfo(outputContent`${outputToken.gray(line)}`) + } else { + outputInfo(outputContent`${line}`) + } + } +} + +function graphQLToAdminAccess( + access: MetaobjectAdminAccess | MetafieldAdminAccess | null | undefined, +): 'merchant_read_write' | undefined { + switch (access) { + case 'MERCHANT_READ_WRITE': + return 'merchant_read_write' + default: + return undefined + } +} + +function graphQLToStorefrontAccess( + access: MetaobjectStorefrontAccess | MetafieldStorefrontAccess | null | undefined, +): 'public_read' | undefined { + switch (access) { + case 'PUBLIC_READ': + return 'public_read' + default: + return undefined + } +} + +function graphQLToCustomerAccountAccess( + access: MetafieldCustomerAccountAccess | null | undefined, +): 'read' | 'read_write' | undefined { + switch (access) { + case 'READ': + return 'read' + case 'READ_WRITE': + return 'read_write' + default: + return undefined + } +} + +function undefinedIfEmptyObject(subject: T | undefined): T | undefined { + if (!subject) { + return undefined + } + if (isEmpty(subject)) { + return undefined + } + if (typeof subject === 'object' && Object.values(subject).every((value) => value === undefined)) { + return undefined + } + return subject +} + +function cleanPatch(patch: Patch): Patch { + return patch.filter(([_key, value]) => value !== undefined) +}