From d537144ab1cf09579a1453f3219adc36ab9a1da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 6 Feb 2026 11:38:50 +0100 Subject: [PATCH] fix: add command to fix morph relation field name mismatches (#17757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When a custom object is renamed after creation, the relation fields on `noteTarget`, `taskTarget`, `attachment`, and `timelineActivity` keep their old names but point to the renamed object. For example: - User creates custom object `solution` - System creates field `solution` on NoteTarget → pointing to Solution - User renames object from `solution` to `productCatalog` - Field name stays as `solution` but points to `productCatalog` - Migration runs: `solution` → `targetSolution` - Now `targetSolution` points to `productCatalog` ❌ This causes the morph name computation to fail: - `getMorphNameFromMorphFieldMetadataName("targetSolution", "productCatalog")` - Tries to remove `ProductCatalog` from `targetSolution` → no match - Name stays as `targetSolution` instead of becoming `target` - Frontend computes: `targetSolution` + `Company` = `targetSolutionCompany` 💥 ## The Fix This command finds and fixes all such mismatches by: 1. Finding MORPH_RELATION fields where `field.name != target${capitalize(targetObject.nameSingular)}` 2. Renaming the column in the workspace schema 3. Updating the field metadata (name + joinColumnName in settings) 4. Invalidating the cache ## Usage **Dry run (to see what would be fixed):** ```bash npx nx run twenty-server:command upgrade:1-17:fix-morph-relation-field-names -- --workspace-id --dry-run ``` **Fix a specific workspace:** ```bash npx nx run twenty-server:command upgrade:1-17:fix-morph-relation-field-names -- --workspace-id ``` **Fix all workspaces:** ```bash npx nx run twenty-server:command upgrade:1-17:fix-morph-relation-field-names ``` ## SQL to find affected workspaces ```sql SELECT fm."workspaceId", COUNT(*) AS mismatched_fields FROM core."fieldMetadata" fm JOIN core."objectMetadata" target_om ON fm."relationTargetObjectMetadataId" = target_om.id JOIN core."objectMetadata" source_om ON fm."objectMetadataId" = source_om.id WHERE fm.type = 'MORPH_RELATION' AND fm.name LIKE 'target%' AND source_om."nameSingular" IN ('noteTarget', 'taskTarget', 'attachment', 'timelineActivity') GROUP BY fm."workspaceId" HAVING COUNT(*) FILTER ( WHERE fm.name != 'target' || initcap(replace(target_om."nameSingular", '_', '')) ) > 0; ``` --- ...-fix-morph-relation-field-names.command.ts | 337 ++++++++++++++++++ .../1-17-upgrade-version-command.module.ts | 3 + .../upgrade.command.ts | 3 + 3 files changed, 343 insertions(+) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command.ts new file mode 100644 index 0000000000..8d96f99689 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command.ts @@ -0,0 +1,337 @@ +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { capitalize } from 'twenty-shared/utils'; +import { DataSource, QueryFailedError, Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util'; +import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +interface MismatchedField { + fieldId: string; + currentFieldName: string; + expectedFieldName: string; + sourceObjectName: string; + targetObjectNameSingular: string; + currentJoinColumnName: string; + expectedJoinColumnName: string; + workspaceSchema: string; +} + +@Command({ + name: 'upgrade:1-17:fix-morph-relation-field-names', + description: + 'Fix morph relation field names that do not match their target object names', +}) +export class FixMorphRelationFieldNamesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + @InjectDataSource() + private readonly coreDataSource: DataSource, + private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const dryRun = options?.dryRun ?? false; + + this.logger.log( + `${dryRun ? '[DRY RUN] ' : ''}Checking morph relation field names for workspace ${workspaceId}`, + ); + + const mismatchedFields = await this.findMismatchedFields(workspaceId); + + if (mismatchedFields.length === 0) { + this.logger.log( + `✅ No mismatched fields found for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `Found ${mismatchedFields.length} mismatched field(s) in workspace ${workspaceId}:`, + ); + + for (const field of mismatchedFields) { + this.logger.log( + ` - ${field.sourceObjectName}.${field.currentFieldName} → ${field.expectedFieldName} (target: ${field.targetObjectNameSingular})`, + ); + } + + if (dryRun) { + this.logger.log('[DRY RUN] No changes made'); + this.logger.log( + '⚠️ Before running without --dry-run, please ensure you have a database backup', + ); + + return; + } + + this.logger.log( + '⚠️ Starting fix. Ensure you have a database backup before proceeding.', + ); + + const queryRunner = this.coreDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + let fixedCount = 0; + + for (const field of mismatchedFields) { + // Step 1: Attempt to rename the column, handling failures gracefully + if (field.currentJoinColumnName !== field.expectedJoinColumnName) { + // Use a savepoint so a failed rename doesn't abort the whole transaction + const renameColumnSavepoint = 'rename_column'; + + await queryRunner.query(`SAVEPOINT ${renameColumnSavepoint}`); + try { + await queryRunner.query( + `ALTER TABLE "${field.workspaceSchema}"."${field.sourceObjectName}" + RENAME COLUMN "${field.currentJoinColumnName}" TO "${field.expectedJoinColumnName}"`, + ); + await queryRunner.query( + `RELEASE SAVEPOINT ${renameColumnSavepoint}`, + ); + this.logger.log( + `Renamed column ${field.currentJoinColumnName} → ${field.expectedJoinColumnName} in ${field.workspaceSchema}.${field.sourceObjectName}`, + ); + } catch (error) { + await queryRunner.query( + `ROLLBACK TO SAVEPOINT ${renameColumnSavepoint}`, + ); + await queryRunner.query( + `RELEASE SAVEPOINT ${renameColumnSavepoint}`, + ); + const errorCode = this.getPostgresErrorCode(error); + + if (errorCode === '42701') { + this.logger.warn( + `⚠️ SKIPPING ${field.sourceObjectName}.${field.currentFieldName}: Both columns exist (${field.currentJoinColumnName} and ${field.expectedJoinColumnName}). Manual intervention required.`, + ); + continue; + } + if (errorCode !== '42703') { + throw error; + } + this.logger.log( + `Column rename skipped for ${field.sourceObjectName}.${field.currentJoinColumnName} (column may already be renamed or missing), updating metadata only`, + ); + } + } + + // Step 2: Verify field still exists with expected current name before updating + const fieldCheck = await queryRunner.query( + `SELECT id, name FROM core."fieldMetadata" WHERE id = $1`, + [field.fieldId], + ); + + if (fieldCheck.length === 0) { + this.logger.warn( + `⚠️ Field ${field.fieldId} no longer exists, skipping`, + ); + continue; + } + + if (fieldCheck[0].name !== field.currentFieldName) { + this.logger.warn( + `⚠️ Field ${field.fieldId} name changed from ${field.currentFieldName} to ${fieldCheck[0].name} since query, skipping`, + ); + continue; + } + + // Step 3: Update field metadata + const newSettings = { + joinColumnName: field.expectedJoinColumnName, + }; + + const updateResult = await queryRunner.query( + `UPDATE core."fieldMetadata" + SET name = $1, + settings = settings || $2::jsonb + WHERE id = $3 + AND name = $4 + RETURNING id`, + [ + field.expectedFieldName, + JSON.stringify(newSettings), + field.fieldId, + field.currentFieldName, + ], + ); + + if (updateResult.length > 0) { + this.logger.log( + `✓ Updated fieldMetadata: ${field.currentFieldName} → ${field.expectedFieldName}`, + ); + fixedCount++; + } else { + this.logger.log( + `Field metadata already up to date for ${field.currentFieldName}`, + ); + } + } + + await queryRunner.commitTransaction(); + + if (fixedCount > 0) { + // Invalidate flat entity caches (new cache system) + await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + 'ORMEntityMetadatas', + 'rolesPermissions', + 'userWorkspaceRoleMap', + 'flatRoleTargetMaps', + 'apiKeyRoleMap', + 'flatRoleTargetByAgentIdMaps', + ]); + + // Increment metadata version so GraphQL schema is regenerated + await this.workspaceMetadataVersionService.incrementMetadataVersion( + workspaceId, + ); + + // Flush legacy cache (GraphQL typeDefs, ORM entity schemas, GraphQL operations) + await this.workspaceCacheStorageService.flush(workspaceId); + + this.logger.log(`Cache invalidated and metadata version incremented`); + } + + this.logger.log( + `✅ Successfully fixed ${fixedCount} field(s) in workspace ${workspaceId}`, + ); + } catch (error) { + if (queryRunner.isTransactionActive) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Error fixing morph relation field names (rolled transaction back on ${workspaceId})`, + error, + ); + } else { + this.logger.error( + `Error fixing morph relation field names after commit on ${workspaceId}`, + error, + ); + } + throw error; + } finally { + await queryRunner.release(); + } + } + + private async findMismatchedFields( + workspaceId: string, + ): Promise { + // Only process fields on these specific objects that use the target morph pattern + const allowedSourceObjects = [ + 'noteTarget', + 'taskTarget', + 'attachment', + 'timelineActivity', + ]; + + const result = await this.coreDataSource.query( + `SELECT + fm.id AS "fieldId", + fm.name AS "currentFieldName", + fm.settings->>'joinColumnName' AS "currentJoinColumnName", + source_om."nameSingular" AS "sourceObjectName", + target_om."nameSingular" AS "targetObjectNameSingular", + ds.schema AS "workspaceSchema" + FROM core."fieldMetadata" fm + JOIN core."objectMetadata" source_om ON fm."objectMetadataId" = source_om.id + JOIN core."objectMetadata" target_om ON fm."relationTargetObjectMetadataId" = target_om.id + JOIN core."dataSource" ds ON ds."workspaceId" = fm."workspaceId" + WHERE fm."workspaceId" = $1 + AND fm.type = $2 + AND fm.name LIKE 'target%' + AND source_om."nameSingular" = ANY($3)`, + [workspaceId, FieldMetadataType.MORPH_RELATION, allowedSourceObjects], + ); + + // Filter in JavaScript to handle camelCase properly + return result + .filter( + (row: { + currentFieldName: string; + targetObjectNameSingular: string; + }) => { + const expectedFieldName = `target${capitalize(row.targetObjectNameSingular)}`; + + return row.currentFieldName !== expectedFieldName; + }, + ) + .map( + (row: { + fieldId: string; + currentFieldName: string; + currentJoinColumnName: string | null; + sourceObjectName: string; + targetObjectNameSingular: string; + workspaceSchema: string; + }) => { + const expectedFieldName = `target${capitalize(row.targetObjectNameSingular)}`; + const expectedJoinColumnName = + computeMorphOrRelationFieldJoinColumnName({ + name: expectedFieldName, + }); + + // Fallback: if settings.joinColumnName is null, derive from field name + const currentJoinColumnName = + row.currentJoinColumnName ?? + computeMorphOrRelationFieldJoinColumnName({ + name: row.currentFieldName, + }); + + return { + fieldId: row.fieldId, + currentFieldName: row.currentFieldName, + expectedFieldName, + sourceObjectName: row.sourceObjectName, + targetObjectNameSingular: row.targetObjectNameSingular, + currentJoinColumnName, + expectedJoinColumnName, + workspaceSchema: row.workspaceSchema, + }; + }, + ); + } + + private getPostgresErrorCode(error: unknown): string | null { + if (!(error instanceof QueryFailedError)) { + return null; + } + const driverError: unknown = error.driverError; + + if ( + typeof driverError === 'object' && + driverError !== null && + 'code' in driverError + ) { + const code = (driverError as { code?: unknown }).code; + + return typeof code === 'string' ? code : null; + } + + return null; + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts index 0ac2742042..29a9ea4597 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BackfillApplicationPackageFilesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-backfill-application-package-files.command'; import { DeleteFileRecordsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-delete-all-files.command'; +import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command'; import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command'; import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command'; import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command'; @@ -63,6 +64,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas GlobalWorkspaceDataSourceModule, ], providers: [ + FixMorphRelationFieldNamesCommand, MigrateAttachmentToMorphRelationsCommand, MigrateNoteTargetToMorphRelationsCommand, MigrateTaskTargetToMorphRelationsCommand, @@ -76,6 +78,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas UpdateFileTableMigrationCommand, ], exports: [ + FixMorphRelationFieldNamesCommand, MigrateAttachmentToMorphRelationsCommand, MigrateNoteTargetToMorphRelationsCommand, MigrateTaskTargetToMorphRelationsCommand, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 814b1fdc41..dc52d8a130 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -11,6 +11,7 @@ import { } from 'src/database/commands/command-runners/upgrade.command-runner'; import { BackfillApplicationPackageFilesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-backfill-application-package-files.command'; import { DeleteFileRecordsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-delete-all-files.command'; +import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command'; import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command'; import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command'; import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command'; @@ -47,6 +48,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly makeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, protected readonly migrateWorkflowCodeStepsCommand: MigrateWorkflowCodeStepsCommand, protected readonly updateFileTableMigrationCommand: UpdateFileTableMigrationCommand, + protected readonly fixMorphRelationFieldNamesCommand: FixMorphRelationFieldNamesCommand, ) { super( workspaceRepository, @@ -69,6 +71,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.deleteFileRecordsCommand, this.backfillApplicationPackageFilesCommand, this.updateFileTableMigrationCommand, + this.fixMorphRelationFieldNamesCommand, ]; this.allCommands = {