diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index 7bdbc533..c7821a8b 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -450,6 +450,7 @@ const startTestMigration = async (req: Request): Promise => { region, user_id, is_sso, + isTest: true }); await marketPlaceAppService?.createAppManifest({ diff --git a/api/src/utils/content-type-creator.utils.ts b/api/src/utils/content-type-creator.utils.ts index fe39aea3..72a02189 100644 --- a/api/src/utils/content-type-creator.utils.ts +++ b/api/src/utils/content-type-creator.utils.ts @@ -1089,8 +1089,19 @@ const saveContent = async (ct: any, contentSave: string) => { throw readError; // rethrow if it's not a "file not found" error } } - // Append new content to schemaData - schemaData.push(ct); + // Upsert by uid: replace an existing entry for this content type instead of + // blindly appending. schema.json is not cleared between runs (clearStaleEntries + // only wipes the entries/ subtree), so re-running against the same stack — e.g. + // a re-run test migration or a delta iteration — would otherwise accumulate + // duplicate content-type entries. + const existingIndex = Array.isArray(schemaData) + ? schemaData.findIndex((existing: any) => existing?.uid === ct?.uid) + : -1; + if (existingIndex > -1) { + schemaData[existingIndex] = ct; + } else { + schemaData.push(ct); + } // Write the updated schemaData back to schema.json await fs.promises.writeFile(schemaFilePath, JSON.stringify(schemaData, null, 2)); diff --git a/api/src/utils/field-attacher.utils.ts b/api/src/utils/field-attacher.utils.ts index 236ed65b..ddf9cb8f 100644 --- a/api/src/utils/field-attacher.utils.ts +++ b/api/src/utils/field-attacher.utils.ts @@ -6,7 +6,7 @@ import { shouldSkipContentTypeCreation } from "./content-type-checker.utils.js"; import { sanitizeProjectId, sanitizeStackId } from "./sanitize-path.utils.js"; import customLogger from "./custom-logger.utils.js"; -export const fieldAttacher = async ({ projectId, orgId, destinationStackId, region, user_id, is_sso }: any) => { +export const fieldAttacher = async ({ projectId, orgId, destinationStackId, region, user_id, is_sso, isTest = false}: any) => { const safeProjectId = sanitizeProjectId(projectId); if (!safeProjectId) { throw new Error("Invalid project identifier"); @@ -45,9 +45,11 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi }) } - if (iteration === 1) { + // Always create the content type on a test migration (skip the iteration-based + // dedupe entirely) or on the first real iteration. The skip logic below only + // applies to delta iterations of a real migration. + if (isTest || iteration === 1) { await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) - } else { const shouldSkip = await shouldSkipContentTypeCreation(safeProjectId, contentType?.otherCmsUid, iteration); diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index 5a83a480..e331f079 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -690,9 +690,16 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { // Lock the Save button only while a migration is actively in flight. // Using migrationStarted alone would permanently lock revisits on delta // iterations since migrationStarted stays true after completion. + // Also lock when there's nothing to save: no pending selection AND the + // current page has no mappable row. tableData is only the current + // server-paginated page, so we must fall back to rowIds (persisted + + // pending selection) — otherwise a content type whose mappable rows sit + // on page 2+ would wrongly disable Save. disabled={ - !!newMigrationData?.migration_execution?.migrationStarted && - !newMigrationData?.migration_execution?.migrationCompleted + (!!newMigrationData?.migration_execution?.migrationStarted && + !newMigrationData?.migration_execution?.migrationCompleted) || + (Object.keys(rowIds ?? {}).length === 0 && + !tableData?.some((row) => row?._canSelect)) } isLoading={isLoadingSaveButton} >