Compare commits

...

3 Commits

Author SHA1 Message Date
Paul Rastoin 5b4f791ddc Move view field label identifier deletion validation into the cross entity validation (#19642)
## Introduction
In the same validate build and run we should be able to delete a view
field targetting a label identifier and at the same create one that
repoints to it again without failing any validation

Leading for this valdiation rule to be moved in the cross entity
validation steps
2026-04-13 16:53:38 +02:00
Paul Rastoin edead399b7 Colliding subject field fix on messageThread command (#19637) 2026-04-13 16:38:46 +02:00
Paul Rastoin 06f09b35ae Fix command FixMessageThreadViewAndLabelIdentifierCommand (#19622)
## Introduction
View standard backfill seed has been introduced during a partial patch,
a window exists where some workspaces got created without the view and
wasn't included in the upgrade process

The fix isn't covering all the view that has been missed during that
time, only the one required for the command to pass
2026-04-13 11:21:21 +02:00
7 changed files with 283 additions and 52 deletions
@@ -1,18 +1,22 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getDefaultFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
@RegisteredWorkspaceCommand('1.21.0', 1775500004000)
@Command({
@@ -76,6 +80,64 @@ export class BackfillMessageThreadSubjectCommand extends ActiveOrSuspendedWorksp
);
}
private findFieldByNameOnObject({
flatFieldMetadataMaps,
objectUniversalIdentifier,
fieldName,
}: {
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
objectUniversalIdentifier: string;
fieldName: string;
}): FlatFieldMetadata | undefined {
return Object.values(flatFieldMetadataMaps.byUniversalIdentifier)
.filter(isDefined)
.find(
(field) =>
field.name === fieldName &&
field.objectMetadataUniversalIdentifier === objectUniversalIdentifier,
);
}
private async renameConflictingField({
conflictingField,
workspaceId,
}: {
conflictingField: FlatFieldMetadata;
workspaceId: string;
}): Promise<void> {
const fieldToUpdate: UniversalFlatFieldMetadata = {
...conflictingField,
name: 'subjectOld',
label: 'Subject (old)',
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [fieldToUpdate],
},
},
workspaceId,
applicationUniversalIdentifier:
conflictingField.applicationUniversalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to rename conflicting subject field to subjectOld for workspace ${workspaceId}: ${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
}
this.logger.log(
`Renamed conflicting subject field to subjectOld for workspace ${workspaceId}`,
);
}
private async ensureSubjectFieldExists({
workspaceId,
isDryRun,
@@ -113,6 +175,26 @@ export class BackfillMessageThreadSubjectCommand extends ActiveOrSuspendedWorksp
return;
}
const conflictingField = this.findFieldByNameOnObject({
flatFieldMetadataMaps,
objectUniversalIdentifier:
messageThreadObjectMetadata.universalIdentifier,
fieldName: 'subject',
});
if (isDefined(conflictingField)) {
this.logger.log(
`Found conflicting field named "subject" (universalIdentifier: ${conflictingField.universalIdentifier}) on messageThread for workspace ${workspaceId}, renaming to subjectOld`,
);
if (!isDryRun) {
await this.renameConflictingField({
conflictingField,
workspaceId,
});
}
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create messageThread.subject field for workspace ${workspaceId}`,
@@ -163,12 +245,8 @@ export class BackfillMessageThreadSubjectCommand extends ActiveOrSuspendedWorksp
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to create messageThread.subject field for workspace ${workspaceId}:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to create messageThread.subject field for workspace ${workspaceId}`,
`Failed to create messageThread.subject field for workspace ${workspaceId}: ${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
}
@@ -9,10 +9,12 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
const MESSAGE_THREAD_OBJECT_UNIVERSAL_IDENTIFIER =
@@ -25,6 +27,7 @@ const ALL_MESSAGE_THREADS_VIEW_UNIVERSAL_IDENTIFIER =
type WorkspaceFlatEntityMaps = Awaited<
ReturnType<WorkspaceCacheService['getOrRecompute']>
>;
type FlatViewMaps = WorkspaceFlatEntityMaps['flatViewMaps'];
type FlatViewFieldMaps = WorkspaceFlatEntityMaps['flatViewFieldMaps'];
type FlatFieldMetadataMaps = WorkspaceFlatEntityMaps['flatFieldMetadataMaps'];
@@ -79,10 +82,12 @@ export class FixMessageThreadViewAndLabelIdentifierCommand extends ActiveOrSuspe
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatViewMaps: existingFlatViewMaps,
flatViewFieldMaps: existingFlatViewFieldMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatViewMaps',
'flatViewFieldMaps',
]);
@@ -100,6 +105,11 @@ export class FixMessageThreadViewAndLabelIdentifierCommand extends ActiveOrSuspe
return;
}
const viewToCreate = this.computeMessageThreadViewToCreate({
standardAllFlatEntityMaps,
existingFlatViewMaps,
});
const viewFieldsToDelete = this.computeMessageThreadViewFieldsToDelete({
existingFlatViewFieldMaps,
});
@@ -115,11 +125,12 @@ export class FixMessageThreadViewAndLabelIdentifierCommand extends ActiveOrSuspe
existingFlatFieldMetadataMaps,
});
const hasViewToCreate = isDefined(viewToCreate);
const hasViewFieldChanges =
viewFieldsToDelete.length > 0 || viewFieldsToCreate.length > 0;
const hasObjectMetadataChange = isDefined(flatObjectMetadataToUpdate);
if (!hasViewFieldChanges && !hasObjectMetadataChange) {
if (!hasViewToCreate && !hasViewFieldChanges && !hasObjectMetadataChange) {
this.logger.log(
`Nothing to fix for messageThread in workspace ${workspaceId}`,
);
@@ -128,7 +139,7 @@ export class FixMessageThreadViewAndLabelIdentifierCommand extends ActiveOrSuspe
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Fixing messageThread in workspace ${workspaceId}: deleting ${viewFieldsToDelete.length} view fields, creating ${viewFieldsToCreate.length} view fields${hasObjectMetadataChange ? ', repointing labelIdentifierFieldMetadataId to subject' : ''}`,
`${isDryRun ? '[DRY RUN] ' : ''}Fixing messageThread in workspace ${workspaceId}: ${hasViewToCreate ? 'creating allMessageThreads view, ' : ''}deleting ${viewFieldsToDelete.length} view fields, creating ${viewFieldsToCreate.length} view fields${hasObjectMetadataChange ? ', repointing labelIdentifierFieldMetadataId to subject' : ''}`,
);
if (isDryRun) {
@@ -146,6 +157,11 @@ export class FixMessageThreadViewAndLabelIdentifierCommand extends ActiveOrSuspe
? [flatObjectMetadataToUpdate]
: [],
},
view: {
flatEntityToCreate: hasViewToCreate ? [viewToCreate] : [],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: viewFieldsToDelete,
@@ -174,6 +190,42 @@ export class FixMessageThreadViewAndLabelIdentifierCommand extends ActiveOrSuspe
);
}
// If the allMessageThreads view does not exist in the workspace, return the
// standard definition so it can be created before its view fields.
private computeMessageThreadViewToCreate({
standardAllFlatEntityMaps,
existingFlatViewMaps,
}: {
standardAllFlatEntityMaps: ReturnType<
typeof computeTwentyStandardApplicationAllFlatEntityMaps
>['allFlatEntityMaps'];
existingFlatViewMaps: FlatViewMaps;
}): UniversalFlatView | undefined {
const existingView = findFlatEntityByUniversalIdentifier<FlatView>({
flatEntityMaps: existingFlatViewMaps,
universalIdentifier: ALL_MESSAGE_THREADS_VIEW_UNIVERSAL_IDENTIFIER,
});
if (isDefined(existingView)) {
return undefined;
}
const standardView = findFlatEntityByUniversalIdentifier<UniversalFlatView>(
{
flatEntityMaps: standardAllFlatEntityMaps.flatViewMaps,
universalIdentifier: ALL_MESSAGE_THREADS_VIEW_UNIVERSAL_IDENTIFIER,
},
);
if (!isDefined(standardView)) {
throw new Error(
'allMessageThreads view not found in standard definition',
);
}
return standardView;
}
// Delete every existing view field attached to the standard allMessageThreads view
// so we can recreate them from the current standard definition in one diff.
private computeMessageThreadViewFieldsToDelete({
@@ -0,0 +1,123 @@
import { msg, t } from '@lingui/core/macro';
import { ViewType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { ViewExceptionCode } from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { type OrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type';
import { type UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
import { type UniversalDeleteViewFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field/types/workspace-migration-view-field-action.type';
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
export const validateViewFieldLabelIdentifierCrossEntity = ({
optimisticUniversalFlatMaps,
deletedViewFieldActions,
preDeletionFlatViewFieldMaps,
}: {
optimisticUniversalFlatMaps: Pick<
AllUniversalFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatViewMaps' | 'flatViewFieldMaps'
>;
deletedViewFieldActions: UniversalDeleteViewFieldAction[];
preDeletionFlatViewFieldMaps: UniversalFlatEntityMaps<UniversalFlatViewField>;
}): Pick<OrchestratorFailureReport, 'viewField'> => {
const validationErrors: Pick<OrchestratorFailureReport, 'viewField'> = {
viewField: [],
};
if (deletedViewFieldActions.length === 0) {
return validationErrors;
}
const alreadyCheckedViewUniversalIdentifiers = new Set<string>();
for (const deleteAction of deletedViewFieldActions) {
const deletedViewField = findFlatEntityByUniversalIdentifierOrThrow({
universalIdentifier: deleteAction.universalIdentifier,
flatEntityMaps: preDeletionFlatViewFieldMaps,
});
const { viewUniversalIdentifier, fieldMetadataUniversalIdentifier } =
deletedViewField;
if (alreadyCheckedViewUniversalIdentifiers.has(viewUniversalIdentifier)) {
continue;
}
const view = findFlatEntityByUniversalIdentifier({
universalIdentifier: viewUniversalIdentifier,
flatEntityMaps: optimisticUniversalFlatMaps.flatViewMaps,
});
if (!isDefined(view) || view.type === ViewType.FIELDS_WIDGET) {
continue;
}
const objectMetadata = findFlatEntityByUniversalIdentifier({
universalIdentifier: view.objectMetadataUniversalIdentifier,
flatEntityMaps: optimisticUniversalFlatMaps.flatObjectMetadataMaps,
});
if (!isDefined(objectMetadata)) {
continue;
}
const { labelIdentifierFieldMetadataUniversalIdentifier } = objectMetadata;
if (!isDefined(labelIdentifierFieldMetadataUniversalIdentifier)) {
continue;
}
if (
fieldMetadataUniversalIdentifier !==
labelIdentifierFieldMetadataUniversalIdentifier
) {
continue;
}
alreadyCheckedViewUniversalIdentifiers.add(viewUniversalIdentifier);
const viewFieldUniversalIdentifiers =
view.viewFieldUniversalIdentifiers ?? [];
const hasLabelIdentifierViewField = viewFieldUniversalIdentifiers.some(
(viewFieldUniversalIdentifier) => {
const viewField = findFlatEntityByUniversalIdentifier({
universalIdentifier: viewFieldUniversalIdentifier,
flatEntityMaps: optimisticUniversalFlatMaps.flatViewFieldMaps,
});
return (
isDefined(viewField) &&
viewField.fieldMetadataUniversalIdentifier ===
labelIdentifierFieldMetadataUniversalIdentifier
);
},
);
if (!hasLabelIdentifierViewField) {
const failedValidation = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier: deleteAction.universalIdentifier,
viewUniversalIdentifier,
fieldMetadataUniversalIdentifier,
},
metadataName: 'viewField',
type: 'delete',
});
failedValidation.errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Label identifier view field cannot be deleted`,
userFriendlyMessage: msg`Label identifier view field cannot be deleted`,
});
validationErrors.viewField.push(failedValidation);
}
}
return validationErrors;
};
@@ -131,6 +131,10 @@ export class WorkspaceMigrationBuildOrchestratorService {
dependencyAllFlatEntityMaps,
});
const preDeletionFlatViewFieldMaps = structuredClone(
optimisticAllFlatEntityMaps.flatViewFieldMaps,
);
const {
flatObjectMetadataMaps,
flatViewFieldMaps,
@@ -818,12 +822,14 @@ export class WorkspaceMigrationBuildOrchestratorService {
}
}
const { objectMetadata } = crossEntityTransversalValidation({
const { objectMetadata, viewField } = crossEntityTransversalValidation({
optimisticUniversalFlatMaps: optimisticAllFlatEntityMaps,
orchestratorActionsReport,
preDeletionFlatViewFieldMaps,
});
orchestratorFailureReport.objectMetadata.push(...objectMetadata);
orchestratorFailureReport.viewField.push(...viewField);
const allErrors = Object.values(orchestratorFailureReport);
@@ -1,21 +1,32 @@
import { validateObjectMetadataCrossEntity } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-cross-entity.util';
import { validateViewFieldLabelIdentifierCrossEntity } from 'src/engine/metadata-modules/flat-view-field/validators/utils/validate-view-field-label-identifier-cross-entity.util';
import {
type OrchestratorActionsReport,
type OrchestratorFailureReport,
} from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type';
import { type UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
export const crossEntityTransversalValidation = ({
optimisticUniversalFlatMaps,
orchestratorActionsReport,
preDeletionFlatViewFieldMaps,
}: {
optimisticUniversalFlatMaps: AllUniversalFlatEntityMaps;
orchestratorActionsReport: OrchestratorActionsReport;
}): Pick<OrchestratorFailureReport, 'objectMetadata'> => {
preDeletionFlatViewFieldMaps: UniversalFlatEntityMaps<UniversalFlatViewField>;
}): Pick<OrchestratorFailureReport, 'objectMetadata' | 'viewField'> => {
const { objectMetadata } = validateObjectMetadataCrossEntity({
optimisticUniversalFlatMaps,
orchestratorActionsReport,
});
return { objectMetadata };
const { viewField } = validateViewFieldLabelIdentifierCrossEntity({
optimisticUniversalFlatMaps,
deletedViewFieldActions: orchestratorActionsReport.viewField.delete,
preDeletionFlatViewFieldMaps,
});
return { objectMetadata, viewField };
};
@@ -127,9 +127,6 @@ export class FlatViewFieldValidatorService {
flatEntityToValidate: { universalIdentifier },
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatViewFieldMaps: optimisticFlatViewFieldMaps,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
flatViewMaps,
},
}: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.viewField
@@ -157,43 +154,6 @@ export class FlatViewFieldValidatorService {
return validationResult;
}
const flatFieldMetadata = findFlatEntityByUniversalIdentifier({
universalIdentifier:
existingFlatViewField.fieldMetadataUniversalIdentifier,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(flatFieldMetadata)) {
return validationResult;
}
const flatObjectMetadata = findFlatEntityByUniversalIdentifier({
universalIdentifier: flatFieldMetadata.objectMetadataUniversalIdentifier,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(flatObjectMetadata)) {
return validationResult;
}
if (
flatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier ===
existingFlatViewField.fieldMetadataUniversalIdentifier
) {
const flatView = findFlatEntityByUniversalIdentifier({
universalIdentifier: existingFlatViewField.viewUniversalIdentifier,
flatEntityMaps: flatViewMaps,
});
if (!isDefined(flatView) || flatView.type !== ViewType.FIELDS_WIDGET) {
validationResult.errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Label identifier view field cannot be deleted`,
userFriendlyMessage: msg`Label identifier view field cannot be deleted`,
});
}
}
return validationResult;
}
@@ -89,10 +89,11 @@ exports[`View Field Resolver - Successful object metadata identifier update side
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "delete",
},
],