Refactor workspace migration runner exception handling (#17310)

# Introduction
In this PR we catch all the runner errors coming from a single workspace
migration action execution.

**1. `WorkspaceMigrationActionExecutionException`** (low-level,
action-specific)
- Thrown from action handlers, utils, and helper functions
- Contains specific error codes: `FIELD_METADATA_NOT_FOUND`,
`OBJECT_METADATA_NOT_FOUND`, `ENUM_OPERATION_FAILED`, `NOT_SUPPORTED`,
etc.
- Simple structure: `message`, `code`, `userFriendlyMessage`
- No action context - just describes what went wrong

**2. `WorkspaceMigrationRunnerException`** (high-level, runner-scoped)
- Only two codes: `INTERNAL_SERVER_ERROR` and `EXECUTION_FAILED`
- `EXECUTION_FAILED` **requires** `action` + `errors` (contains the
action context)
- `INTERNAL_SERVER_ERROR` **requires** `message` (no action context)

## Refactor
- Removed the `relatedFlatEntityMapsKeys` from the `WorkspaceMigration`
type as they're directly inferred from passed actions
- Swallowing actions rollbacks errors in order to iterate over all of
them

## Testing
Created a very straigthforward install application from workspace
migration endpoint in order to start testing the introduced
`WorkspaceMigrationActionExecutionException`
Introduced a feature flag that stop the access to the endpoint if not
enabled
Whole taken direction are totally subjective and highly prone to
mutations ( endpoint location, naming and input schema see
`ts-expect-error` comment )
cc @martmull 

## Response error
```ts
{
  "eventId": "evt_a1b2c3d4-5678-90ab-cdef-1234567890ab",
  "extensions": {
    "action": {
      "metadataName": "fieldMetadata",
      "type": "delete",
      "universalIdentifier": "20202020-6110-4547-9fd0-2525257a2c3f"
    },
    "code": "APPLICATION_INSTALLATION_FAILED",
    "errors": {
      "metadata": {
        "code": "ENTITY_NOT_FOUND",
        "message": "Could not find flat entity with universal identifier 20202020-6110-4547-9fd0-2525257a2c3f"
      },
      "workspaceSchema": {
        "code": "ENTITY_NOT_FOUND",
        "message": "Could not find flat entity in maps"
      }
    },
    "exceptionEventId": "exc_f9e8d7c6-5432-10ba-fedc-ba0987654321",
    "userFriendlyMessage": "Migration execution failed."
  },
  "message": "Migration action 'delete' for 'fieldMetadata' failed",
  "name": "GraphQLError"
}
```
This commit is contained in:
Paul Rastoin
2026-01-22 16:05:21 +01:00
committed by GitHub
parent a68e05682b
commit 2a28c34a37
53 changed files with 829 additions and 294 deletions
@@ -5,6 +5,8 @@ import { ApplicationSyncService } from 'src/engine/core-modules/application/appl
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
@@ -18,11 +20,11 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@Module({
imports: [
@@ -45,11 +47,13 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
PermissionFlagModule,
WorkflowCommonModule,
FileStorageModule,
WorkspaceCacheModule,
WorkspaceMigrationRunnerModule,
],
providers: [
ApplicationResolver,
ApplicationSyncService,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
exports: [ApplicationSyncService],
})
@@ -1,4 +1,9 @@
import { UseFilters, UseGuards, UseInterceptors } from '@nestjs/common';
import {
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
@@ -6,47 +11,54 @@ import path, { join } from 'path';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type Repository } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
import { type Repository } from 'typeorm';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
@UsePipes(ResolverValidationPipe)
@Resolver()
@UseInterceptors(WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor)
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@UseFilters(ApplicationExceptionFilter)
export class ApplicationResolver {
constructor(
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
) {}
@@ -83,6 +95,36 @@ export class ApplicationResolver {
return true;
}
@Mutation(() => Boolean)
async installApplication(
@Args() { workspaceMigration: { actions } }: InstallApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
const { featureFlagsMap } = await this.workspaceCacheService.getOrRecompute(
workspaceId,
['featureFlagsMap'],
);
if (
featureFlagsMap[
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED
] !== true
) {
throw new ApplicationException(
'Application installation from tarball is not enabled',
ApplicationExceptionCode.FORBIDDEN,
);
}
await this.workspaceMigrationRunnerService.run({
// @ts-expect-error ignoring until we implement a production ready validation schema for workspaceMigration
actions,
workspaceId,
});
return true;
}
@Mutation(() => Boolean)
async uninstallApplication(
@Args() { universalIdentifier }: UninstallApplicationInput,
@@ -0,0 +1,54 @@
import { ArgsType, Field, InputType, registerEnumType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsEnum,
IsUUID,
ValidateNested,
} from 'class-validator';
import { ALL_METADATA_NAME, AllMetadataName } from 'twenty-shared/metadata';
import { WORKSPACE_MIGRATION_ACTION_TYPE } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/constants/workspace-migration-action-type.constant';
registerEnumType(ALL_METADATA_NAME, {
name: 'AllMetadataName',
});
registerEnumType(WORKSPACE_MIGRATION_ACTION_TYPE, {
name: 'WorkspaceMigrationActionType',
});
@InputType()
export class WorkspaceMigrationDeleteActionInput {
@Field(() => WORKSPACE_MIGRATION_ACTION_TYPE)
@IsEnum(WORKSPACE_MIGRATION_ACTION_TYPE)
type: 'delete';
@Field(() => ALL_METADATA_NAME)
@IsEnum(ALL_METADATA_NAME)
metadataName: AllMetadataName;
@Field(() => String)
@IsUUID()
universalIdentifier: string;
}
@InputType()
export class WorkspaceMigrationInput {
@Field(() => [WorkspaceMigrationDeleteActionInput])
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => WorkspaceMigrationDeleteActionInput)
actions: WorkspaceMigrationDeleteActionInput[];
}
@ArgsType()
export class InstallApplicationInput {
@Field(() => WorkspaceMigrationInput)
@ValidateNested()
@Type(() => WorkspaceMigrationInput)
workspaceMigration: WorkspaceMigrationInput;
}
@@ -3,6 +3,7 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED',
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED = 'IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
@@ -32,6 +32,7 @@ export enum ErrorCode {
TIMEOUT = 'TIMEOUT',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
METADATA_VALIDATION_FAILED = 'METADATA_VALIDATION_FAILED',
APPLICATION_INSTALLATION_FAILED = 'APPLICATION_INSTALLATION_FAILED',
}
type RestrictedGraphQLErrorExtensions = {