diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6285888cfc1..3a773398379 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1738,6 +1738,7 @@ export enum FeatureFlagKey { IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED', IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED', IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED', + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED = 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED', IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED', IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', diff --git a/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql b/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql index b53643be750..ef35ea7186e 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql +++ b/packages/twenty-sdk/src/clients/generated/metadata/schema.graphql @@ -1548,6 +1548,7 @@ enum FeatureFlagKey { IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED IS_DRAFT_EMAIL_ENABLED IS_RICH_TEXT_V1_MIGRATED + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED IS_CONNECTED_ACCOUNT_MIGRATED } diff --git a/packages/twenty-sdk/src/clients/generated/metadata/schema.ts b/packages/twenty-sdk/src/clients/generated/metadata/schema.ts index a4807811117..874bd5f296e 100644 --- a/packages/twenty-sdk/src/clients/generated/metadata/schema.ts +++ b/packages/twenty-sdk/src/clients/generated/metadata/schema.ts @@ -1252,7 +1252,7 @@ export interface FeatureFlag { __typename: 'FeatureFlag' } -export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' +export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' export interface SSOIdentityProvider { id: Scalars['UUID'] @@ -8910,6 +8910,7 @@ export const enumFeatureFlagKey = { IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' as const, IS_DRAFT_EMAIL_ENABLED: 'IS_DRAFT_EMAIL_ENABLED' as const, IS_RICH_TEXT_V1_MIGRATED: 'IS_RICH_TEXT_V1_MIGRATED' as const, + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED: 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' as const, IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const, IS_CONNECTED_ACCOUNT_MIGRATED: 'IS_CONNECTED_ACCOUNT_MIGRATED' as const } diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index 131c1ed75c8..efd06711f31 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -23,5 +23,6 @@ declare module 'express-serve-static-core' { userWorkspaceId?: string; authProvider?: AuthProviderEnum | null; impersonationContext?: RawAuthContext['impersonationContext']; + skipWorkspaceSchemaCreation?: boolean; } } diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.module.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.module.ts new file mode 100644 index 00000000000..1a117c8cfd6 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; + +import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module'; +import { DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service'; +import { WorkspaceResolverNameMapCacheService } from 'src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service'; +import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; + +@Module({ + imports: [ + CoreCommonApiModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, + WorkspaceCacheModule, + WorkspaceResolverBuilderModule, + ], + providers: [DirectExecutionService, WorkspaceResolverNameMapCacheService], + exports: [DirectExecutionService], +}) +export class DirectExecutionModule {} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts new file mode 100644 index 00000000000..0007fdc18bd --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts @@ -0,0 +1,357 @@ +import { Injectable } from '@nestjs/common'; + +import { type MessageDescriptor } from '@lingui/core'; +import { type Request } from 'express'; +import { + GraphQLError, + type DocumentNode, + type FieldNode, + type GraphQLFormattedError, + type GraphQLResolveInfo, +} from 'graphql'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { isDefined } from 'twenty-shared/utils'; + +import graphqlFields from 'graphql-fields'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { assertCreateManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util'; +import { assertCreateOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util'; +import { assertDeleteManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util'; +import { assertDeleteOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util'; +import { assertDestroyManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util'; +import { assertDestroyOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util'; +import { assertFindDuplicatesArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util'; +import { assertFindManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util'; +import { assertFindOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util'; +import { assertGroupByArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util'; +import { assertMergeManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util'; +import { assertRestoreManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util'; +import { assertRestoreOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util'; +import { assertUpdateManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util'; +import { assertUpdateOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util'; +import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util'; +import { buildWorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util'; +import { extractArgumentsFromAst } from 'src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util'; +import { graphQLBackfillNullsFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util'; +import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util'; +import { graphQLBuildPartialResolveInfo } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util'; +import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util'; +import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; +import { CreateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory'; +import { CreateOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory'; +import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory'; +import { DeleteOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory'; +import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory'; +import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory'; +import { FindDuplicatesResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory'; +import { FindManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory'; +import { FindOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory'; +import { GroupByResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory'; +import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory'; +import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory'; +import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory'; +import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; +import { UpdateOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory'; +import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; +import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +type DirectExecutionResult = { + data: Record | null; + errors?: GraphQLFormattedError[]; +}; + +@Injectable() +export class DirectExecutionService { + private readonly factoryMap: Map< + string, + WorkspaceResolverBuilderFactoryInterface + >; + + private readonly argsAssertionMap: Map void>; + + constructor( + private readonly workspaceFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly twentyConfigService: TwentyConfigService, + private readonly i18nService: I18nService, + private readonly findManyResolverFactory: FindManyResolverFactory, + private readonly findOneResolverFactory: FindOneResolverFactory, + private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory, + private readonly groupByResolverFactory: GroupByResolverFactory, + private readonly createOneResolverFactory: CreateOneResolverFactory, + private readonly createManyResolverFactory: CreateManyResolverFactory, + private readonly updateOneResolverFactory: UpdateOneResolverFactory, + private readonly updateManyResolverFactory: UpdateManyResolverFactory, + private readonly deleteOneResolverFactory: DeleteOneResolverFactory, + private readonly deleteManyResolverFactory: DeleteManyResolverFactory, + private readonly destroyOneResolverFactory: DestroyOneResolverFactory, + private readonly destroyManyResolverFactory: DestroyManyResolverFactory, + private readonly restoreOneResolverFactory: RestoreOneResolverFactory, + private readonly restoreManyResolverFactory: RestoreManyResolverFactory, + private readonly mergeManyResolverFactory: MergeManyResolverFactory, + ) { + this.factoryMap = new Map( + [ + [RESOLVER_METHOD_NAMES.FIND_MANY, this.findManyResolverFactory], + [RESOLVER_METHOD_NAMES.FIND_ONE, this.findOneResolverFactory], + [ + RESOLVER_METHOD_NAMES.FIND_DUPLICATES, + this.findDuplicatesResolverFactory, + ], + [RESOLVER_METHOD_NAMES.GROUP_BY, this.groupByResolverFactory], + [RESOLVER_METHOD_NAMES.CREATE_ONE, this.createOneResolverFactory], + [RESOLVER_METHOD_NAMES.CREATE_MANY, this.createManyResolverFactory], + [RESOLVER_METHOD_NAMES.UPDATE_ONE, this.updateOneResolverFactory], + [RESOLVER_METHOD_NAMES.UPDATE_MANY, this.updateManyResolverFactory], + [RESOLVER_METHOD_NAMES.DELETE_ONE, this.deleteOneResolverFactory], + [RESOLVER_METHOD_NAMES.DELETE_MANY, this.deleteManyResolverFactory], + [RESOLVER_METHOD_NAMES.DESTROY_ONE, this.destroyOneResolverFactory], + [RESOLVER_METHOD_NAMES.DESTROY_MANY, this.destroyManyResolverFactory], + [RESOLVER_METHOD_NAMES.RESTORE_ONE, this.restoreOneResolverFactory], + [RESOLVER_METHOD_NAMES.RESTORE_MANY, this.restoreManyResolverFactory], + [RESOLVER_METHOD_NAMES.MERGE_MANY, this.mergeManyResolverFactory], + ], + ); + + this.argsAssertionMap = new Map void>([ + [RESOLVER_METHOD_NAMES.FIND_MANY, assertFindManyArgs], + [RESOLVER_METHOD_NAMES.FIND_ONE, assertFindOneArgs], + [RESOLVER_METHOD_NAMES.FIND_DUPLICATES, assertFindDuplicatesArgs], + [RESOLVER_METHOD_NAMES.GROUP_BY, assertGroupByArgs], + [RESOLVER_METHOD_NAMES.CREATE_ONE, assertCreateOneArgs], + [RESOLVER_METHOD_NAMES.CREATE_MANY, assertCreateManyArgs], + [RESOLVER_METHOD_NAMES.UPDATE_ONE, assertUpdateOneArgs], + [RESOLVER_METHOD_NAMES.UPDATE_MANY, assertUpdateManyArgs], + [RESOLVER_METHOD_NAMES.DELETE_ONE, assertDeleteOneArgs], + [RESOLVER_METHOD_NAMES.DELETE_MANY, assertDeleteManyArgs], + [RESOLVER_METHOD_NAMES.DESTROY_ONE, assertDestroyOneArgs], + [RESOLVER_METHOD_NAMES.DESTROY_MANY, assertDestroyManyArgs], + [RESOLVER_METHOD_NAMES.RESTORE_ONE, assertRestoreOneArgs], + [RESOLVER_METHOD_NAMES.RESTORE_MANY, assertRestoreManyArgs], + [RESOLVER_METHOD_NAMES.MERGE_MANY, assertMergeManyArgs], + ]); + } + + async getGeneratedWorkspaceResolverNames( + workspaceId: string, + ): Promise | null> { + const { graphQLResolverNameMap } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'graphQLResolverNameMap', + ]); + + return new Set(Object.keys(graphQLResolverNameMap)); + } + + async execute( + req: Request, + document: DocumentNode, + ): Promise { + try { + const workspaceId = req.workspace?.id; + + if (!isDefined(workspaceId)) { + return null; + } + + const topLevelFields = graphQLExtractTopLevelFields( + document, + req.body.operationName, + ); + + this.checkRootResolverLimitsOrThrow(topLevelFields); + + const fragmentMap = graphQLBuildFragmentMap(document); + const variables = req.body.variables ?? {}; + const data: Record = {}; + + const { graphQLResolverNameMap } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'graphQLResolverNameMap', + ]); + + const { + flatObjectMetadataMaps, + flatFieldMetadataMaps, + objectIdByNameSingular, + } = await this.loadWorkspaceMetadata(workspaceId); + + const results = await Promise.allSettled( + topLevelFields.map(async (field) => { + const entry = graphQLResolverNameMap[field.name.value]; + const responseKey = field.alias?.value ?? field.name.value; + + const args = extractArgumentsFromAst(field.arguments, variables); + + const graphqlPartialResolveInfo = graphQLBuildPartialResolveInfo( + field, + fragmentMap, + ); + + const workspaceSchemaBuilderContext = + buildWorkspaceSchemaBuilderContext( + entry, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + objectIdByNameSingular, + ); + + const result = await this.executeField({ + entry, + args, + graphqlPartialResolveInfo, + workspaceSchemaBuilderContext, + }); + + graphQLBackfillNullsFromSelectedFields( + result, + graphqlFields(graphqlPartialResolveInfo as GraphQLResolveInfo), + ); + + return { responseKey, result }; + }), + ); + + const errors: GraphQLFormattedError[] = []; + + for (const settled of results) { + if (settled.status === 'fulfilled') { + data[settled.value.responseKey] = settled.value.result; + } else { + errors.push(this.formatError(settled.reason, req)); + } + } + + if (errors.length > 0) { + return { data, errors }; + } + + return { data }; + } catch (error) { + return { data: null, errors: [this.formatError(error, req)] }; + } + } + + private async executeField({ + entry, + args, + graphqlPartialResolveInfo, + workspaceSchemaBuilderContext, + }: { + entry: ResolverNameMapEntry; + args: Record; + graphqlPartialResolveInfo: Pick< + GraphQLResolveInfo, + 'fieldNodes' | 'fragments' + >; + workspaceSchemaBuilderContext: WorkspaceSchemaBuilderContext; + }): Promise { + const factory = this.factoryMap.get(entry.method); + const assertFunction = this.argsAssertionMap.get(entry.method); + + if (!isDefined(factory) || !isDefined(assertFunction)) { + throw new GraphqlDirectExecutionException( + `Unknown method: ${entry.method}`, + GraphqlDirectExecutionExceptionCode.UNKNOWN_METHOD, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + assertFunction(args); + + const resolver = factory.create(workspaceSchemaBuilderContext); + + return resolver( + null, + args, + null, + graphqlPartialResolveInfo as GraphQLResolveInfo, + ); + } + + private formatError(error: any, req: Request): GraphQLFormattedError { + try { + workspaceQueryRunnerGraphqlApiExceptionHandler(error); + } catch (graphqlError) { + if (graphqlError instanceof GraphQLError) { + const json = graphqlError.toJSON(); + + if (json.extensions?.userFriendlyMessage) { + const userLocale = req.locale ?? SOURCE_LOCALE; + const i18n = this.i18nService.getI18nInstance(userLocale); + + json.extensions.userFriendlyMessage = i18n._( + json.extensions.userFriendlyMessage as MessageDescriptor, + ); + } + + return json; + } + } + + return { + message: isDefined(error.message) + ? error.message + : 'Internal server error', + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }; + } + + private checkRootResolverLimitsOrThrow(topLevelFields: FieldNode[]): void { + const maxRootResolvers = this.twentyConfigService.get( + 'GRAPHQL_MAX_ROOT_RESOLVERS', + ); + + if ( + isDefined(maxRootResolvers) && + topLevelFields.length > maxRootResolvers + ) { + throw new UserInputError( + `Query too complex - Too many root resolvers requested: ${topLevelFields.length} - Maximum allowed root resolvers: ${maxRootResolvers}`, + ); + } + + const seen = new Set(); + + for (const field of topLevelFields) { + const name = field.name.value; + + if (seen.has(name)) { + throw new UserInputError(`Duplicate root resolver: "${name}"`); + } + + seen.add(name); + } + } + + private async loadWorkspaceMetadata(workspaceId: string) { + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const { idByNameSingular } = buildObjectIdByNameMaps( + flatObjectMetadataMaps, + ); + + return { + flatObjectMetadataMaps, + flatFieldMetadataMaps, + objectIdByNameSingular: idByNameSingular, + }; + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception.ts new file mode 100644 index 00000000000..1696f9b1fd0 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception.ts @@ -0,0 +1,20 @@ +import { type MessageDescriptor } from '@lingui/core'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum GraphqlDirectExecutionExceptionCode { + INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT', + UNKNOWN_METHOD = 'UNKNOWN_METHOD', +} + +export class GraphqlDirectExecutionException extends CustomException { + constructor( + message: string, + code: GraphqlDirectExecutionExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor }, + ) { + super(message, code, { + userFriendlyMessage, + }); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook.ts new file mode 100644 index 00000000000..b4a719396a0 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook.ts @@ -0,0 +1,95 @@ +import { type Request } from 'express'; +import { DocumentNode, parse } from 'graphql'; +import { type Plugin } from 'graphql-yoga'; +import { FeatureFlagKey } from 'twenty-shared/types'; + +import { isNull } from '@sniptt/guards'; +import { type DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service'; +import { computeSkipWorkspaceSchemaCreation } from 'src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util'; +import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util'; +import { hasOnlyGeneratedWorkspaceResolvers } from 'src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util'; +import { isSubscriptionOperation } from 'src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util'; +import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; + +export type DirectExecutionPluginConfig = { + directExecutionService: DirectExecutionService; + featureFlagService: FeatureFlagService; +}; + +export function useDirectExecution( + config: DirectExecutionPluginConfig, +): Plugin { + return { + onRequest: async ({ endResponse, serverContext }) => { + const req = (serverContext as unknown as { req: Request }).req; + + if (!req.workspace?.id || !req.body?.query) { + return; + } + + const isEnabled = await config.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED, + req.workspace.id, + ); + + if (!isEnabled) { + return; + } + + const generatedWorkspaceResolverNames = + await config.directExecutionService.getGeneratedWorkspaceResolverNames( + req.workspace.id, + ); + + if (!generatedWorkspaceResolverNames) { + return; + } + + const queryString = req.body.query as string; + const operationName = req.body.operationName as string | undefined; + + let document: DocumentNode; + try { + document = parse(queryString); + } catch { + return; + } + + if ( + !findOperationDefinition(document, operationName) || + isSubscriptionOperation(document, operationName) + ) { + return; + } + + if ( + computeSkipWorkspaceSchemaCreation( + queryString, + document, + operationName, + generatedWorkspaceResolverNames, + ) + ) { + req.skipWorkspaceSchemaCreation = true; + } + + if ( + !hasOnlyGeneratedWorkspaceResolvers( + document, + operationName, + generatedWorkspaceResolverNames, + ) + ) { + return; + } + + const result = await config.directExecutionService.execute(req, document); + + if (isNull(result)) { + return; + } + + return endResponse(Response.json(result)); + }, + }; +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service.ts new file mode 100644 index 00000000000..181fde17f0c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; + +import { + type ResolverNameMapEntry, + buildResolverNameMap, +} from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util'; +import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +@Injectable() +@WorkspaceCache('graphQLResolverNameMap') +export class WorkspaceResolverNameMapCacheService extends WorkspaceCacheProvider< + Record +> { + constructor(private readonly workspaceCacheService: WorkspaceCacheService) { + super(); + } + + async computeForCache( + workspaceId: string, + ): Promise> { + const { flatObjectMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + ]); + + return buildResolverNameMap(flatObjectMetadataMaps); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts new file mode 100644 index 00000000000..069508e4785 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/compute-skip-workspace-schema-creation.util.spec.ts @@ -0,0 +1,178 @@ +import { parse } from 'graphql'; + +import { computeSkipWorkspaceSchemaCreation } from 'src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util'; + +const GENERATED_RESOLVERS = new Set([ + 'findManyCompanies', + 'findOneCompany', + 'createOneCompany', + 'findManyPeople', + 'findOnePerson', +]); + +describe('computeSkipWorkspaceSchemaCreation', () => { + it('should return true when all fields are core resolvers', () => { + const query = ` + query { + currentWorkspace { id } + } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); + + it('should return true for multiple core resolver fields', () => { + const query = ` + query { + currentWorkspace { id } + currentUser { id } + } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); + + it('should return true when all fields are generated workspace resolvers', () => { + const query = ` + query { + findManyCompanies { id name } + } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); + + it('should return false for mixed queries', () => { + const query = ` + query { + findManyCompanies { id } + currentWorkspace { id } + } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(false); + }); + + it('should return false for __schema introspection', () => { + const query = ` + query { + __schema { types { name } } + } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(false); + }); + + it('should return false for __type introspection', () => { + const query = ` + query { + __type(name: "Company") { name fields { name } } + } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(false); + }); + + it('should not treat __typename as introspection', () => { + const query = ` + query { + currentWorkspace { id __typename } + } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); + + it('should return true when no operation matches (no fields to check)', () => { + const query = ` + query GetCompanies { findManyCompanies { id } } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + 'NonExistent', + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); + + it('should respect operationName', () => { + const query = ` + query CoreQuery { + currentWorkspace { id } + } + query WorkspaceQuery { + findManyCompanies { id } + } + `; + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + 'CoreQuery', + GENERATED_RESOLVERS, + ), + ).toBe(true); + + expect( + computeSkipWorkspaceSchemaCreation( + query, + parse(query), + 'WorkspaceQuery', + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/find-operation-definition.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/find-operation-definition.util.spec.ts new file mode 100644 index 00000000000..f6618ad8f54 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/find-operation-definition.util.spec.ts @@ -0,0 +1,64 @@ +import { parse } from 'graphql'; + +import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util'; + +describe('findOperationDefinition', () => { + it('should return the single operation when no operationName is given', () => { + const document = parse('query { findManyCompanies { id } }'); + + const result = findOperationDefinition(document, undefined); + + expect(result).toBeDefined(); + expect(result?.name).toBeUndefined(); + expect(result?.operation).toBe('query'); + }); + + it('should throw when multiple operations exist and no operationName is given', () => { + const document = parse(` + query First { findManyCompanies { id } } + query Second { findManyPeople { id } } + `); + + expect(() => findOperationDefinition(document, undefined)).toThrow( + 'Must provide operation name when document contains multiple operations.', + ); + }); + + it('should return the named operation when operationName matches', () => { + const document = parse(` + query First { findManyCompanies { id } } + query Second { findManyPeople { id } } + `); + + const result = findOperationDefinition(document, 'Second'); + + expect(result?.name?.value).toBe('Second'); + }); + + it('should return undefined when operationName does not match any operation', () => { + const document = parse('query MyQuery { findManyCompanies { id } }'); + + const result = findOperationDefinition(document, 'NonExistent'); + + expect(result).toBeUndefined(); + }); + + it('should return a mutation operation', () => { + const document = parse( + 'mutation CreateOne { createOnePerson(data: {}) { id } }', + ); + + const result = findOperationDefinition(document, 'CreateOne'); + + expect(result?.operation).toBe('mutation'); + expect(result?.name?.value).toBe('CreateOne'); + }); + + it('should return undefined for an empty document', () => { + const document = parse('type Query { dummy: String }'); + + const result = findOperationDefinition(document, undefined); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/graphql-extract-top-level-fields.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/graphql-extract-top-level-fields.util.spec.ts new file mode 100644 index 00000000000..d1b123ae5af --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/graphql-extract-top-level-fields.util.spec.ts @@ -0,0 +1,76 @@ +import { parse } from 'graphql'; + +import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util'; + +describe('graphQLExtractTopLevelFields', () => { + it('should return top-level fields from a query', () => { + const query = ` + query { + findManyCompanies { id name } + findManyPeople { id email } + } + `; + + const fields = graphQLExtractTopLevelFields(parse(query), undefined); + + expect(fields).toHaveLength(2); + expect(fields[0].name.value).toBe('findManyCompanies'); + expect(fields[1].name.value).toBe('findManyPeople'); + }); + + it('should return top-level fields from a mutation', () => { + const query = ` + mutation { + createOnePerson(data: { name: "Test" }) { id } + } + `; + + const fields = graphQLExtractTopLevelFields(parse(query), undefined); + + expect(fields).toHaveLength(1); + expect(fields[0].name.value).toBe('createOnePerson'); + }); + + it('should select the operation matching operationName', () => { + const query = ` + query GetCompanies { + findManyCompanies { id } + } + query GetPeople { + findManyPeople { id } + } + `; + + const fields = graphQLExtractTopLevelFields(parse(query), 'GetPeople'); + + expect(fields).toHaveLength(1); + expect(fields[0].name.value).toBe('findManyPeople'); + }); + + it('should throw when multiple operations exist and operationName is undefined', () => { + const query = ` + query First { + findManyCompanies { id } + } + query Second { + findManyPeople { id } + } + `; + + expect(() => graphQLExtractTopLevelFields(parse(query), undefined)).toThrow( + 'Must provide operation name when document contains multiple operations.', + ); + }); + + it('should return an empty array when no operation matches', () => { + const query = ` + query GetCompanies { + findManyCompanies { id } + } + `; + + const fields = graphQLExtractTopLevelFields(parse(query), 'NonExistent'); + + expect(fields).toEqual([]); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/has-only-generated-workspace-resolver.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/has-only-generated-workspace-resolver.util.spec.ts new file mode 100644 index 00000000000..3d8a67e178d --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/has-only-generated-workspace-resolver.util.spec.ts @@ -0,0 +1,121 @@ +import { parse } from 'graphql'; + +import { hasOnlyGeneratedWorkspaceResolvers } from 'src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util'; + +const GENERATED_RESOLVERS = new Set([ + 'companies', + 'company', + 'createOneCompany', + 'people', + 'person', +]); + +describe('hasOnlyGeneratedWorkspaceResolvers', () => { + it('should return true when all fields are generated workspace resolvers', () => { + const query = ` + query { + companies { id name } + people { id email } + } + `; + + expect( + hasOnlyGeneratedWorkspaceResolvers( + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); + + it('should return true for a single generated workspace resolver', () => { + const query = ` + query { + company(filter: { id: { eq: "123" } }) { id } + } + `; + + expect( + hasOnlyGeneratedWorkspaceResolvers( + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); + + it('should return false when all fields are core resolvers', () => { + const query = ` + query { + search { id } + getTimelineCalendarEventsFromOpportunityId { id } + } + `; + + expect( + hasOnlyGeneratedWorkspaceResolvers( + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(false); + }); + + it('should return false for mixed queries', () => { + const query = ` + query { + companies { id } + getTimelineCalendarEventsFromOpportunityId { id } + } + `; + + expect( + hasOnlyGeneratedWorkspaceResolvers( + parse(query), + undefined, + GENERATED_RESOLVERS, + ), + ).toBe(false); + }); + + it('should return true when no operation matches (no fields to check)', () => { + const query = ` + query GetCompanies { companies { id } } + `; + + expect( + hasOnlyGeneratedWorkspaceResolvers( + parse(query), + 'NonExistent', + GENERATED_RESOLVERS, + ), + ).toBe(true); + }); + + it('should respect operationName', () => { + const query = ` + query WorkspaceQuery { + companies { id } + } + query CoreQuery { + getTimelineCalendarEventsFromOpportunityId { id } + } + `; + + expect( + hasOnlyGeneratedWorkspaceResolvers( + parse(query), + 'WorkspaceQuery', + GENERATED_RESOLVERS, + ), + ).toBe(true); + + expect( + hasOnlyGeneratedWorkspaceResolvers( + parse(query), + 'CoreQuery', + GENERATED_RESOLVERS, + ), + ).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/is-subscription-operation.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/is-subscription-operation.util.spec.ts new file mode 100644 index 00000000000..3b6f4b3ecff --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/__tests__/is-subscription-operation.util.spec.ts @@ -0,0 +1,32 @@ +import { parse } from 'graphql'; + +import { isSubscriptionOperation } from 'src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util'; + +describe('isSubscriptionOperation', () => { + it('should return true for a subscription operation', () => { + const query = 'subscription { onCreateCompany { id } }'; + + expect(isSubscriptionOperation(parse(query), undefined)).toBe(true); + }); + + it('should return true for a named subscription matching operationName', () => { + const query = ` + subscription OnCreate { onCreateCompany { id } } + query GetAll { findManyCompanies { id } } + `; + + expect(isSubscriptionOperation(parse(query), 'OnCreate')).toBe(true); + }); + + it('should return false for a query operation', () => { + const query = 'query { findManyCompanies { id } }'; + + expect(isSubscriptionOperation(parse(query), undefined)).toBe(false); + }); + + it('should return false for a mutation operation', () => { + const query = 'mutation { createOnePerson(data: {}) { id } }'; + + expect(isSubscriptionOperation(parse(query), undefined)).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util.ts new file mode 100644 index 00000000000..280d0838d5c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util.ts @@ -0,0 +1,51 @@ +import { isBoolean, isObject } from 'class-validator'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertCreateManyArgs( + args: unknown, +): asserts args is CreateManyResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + const allowedKeys = new Set(['data', 'upsert']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('data' in args) || !Array.isArray(args.data)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "data" (array)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('upsert' in args && isDefined(args.upsert) && !isBoolean(args.upsert)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "upsert" must be a boolean', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util.ts new file mode 100644 index 00000000000..d5e2e9f3361 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util.ts @@ -0,0 +1,52 @@ +import { isBoolean, isObject } from 'class-validator'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertCreateOneArgs( + args: unknown, +): asserts args is CreateOneResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['data', 'upsert']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('data' in args) || !isObject(args.data)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "data" (object)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('upsert' in args && isDefined(args.upsert) && !isBoolean(args.upsert)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "upsert" must be a boolean', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util.ts new file mode 100644 index 00000000000..a28a18d51a0 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util.ts @@ -0,0 +1,42 @@ +import { isObject } from 'class-validator'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertDeleteManyArgs( + args: unknown, +): asserts args is DeleteManyResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['filter']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('filter' in args) || !isObject(args.filter)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "filter" (object)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util.ts new file mode 100644 index 00000000000..4e70b16e439 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util.ts @@ -0,0 +1,44 @@ +import { isObject, isString } from 'class-validator'; + +import { isValidUuid } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type DeleteOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertDeleteOneArgs( + args: unknown, +): asserts args is DeleteOneResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['id']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "id" (UUID)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util.ts new file mode 100644 index 00000000000..9b0ddf073a5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util.ts @@ -0,0 +1,42 @@ +import { isObject } from 'class-validator'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type DestroyManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertDestroyManyArgs( + args: unknown, +): asserts args is DestroyManyResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['filter']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('filter' in args) || !isObject(args.filter)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "filter" (object)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util.ts new file mode 100644 index 00000000000..5d15193e054 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util.ts @@ -0,0 +1,44 @@ +import { isObject, isString } from 'class-validator'; + +import { isValidUuid } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type DestroyOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertDestroyOneArgs( + args: unknown, +): asserts args is DestroyOneResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['id']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "id" (UUID)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util.ts new file mode 100644 index 00000000000..813c6e6c836 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util.ts @@ -0,0 +1,52 @@ +import { isObject } from 'class-validator'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type FindDuplicatesResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertFindDuplicatesArgs( + args: unknown, +): asserts args is FindDuplicatesResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['data', 'ids']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if ('data' in args && isDefined(args.data) && !Array.isArray(args.data)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "data" must be an array', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('ids' in args && isDefined(args.ids) && !Array.isArray(args.ids)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "ids" must be an array', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util.ts new file mode 100644 index 00000000000..50beae3ac7d --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util.ts @@ -0,0 +1,100 @@ +import { isArray, isNumber, isObject, isString } from 'class-validator'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertFindManyArgs( + args: unknown, +): asserts args is FindManyResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set([ + 'filter', + 'orderBy', + 'first', + 'last', + 'before', + 'after', + 'offset', + ]); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if ('first' in args && isDefined(args.first) && !isNumber(args.first)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "first" must be a number', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('last' in args && isDefined(args.last) && !isNumber(args.last)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "last" must be a number', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('filter' in args && isDefined(args.filter) && !isObject(args.filter)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "filter" must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('orderBy' in args && isDefined(args.orderBy) && !isArray(args.orderBy)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "orderBy" must be an array', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('before' in args && isDefined(args.before) && !isString(args.before)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "before" must be a string', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('after' in args && isDefined(args.after) && !isString(args.after)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "after" must be a string', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('offset' in args && isDefined(args.offset) && !isNumber(args.offset)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "offset" must be a number', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util.ts new file mode 100644 index 00000000000..24a2ad19f9d --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util.ts @@ -0,0 +1,44 @@ +import { isObject } from 'class-validator'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type FindOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertFindOneArgs( + args: unknown, +): asserts args is FindOneResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['filter']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if ('filter' in args && isDefined(args.filter) && !isObject(args.filter)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "filter" must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts new file mode 100644 index 00000000000..40bff9504e7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts @@ -0,0 +1,127 @@ +import { + isArray, + isBoolean, + isNumber, + isObject, + isString, +} from 'class-validator'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertGroupByArgs( + args: unknown, +): asserts args is GroupByResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set([ + 'filter', + 'orderBy', + 'orderByForRecords', + 'groupBy', + 'viewId', + 'includeRecords', + 'limit', + 'offsetForRecords', + ]); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('groupBy' in args) || !Array.isArray(args.groupBy)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "groupBy" (array)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('filter' in args && isDefined(args.filter) && !isObject(args.filter)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "filter" must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('orderBy' in args && isDefined(args.orderBy) && !isArray(args.orderBy)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "orderBy" must be an array', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ( + 'orderByForRecords' in args && + isDefined(args.orderByForRecords) && + !isArray(args.orderByForRecords) + ) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "orderByForRecords" must be an array', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('viewId' in args && isDefined(args.viewId) && !isString(args.viewId)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "viewId" must be a string', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ( + 'includeRecords' in args && + isDefined(args.includeRecords) && + !isBoolean(args.includeRecords) + ) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "includeRecords" must be a boolean', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('limit' in args && isDefined(args.limit) && !isNumber(args.limit)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "limit" must be a number', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ( + 'offsetForRecords' in args && + isDefined(args.offsetForRecords) && + !isNumber(args.offsetForRecords) + ) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "offsetForRecords" must be a number', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util.ts new file mode 100644 index 00000000000..89502cc49da --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util.ts @@ -0,0 +1,63 @@ +import { isBoolean, isNumber, isObject } from 'class-validator'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type MergeManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertMergeManyArgs( + args: unknown, +): asserts args is MergeManyResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['ids', 'conflictPriorityIndex', 'dryRun']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('ids' in args) || !Array.isArray(args.ids)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "ids" (array)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ( + !('conflictPriorityIndex' in args) || + !isNumber(args.conflictPriorityIndex) + ) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "conflictPriorityIndex" (number)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if ('dryRun' in args && isDefined(args.dryRun) && !isBoolean(args.dryRun)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: "dryRun" must be a boolean', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util.ts new file mode 100644 index 00000000000..2836f3b9e55 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util.ts @@ -0,0 +1,42 @@ +import { isObject } from 'class-validator'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type RestoreManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertRestoreManyArgs( + args: unknown, +): asserts args is RestoreManyResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['filter']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('filter' in args) || !isObject(args.filter)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "filter" (object)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util.ts new file mode 100644 index 00000000000..95a5dde86a2 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util.ts @@ -0,0 +1,43 @@ +import { isObject, isString } from 'class-validator'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type RestoreOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { isValidUuid } from 'twenty-shared/utils'; + +export function assertRestoreOneArgs( + args: unknown, +): asserts args is RestoreOneResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['id']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "id" (UUID)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util.ts new file mode 100644 index 00000000000..2f0c7395a22 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util.ts @@ -0,0 +1,50 @@ +import { isObject } from 'class-validator'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertUpdateManyArgs( + args: unknown, +): asserts args is UpdateManyResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['filter', 'data']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('filter' in args) || !isObject(args.filter)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "filter" (object)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if (!('data' in args) || !isObject(args.data)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "data" (object)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util.ts new file mode 100644 index 00000000000..5e8ff81bd7e --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util.ts @@ -0,0 +1,52 @@ +import { isObject, isString } from 'class-validator'; + +import { isValidUuid } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +export function assertUpdateOneArgs( + args: unknown, +): asserts args is UpdateOneResolverArgs { + if (!isObject(args)) { + throw new GraphqlDirectExecutionException( + 'Invalid argument: it must be an object', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + const argKeys = Object.keys(args); + + const allowedKeys = new Set(['id', 'data']); + + for (const key of argKeys) { + if (!allowedKeys.has(key)) { + throw new GraphqlDirectExecutionException( + `Argument not allowed: ${key}`, + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + } + + if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "id" (UUID)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + + if (!('data' in args) || !isObject(args.data)) { + throw new GraphqlDirectExecutionException( + 'Missing required argument: "data" (object)', + GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util.ts new file mode 100644 index 00000000000..c59eb9fcf3c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util.ts @@ -0,0 +1,47 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories'; +import { type WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; + +export type ResolverNameMapEntry = { + objectMetadataUniversalIdentifier: string; + method: WorkspaceResolverBuilderMethodNames; + operationType: 'query' | 'mutation'; +}; + +export const buildResolverNameMap = ( + flatObjectMetadataMaps: FlatEntityMaps, +): Record => { + const map: Record = {}; + + const allMethods = [ + ...workspaceResolverBuilderMethodNames.queries.map((method) => ({ + method, + operationType: 'query' as const, + })), + ...workspaceResolverBuilderMethodNames.mutations.map((method) => ({ + method, + operationType: 'mutation' as const, + })), + ]; + + for (const flatObjectMetadata of Object.values( + flatObjectMetadataMaps.byUniversalIdentifier, + ).filter(isDefined)) { + for (const { method, operationType } of allMethods) { + const resolverName = getResolverName(flatObjectMetadata, method); + + map[resolverName] = { + objectMetadataUniversalIdentifier: + flatObjectMetadata.universalIdentifier, + method, + operationType, + }; + } + } + + return map; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts new file mode 100644 index 00000000000..6b66b7c287b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts @@ -0,0 +1,30 @@ +import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util'; +import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +export const buildWorkspaceSchemaBuilderContext = ( + entry: ResolverNameMapEntry, + flatObjectMetadataMaps: FlatEntityMaps, + flatFieldMetadataMaps: FlatEntityMaps, + objectIdByNameSingular: Record, +): WorkspaceSchemaBuilderContext => { + const flatObjectMetadata = + flatObjectMetadataMaps.byUniversalIdentifier[ + entry.objectMetadataUniversalIdentifier + ]; + + if (!flatObjectMetadata) { + throw new Error( + `Object metadata not found for universal identifier: ${entry.objectMetadataUniversalIdentifier}`, + ); + } + + return { + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + objectIdByNameSingular, + }; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts new file mode 100644 index 00000000000..2b63cc898b9 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util.ts @@ -0,0 +1,27 @@ +import { type DocumentNode } from 'graphql'; + +import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util'; + +const INTROSPECTION_PATTERN = /__schema|__type(?!name)/; + +export const computeSkipWorkspaceSchemaCreation = ( + queryString: string, + document: DocumentNode, + operationName: string | undefined, + generatedWorkspaceResolverNames: Set, +): boolean => { + if (INTROSPECTION_PATTERN.test(queryString)) { + return false; + } + + const topLevelFields = graphQLExtractTopLevelFields(document, operationName); + + const hasCore = topLevelFields.some( + (field) => !generatedWorkspaceResolverNames.has(field.name.value), + ); + const hasGenerated = topLevelFields.some((field) => + generatedWorkspaceResolverNames.has(field.name.value), + ); + + return !(hasCore && hasGenerated); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util.ts new file mode 100644 index 00000000000..018f93b4991 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util.ts @@ -0,0 +1,23 @@ +import { type ArgumentNode, valueFromASTUntyped } from 'graphql'; +import { isDefined, isEmptyObject } from 'twenty-shared/utils'; + +// Converts GraphQL AST argument nodes into a plain JS object, +// resolving variable references from the variables map. +export const extractArgumentsFromAst = ( + argumentNodes: readonly ArgumentNode[] | undefined, + variables: Record | undefined, +): Record => { + if (!argumentNodes || argumentNodes.length === 0) { + return {}; + } + + const result: Record = {}; + + for (const arg of argumentNodes) { + const value = valueFromASTUntyped(arg.value, variables); + if (!isDefined(value) || isEmptyObject(value)) continue; + result[arg.name.value] = valueFromASTUntyped(arg.value, variables); + } + + return result; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/find-operation-definition.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/find-operation-definition.util.ts new file mode 100644 index 00000000000..3d9b844f5db --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/find-operation-definition.util.ts @@ -0,0 +1,30 @@ +import { + type DocumentNode, + type OperationDefinitionNode, + GraphQLError, + Kind, +} from 'graphql'; + +export const findOperationDefinition = ( + document: DocumentNode, + operationName: string | undefined, +): OperationDefinitionNode | undefined => { + const operations = document.definitions.filter( + (definition): definition is OperationDefinitionNode => + definition.kind === Kind.OPERATION_DEFINITION, + ); + + if (operationName) { + return operations.find( + (operation) => operation.name?.value === operationName, + ); + } + + if (operations.length > 1) { + throw new GraphQLError( + 'Must provide operation name when document contains multiple operations.', + ); + } + + return operations[0]; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts new file mode 100644 index 00000000000..a1106973e61 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts @@ -0,0 +1,52 @@ +// GraphQL schema execution automatically returns null for nullable fields +// that are missing from the resolved object. Since direct execution bypasses +// schema resolution, we need to explicitly set requested-but-missing fields +// to null so the response shape matches what GraphQL would produce. +export const graphQLBackfillNullsFromSelectedFields = ( + result: unknown, + selectedFields: Record, +): unknown => { + if (result === null || result === undefined || typeof result !== 'object') { + return result; + } + + if (Array.isArray(result)) { + return result.map((item) => + graphQLBackfillNullsFromSelectedFields(item, selectedFields), + ); + } + + const record = result as Record; + + for (const [key, subFields] of Object.entries(selectedFields)) { + if (!(key in record)) { + record[key] = null; + continue; + } + + const hasNestedFields = + subFields && + typeof subFields === 'object' && + Object.keys(subFields).length > 0; + + if (!hasNestedFields || record[key] === null || record[key] === undefined) { + continue; + } + + if (Array.isArray(record[key])) { + record[key] = (record[key] as unknown[]).map((item) => + graphQLBackfillNullsFromSelectedFields( + item, + subFields as Record, + ), + ); + } else if (typeof record[key] === 'object') { + graphQLBackfillNullsFromSelectedFields( + record[key], + subFields as Record, + ); + } + } + + return result; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util.ts new file mode 100644 index 00000000000..15dd6ddf0e4 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util.ts @@ -0,0 +1,15 @@ +import { DocumentNode, FragmentDefinitionNode, Kind } from 'graphql'; + +export const graphQLBuildFragmentMap = ( + document: DocumentNode, +): Map => { + const map = new Map(); + + for (const definition of document.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + map.set(definition.name.value, definition); + } + } + + return map; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util.ts new file mode 100644 index 00000000000..8fefdbfb60b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util.ts @@ -0,0 +1,13 @@ +import { + type FieldNode, + type FragmentDefinitionNode, + type GraphQLResolveInfo, +} from 'graphql'; + +export const graphQLBuildPartialResolveInfo = ( + field: FieldNode, + fragmentMap: Map, +): Pick => ({ + fieldNodes: [field], + fragments: Object.fromEntries(fragmentMap), +}); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util.ts new file mode 100644 index 00000000000..7966aa91ad9 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util.ts @@ -0,0 +1,24 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + GraphqlDirectExecutionException, + GraphqlDirectExecutionExceptionCode, +} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { + InternalServerError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export const graphqlDirectExecutionToGraphqlApiExceptionHandler = ( + error: GraphqlDirectExecutionException, +) => { + switch (error.code) { + case GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT: + throw new UserInputError(error); + case GraphqlDirectExecutionExceptionCode.UNKNOWN_METHOD: + throw new InternalServerError(error); + default: { + return assertUnreachable(error.code); + } + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util.ts new file mode 100644 index 00000000000..a5b367f70c1 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util.ts @@ -0,0 +1,18 @@ +import { type DocumentNode, type FieldNode, Kind } from 'graphql'; + +import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util'; + +export const graphQLExtractTopLevelFields = ( + document: DocumentNode, + operationName: string | undefined, +): FieldNode[] => { + const operationDefinition = findOperationDefinition(document, operationName); + + if (!operationDefinition) { + return []; + } + + return operationDefinition.selectionSet.selections.filter( + (selection): selection is FieldNode => selection.kind === Kind.FIELD, + ); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util.ts new file mode 100644 index 00000000000..66112bc8b54 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util.ts @@ -0,0 +1,15 @@ +import { type DocumentNode } from 'graphql'; + +import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util'; + +export const hasOnlyGeneratedWorkspaceResolvers = ( + document: DocumentNode, + operationName: string | undefined, + generatedWorkspaceResolverNames: Set, +): boolean => { + const topLevelFields = graphQLExtractTopLevelFields(document, operationName); + + return topLevelFields.every((field) => + generatedWorkspaceResolverNames.has(field.name.value), + ); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util.ts new file mode 100644 index 00000000000..deaeb9f95d8 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util.ts @@ -0,0 +1,12 @@ +import { type DocumentNode } from 'graphql'; + +import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util'; + +export const isSubscriptionOperation = ( + document: DocumentNode, + operationName: string | undefined, +): boolean => { + const operation = findOperationDefinition(document, operationName); + + return operation?.operation === 'subscription'; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts index 8b4af6579c0..2d63458e811 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { DirectExecutionModule } from 'src/engine/api/graphql/direct-execution/direct-execution.module'; import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module'; @Module({ - imports: [CoreEngineModule], + imports: [CoreEngineModule, DirectExecutionModule], providers: [], - exports: [CoreEngineModule], + exports: [CoreEngineModule, DirectExecutionModule], }) export class GraphQLConfigModule {} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index 6c7715a1836..9df77aad620 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -18,9 +18,12 @@ import { isDefined } from 'twenty-shared/utils'; import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +import { DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service'; +import { useDirectExecution } from 'src/engine/api/graphql/direct-execution/hooks/use-direct-execution.hook'; import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory'; import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { useSentryTracing } from 'src/engine/core-modules/exception-handler/hooks/use-sentry-tracing'; import { useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers } from 'src/engine/core-modules/graphql/hooks/use-disable-introspection-and-suggestions-for-unauthenticated-users.hook'; import { useGraphQLErrorHandlerHook } from 'src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook'; @@ -50,12 +53,18 @@ export class GraphQLConfigService private readonly metricsService: MetricsService, private readonly dataloaderService: DataloaderService, private readonly i18nService: I18nService, + private readonly directExecutionService: DirectExecutionService, + private readonly featureFlagService: FeatureFlagService, ) {} createGqlOptions(): YogaDriverConfig { const isDebugMode = this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.DEVELOPMENT; const plugins = [ + useDirectExecution({ + directExecutionService: this.directExecutionService, + featureFlagService: this.featureFlagService, + }), useGraphQLErrorHandlerHook({ metricsService: this.metricsService, exceptionHandlerService: this.exceptionHandlerService, @@ -85,10 +94,11 @@ export class GraphQLConfigService resolverSchemaScope: 'core', buildSchemaOptions: {}, conditionalSchema: async (context) => { - const { workspace, user, application } = context.req; + const { workspace, user, application, skipWorkspaceSchemaCreation } = + context.req; try { - if (!isDefined(workspace)) { + if (!isDefined(workspace) || skipWorkspaceSchemaCreation) { return new GraphQLSchema({}); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts index 58667e2f7f4..4b25b71d6c5 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts @@ -2,6 +2,8 @@ import { type QueryFailedError } from 'typeorm'; import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { commonQueryRunnerToGraphqlApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util'; +import { GraphqlDirectExecutionException } from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; +import { graphqlDirectExecutionToGraphqlApiExceptionHandler } from 'src/engine/api/graphql/direct-execution/utils/graphql-direct-execution-to-graphql-api-exception-handler.util'; import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util'; import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util'; @@ -45,6 +47,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = ( return apiKeyGraphqlApiExceptionHandler(error); case error instanceof ThrottlerException: return throttlerToGraphqlApiExceptionHandler(error); + case error instanceof GraphqlDirectExecutionException: + return graphqlDirectExecutionToGraphqlApiExceptionHandler(error); default: throw error; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts index 9bb43cd973c..4953094cf25 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class CreateManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts index 2412f852b4e..8f7986a7aca 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class CreateOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts index a941564284c..9328cdbe31d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class DeleteManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts index c8a956e8778..66fa9ab811b 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class DeleteOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts index 67b5bbfaca3..bb622bd546f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class DestroyManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts index aa2affcd324..1d090f0a9af 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class DestroyOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts index a833b7beb6b..abd41c9a4c8 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts @@ -31,7 +31,7 @@ export class FindDuplicatesResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts index f8125d87d6f..5e188e1b8f2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class FindManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts index 19e027ea8b0..0dd83863bc1 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class FindOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { try { const selectedFields = graphqlFields(info); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts index be22cdfe131..4de25758be6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts @@ -31,7 +31,7 @@ export class GroupByResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts index da59fe1b028..3d22a30b6d3 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts @@ -30,7 +30,7 @@ export class MergeManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts index 13ac817f89d..e1773de6cd7 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class RestoreManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts index 0706f082de3..2a9518efd0d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class RestoreOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts index e2756a4594b..bb49e72ee45 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts @@ -31,7 +31,7 @@ export class UpdateManyResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts index 48f273a69e4..06bb7584700 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts @@ -30,7 +30,7 @@ export class UpdateOneResolverFactory ): Resolver { const internalContext = context; - return async (_source, args, requestContext, info) => { + return async (_source, args, _requestContext, info) => { const selectedFields = graphqlFields(info); const resolverContext = createQueryRunnerContext({ diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts index cd2915d8d77..c869d679652 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module.ts @@ -16,6 +16,10 @@ import { workspaceResolverBuilderFactories } from './factories/factories'; WorkspaceResolverFactory, WorkspaceResolverBuilderService, ], - exports: [WorkspaceResolverFactory, WorkspaceResolverBuilderService], + exports: [ + ...workspaceResolverBuilderFactories, + WorkspaceResolverFactory, + WorkspaceResolverBuilderService, + ], }) export class WorkspaceResolverBuilderModule {} diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 28297aa9efa..448ab698034 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -230,26 +230,24 @@ describe('WorkspaceEntityManager', () => { IS_JSON_FILTER_ENABLED: false, IS_AI_ENABLED: false, IS_APPLICATION_ENABLED: false, - IS_IMAP_SMTP_CALDAV_ENABLED: false, + IS_MARKETPLACE_ENABLED: false, + IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false, IS_PUBLIC_DOMAIN_ENABLED: false, IS_EMAILING_DOMAIN_ENABLED: false, IS_DASHBOARD_V2_ENABLED: false, IS_ATTACHMENT_MIGRATED: false, IS_NOTE_TARGET_MIGRATED: false, IS_TASK_TARGET_MIGRATED: false, - IS_TIMELINE_ACTIVITY_MIGRATED: false, - IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED: false, IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED: false, IS_JUNCTION_RELATIONS_ENABLED: false, IS_COMMAND_MENU_ITEM_ENABLED: false, IS_NAVIGATION_MENU_ITEM_ENABLED: false, - IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: false, - IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false, - IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false, - IS_MARKETPLACE_ENABLED: false, IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED: false, + IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: false, IS_DRAFT_EMAIL_ENABLED: false, IS_RICH_TEXT_V1_MIGRATED: false, + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED: false, + IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false, IS_CONNECTED_ACCOUNT_MIGRATED: false, }, userWorkspaceRoleMap: {}, diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index 02345256aa0..c3457212197 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -4,6 +4,7 @@ import { } from 'twenty-shared/types'; import { type EntityMetadata } from 'typeorm'; +import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util'; import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type'; import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type'; import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; @@ -51,6 +52,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { flatWebhookMaps: 'flat-maps:webhook', flatWorkspaceMemberMaps: 'flat-maps:workspace-member', applicationVariableMaps: 'cache:application-variable', + graphQLResolverNameMap: 'direct-execution:graphql-resolver-name-map', } as const satisfies Record; export type AdditionalCacheDataMaps = { @@ -65,6 +67,7 @@ export type AdditionalCacheDataMaps = { flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps; flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps; applicationVariableMaps: ApplicationVariableCacheMaps; + graphQLResolverNameMap: Record; }; export type WorkspaceCacheDataMap = AllFlatEntityMaps & diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts index aed64f1843d..5d8874791a7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service.ts @@ -97,6 +97,7 @@ export class WorkspaceMigrationRunnerService { 'apiKeyRoleMap', 'ORMEntityMetadatas', 'flatRoleTargetByAgentIdMaps', + 'graphQLResolverNameMap', ]), ); } diff --git a/packages/twenty-shared/src/types/FeatureFlagKey.ts b/packages/twenty-shared/src/types/FeatureFlagKey.ts index d7bd4e01eb2..cbf68e928da 100644 --- a/packages/twenty-shared/src/types/FeatureFlagKey.ts +++ b/packages/twenty-shared/src/types/FeatureFlagKey.ts @@ -19,6 +19,7 @@ export enum FeatureFlagKey { IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED', IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED', IS_RICH_TEXT_V1_MIGRATED = 'IS_RICH_TEXT_V1_MIGRATED', + IS_DIRECT_GRAPHQL_EXECUTION_ENABLED = 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED', IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED', IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED', }