fix: add command to fix morph relation field name mismatches (#17757)
## 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 <id> --dry-run ``` **Fix a specific workspace:** ```bash npx nx run twenty-server:command upgrade:1-17:fix-morph-relation-field-names -- --workspace-id <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; ```
This commit is contained in:
+337
@@ -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<WorkspaceEntity>,
|
||||
@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<void> {
|
||||
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<MismatchedField[]> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
+3
@@ -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,
|
||||
|
||||
+3
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user