From 48031a7ce267b87c6aed146c1177f56a694ac4e0 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:03:50 +0100 Subject: [PATCH] Runner v2 on cascade `delete_field/object` + refactor workspace deletion side effect (#15830) # Introduction While removing the v1 https://github.com/twentyhq/twenty/pull/15823 I've encountered the method `objectMetadataService.deleteObjectsMetadata` that I didn't wanted to migrate as it is and if it's not challenging its existence legitimacy. ## Motivations In a nutshell on a workspace deletion object metadata and field metadata cascading deletion is correclty handled But that's not the case for all of a workspaces entities ( roles, workspaceMigrations ) I suspect that we did not defined the foreignKey explicitly through `typeorm` ## Battle testing v2 I still decided to give a try to a complex operation in the v2 such as a workspace all object metadata deletion. Spoiler it failed due to object being interdependent between them and not being topologically sorted ( morph relation can introduce circular dep anw ). Note: Even after removing the delete field on delete object aggregator we end up with an equivalent circular dep error which an object and its field metadata identifier connection. ## Elegant `DEFERRED` and `DEFERRABLE` foreign keys The most safe, low level solution would be to make all field relations deferrable and start the runner transaction as deferred. But this requires a quite invasive migration of existing FK ## On cascade solution I've opted for the quick fix, on object or field deletion spread cacasde. It's pretty safe as the builder priorly validates the deletion and and its related entities integrity Only for both field and object deletion action types in v2 runner ## Integration coverage Added a test that will scan a new workspace database core schema tables and expect now result after workspace deletion through its only user deletion --- .../workspace/services/workspace.service.ts | 2 - .../object-metadata-v2.service.ts | 160 +++++++++++++++--- .../object-metadata/object-metadata.entity.ts | 1 + ...workspace-schema-column-manager.service.ts | 5 +- .../workspace-schema-table-manager.service.ts | 5 +- .../workspace-manager.service.spec.ts | 20 ++- .../workspace-manager.module.ts | 2 +- .../workspace-manager.service.ts | 21 +-- .../delete-field-action-handler.service.ts | 1 + .../delete-object-action-handler.service.ts | 1 + ...runner-action-handler-service.interface.ts | 33 ++-- ...and-workspace-creation.integration-spec.ts | 125 ++++++++++++++ .../utils/create-one-object-metadata.util.ts | 3 +- .../utils/make-metadata-api-request.util.ts | 7 +- .../types/perform-metadata-query.type.ts | 1 + 15 files changed, 316 insertions(+), 71 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index e84b9402c8..3d9b8a91d0 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -9,7 +9,6 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { Repository } from 'typeorm'; -import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service'; @@ -51,7 +50,6 @@ import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { - private readonly featureLookUpKey = BillingEntitlementKey.CUSTOM_DOMAIN; protected readonly logger = new Logger(WorkspaceService.name); private readonly WORKSPACE_FIELD_PERMISSIONS: Record< diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts index b774a47b2f..4a801c2fe0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { isDefined } from 'twenty-shared/utils'; +import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { computeFlatEntityMapsFromTo } from 'src/engine/metadata-modules/flat-entity/utils/compute-flat-entity-maps-from-to.util'; import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util'; import { fromDeleteObjectInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util'; @@ -165,50 +166,127 @@ export class ObjectMetadataServiceV2 { workspaceId: string; isSystemBuild?: boolean; }): Promise { + const deletedObjectMetadataDtos = await this.deleteManyObjectMetadatas({ + deleteObjectInputs: [deleteObjectInput], + workspaceId, + isSystemBuild, + }); + + if (deletedObjectMetadataDtos.length !== 1) { + throw new ObjectMetadataException( + 'Could not retrieve deleted object metadata dto', + ObjectMetadataExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const [deletedObjectMetadataDto] = deletedObjectMetadataDtos; + + return deletedObjectMetadataDto; + } + + private async deleteManyObjectMetadatas({ + workspaceId, + deleteObjectInputs, + isSystemBuild = false, + }: { + deleteObjectInputs: DeleteOneObjectInput[]; + workspaceId: string; + isSystemBuild?: boolean; + }): Promise { + if (deleteObjectInputs.length === 0) { + return []; + } + + const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatIndexMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatIndexMaps', + 'flatFieldMetadataMaps', + ], + }, + ); + + const initialAccumulator: { + flatFieldMetadatasToDeleteById: Record; + flatObjectMetadatasToDeleteById: Record; + flatIndexToDeleteById: Record; + } = { + flatFieldMetadatasToDeleteById: {}, + flatIndexToDeleteById: {}, + flatObjectMetadatasToDeleteById: {}, + }; + const { - flatObjectMetadataMaps: existingFlatObjectMetadataMaps, - flatIndexMaps: existingFlatIndexMaps, - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: [ - 'flatObjectMetadataMaps', - 'flatIndexMaps', - 'flatFieldMetadataMaps', - ], - }, - ); + flatFieldMetadatasToDeleteById, + flatIndexToDeleteById, + flatObjectMetadatasToDeleteById, + } = deleteObjectInputs.reduce((accumulator, deleteObjectInput) => { + const { + flatFieldMetadatasToDelete, + flatObjectMetadataToDelete, + flatIndexToDelete, + } = fromDeleteObjectInputToFlatFieldMetadatasToDelete({ + flatObjectMetadataMaps, + flatIndexMaps, + flatFieldMetadataMaps, + deleteObjectInput, + }); + + return { + flatFieldMetadatasToDeleteById: { + ...accumulator.flatFieldMetadatasToDeleteById, + ...fromArrayToUniqueKeyRecord({ + array: flatFieldMetadatasToDelete, + uniqueKey: 'id', + }), + }, + flatIndexToDeleteById: { + ...accumulator.flatIndexToDeleteById, + ...fromArrayToUniqueKeyRecord({ + array: flatIndexToDelete, + uniqueKey: 'id', + }), + }, + flatObjectMetadatasToDeleteById: { + ...accumulator.flatObjectMetadatasToDeleteById, + [flatObjectMetadataToDelete.id]: flatObjectMetadataToDelete, + }, + }; + }, initialAccumulator); const { flatFieldMetadatasToDelete, - flatObjectMetadataToDelete, + flatObjectMetadatasToDelete, flatIndexToDelete, - } = fromDeleteObjectInputToFlatFieldMetadatasToDelete({ - flatObjectMetadataMaps: existingFlatObjectMetadataMaps, - flatIndexMaps: existingFlatIndexMaps, - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - deleteObjectInput, - }); + } = { + flatFieldMetadatasToDelete: Object.values(flatFieldMetadatasToDeleteById), + flatObjectMetadatasToDelete: Object.values( + flatObjectMetadatasToDeleteById, + ), + flatIndexToDelete: Object.values(flatIndexToDeleteById), + }; const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( { fromToAllFlatEntityMaps: { flatObjectMetadataMaps: computeFlatEntityMapsFromTo({ - flatEntityMaps: existingFlatObjectMetadataMaps, + flatEntityMaps: flatObjectMetadataMaps, flatEntityToCreate: [], - flatEntityToDelete: [flatObjectMetadataToDelete], + flatEntityToDelete: flatObjectMetadatasToDelete, flatEntityToUpdate: [], }), flatIndexMaps: computeFlatEntityMapsFromTo({ - flatEntityMaps: existingFlatIndexMaps, + flatEntityMaps: flatIndexMaps, flatEntityToCreate: [], flatEntityToDelete: flatIndexToDelete, flatEntityToUpdate: [], }), flatFieldMetadataMaps: computeFlatEntityMapsFromTo({ - flatEntityMaps: existingFlatFieldMetadataMaps, + flatEntityMaps: flatFieldMetadataMaps, flatEntityToCreate: [], flatEntityToDelete: flatFieldMetadatasToDelete, flatEntityToUpdate: [], @@ -229,12 +307,12 @@ export class ObjectMetadataServiceV2 { if (isDefined(validateAndBuildResult)) { throw new WorkspaceMigrationBuilderExceptionV2( validateAndBuildResult, - 'Multiple validation errors occurred while deleting object', + `Multiple validation errors occurred while deleting object${deleteObjectInputs.length > 1 ? 's' : ''}`, ); } - return fromFlatObjectMetadataToObjectMetadataDto( - flatObjectMetadataToDelete, + return flatObjectMetadatasToDelete.map( + fromFlatObjectMetadataToObjectMetadataDto, ); } @@ -445,4 +523,30 @@ export class ObjectMetadataServiceV2 { position: favoriteCount, }); } + + public async deleteWorkspaceAllObjectMetadata({ + workspaceId, + }: { + workspaceId: string; + }) { + const { flatObjectMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }, + ); + + const deleteObjectInputs = Object.values(flatObjectMetadataMaps.byId) + .filter(isDefined) + .map((flatObjectMetadata) => ({ + id: flatObjectMetadata.id, + })); + + await this.deleteManyObjectMetadatas({ + deleteObjectInputs, + workspaceId, + isSystemBuild: true, + }); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index fcb7b918a8..297f11879e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -107,6 +107,7 @@ export class ObjectMetadataEntity @Column({ default: false }) isLabelSyncedWithName: boolean; + // TODO create a relation to workspace with cascade delete will also create foreignKey @Column({ nullable: false, type: 'uuid' }) workspaceId: string; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts index 831cb92667..fcc55a1198 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts @@ -33,20 +33,23 @@ export class WorkspaceSchemaColumnManagerService { schemaName, tableName, columnNames, + cascade = false, }: { queryRunner: QueryRunner; schemaName: string; tableName: string; columnNames: string[]; + cascade?: boolean; }): Promise { if (columnNames.length === 0) return; const safeSchemaName = removeSqlDDLInjection(schemaName); const safeTableName = removeSqlDDLInjection(tableName); + const cascadeClause = cascade ? ' CASCADE' : ''; const dropClauses = columnNames.map((name) => { const safeName = removeSqlDDLInjection(name); - return `DROP COLUMN IF EXISTS "${safeName}"`; + return `DROP COLUMN IF EXISTS "${safeName}"${cascadeClause}`; }); const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ${dropClauses.join(', ')}`; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service.ts index cc4e8d84fe..679ad2c3cc 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-table-manager.service.ts @@ -39,14 +39,17 @@ export class WorkspaceSchemaTableManagerService { queryRunner, schemaName, tableName, + cascade = false, }: { queryRunner: QueryRunner; schemaName: string; tableName: string; + cascade?: boolean; }): Promise { const safeSchemaName = removeSqlDDLInjection(schemaName); const safeTableName = removeSqlDDLInjection(tableName); - const sql = `DROP TABLE IF EXISTS "${safeSchemaName}"."${safeTableName}"`; + const cascadeClause = cascade ? ' CASCADE' : ''; + const sql = `DROP TABLE IF EXISTS "${safeSchemaName}"."${safeTableName}"${cascadeClause}`; await queryRunner.query(sql); } diff --git a/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts index 7e2b7644f9..240efddac8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts @@ -11,6 +11,7 @@ import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; @@ -27,7 +28,7 @@ import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/works describe('WorkspaceManagerService', () => { let service: WorkspaceManagerService; - let objectMetadataService: ObjectMetadataService; + let objectMetadataServiceV2: ObjectMetadataServiceV2; let workspaceMigrationRepository: Repository; let dataSourceRepository: Repository; let workspaceDataSourceService: WorkspaceDataSourceService; @@ -101,6 +102,10 @@ describe('WorkspaceManagerService', () => { provide: FeatureFlagService, useValue: {}, }, + { + provide: ObjectMetadataService, + useValue: {}, + }, { provide: RoleService, useValue: {}, @@ -123,11 +128,10 @@ describe('WorkspaceManagerService', () => { provide: WorkspaceSyncMetadataService, useValue: {}, }, - { - provide: ObjectMetadataService, + provide: ObjectMetadataServiceV2, useValue: { - deleteObjectsMetadata: jest.fn(), + deleteWorkspaceAllObjectMetadata: jest.fn(), }, }, { @@ -150,8 +154,8 @@ describe('WorkspaceManagerService', () => { }).compile(); service = module.get(WorkspaceManagerService); - objectMetadataService = module.get( - ObjectMetadataService, + objectMetadataServiceV2 = module.get( + ObjectMetadataServiceV2, ); workspaceMigrationRepository = module.get< Repository @@ -177,7 +181,9 @@ describe('WorkspaceManagerService', () => { describe('delete', () => { it('should delete all the workspace metadata tables and workspace schema', async () => { await service.delete('workspace-id'); - expect(objectMetadataService.deleteObjectsMetadata).toHaveBeenCalled(); + expect( + objectMetadataServiceV2.deleteWorkspaceAllObjectMetadata, + ).toHaveBeenCalled(); expect(workspaceMigrationRepository.delete).toHaveBeenCalledWith({ workspaceId: 'workspace-id', }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts index c92fbfb0b2..50ff0eafe0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -18,7 +19,6 @@ import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-see import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module'; import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module'; import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module'; -import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { WorkspaceManagerService } from './workspace-manager.service'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index 69660fbe01..f0ce3bc0e4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -9,6 +9,7 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; @@ -32,6 +33,7 @@ export class WorkspaceManagerService { private readonly workspaceDataSourceService: WorkspaceDataSourceService, private readonly workspaceMigrationService: WorkspaceMigrationService, private readonly objectMetadataService: ObjectMetadataService, + private readonly objectMetadataServiceV2: ObjectMetadataServiceV2, private readonly dataSourceService: DataSourceService, private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService, @InjectRepository(UserWorkspaceEntity) @@ -136,32 +138,23 @@ export class WorkspaceManagerService { }); } + // TODO investigate why some entities are not on cascade delete + // Are foreign keys correctly applied ? public async delete(workspaceId: string): Promise { - //TODO: delete all logs when #611 closed - this.logger.log(`Deleting workspace ${workspaceId} ...`); - await this.roleTargetsRepository.delete({ workspaceId, }); - this.logger.log(`workspace ${workspaceId} role targets deleted`); - await this.roleRepository.delete({ workspaceId, }); - this.logger.log(`workspace ${workspaceId} role deleted`); - await this.objectMetadataService.deleteObjectsMetadata(workspaceId); - this.logger.log(`workspace ${workspaceId} object metadata deleted`); + await this.objectMetadataServiceV2.deleteWorkspaceAllObjectMetadata({ + workspaceId, + }); await this.workspaceMigrationService.deleteAllWithinWorkspace(workspaceId); - this.logger.log(`workspace ${workspaceId} migration deleted`); - await this.dataSourceService.delete(workspaceId); - this.logger.log(`workspace ${workspaceId} data source deleted`); - // Delete schema await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspaceId); - - this.logger.log(`workspace ${workspaceId} schema deleted`); } private async setupDefaultRoles( diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/delete-field-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/delete-field-action-handler.service.ts index f6eacbff0f..95f9e60872 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/delete-field-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/delete-field-action-handler.service.ts @@ -103,6 +103,7 @@ export class DeleteFieldActionHandlerService extends WorkspaceMigrationRunnerAct schemaName, tableName, columnNames: columnNamesToDrop, + cascade: true, }); const enumOperations = collectEnumOperationsForField({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/object/services/delete-object-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/object/services/delete-object-action-handler.service.ts index 08f7420528..fe5cc61d01 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/object/services/delete-object-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/object/services/delete-object-action-handler.service.ts @@ -89,6 +89,7 @@ export class DeleteObjectActionHandlerService extends WorkspaceMigrationRunnerAc queryRunner, schemaName, tableName, + cascade: true, }); const objectFlatFieldMetadatas = findManyFlatEntityByIdInFlatEntityMapsOrThrow({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface.ts index d1fed02cf0..e95752c954 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface.ts @@ -73,21 +73,26 @@ export abstract class BaseWorkspaceMigrationRunnerActionHandlerService< | MetadataToFlatEntityMapsKey > > { - await Promise.all([ - this.asyncMethodPerformanceMetricWrapper({ - label: 'executeForMetadata', - method: async () => this.executeForMetadata(context), - }), - this.asyncMethodPerformanceMetricWrapper({ - label: 'executeForWorkspaceSchema', - method: async () => this.executeForWorkspaceSchema(context), - }), - ]); + try { + await Promise.all([ + this.asyncMethodPerformanceMetricWrapper({ + label: 'executeForMetadata', + method: async () => this.executeForMetadata(context), + }), + this.asyncMethodPerformanceMetricWrapper({ + label: 'executeForWorkspaceSchema', + method: async () => this.executeForWorkspaceSchema(context), + }), + ]); - return this.optimisticallyApplyActionOnAllFlatEntityMaps({ - action: context.action, - allFlatEntityMaps: context.allFlatEntityMaps, - }); + return this.optimisticallyApplyActionOnAllFlatEntityMaps({ + action: context.action, + allFlatEntityMaps: context.allFlatEntityMaps, + }); + } catch (error) { + this.logger.error(`${this.actionType} execution failed`, error); + throw error; + } } async rollback( diff --git a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts index 7c6a388331..8f5531f6a2 100644 --- a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts @@ -5,6 +5,7 @@ import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get- import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; import { signUp } from 'test/integration/graphql/utils/sign-up.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; @@ -118,4 +119,128 @@ describe('Successful user and workspace creation', () => { workpsaceCustomApplication.id, ); }); + + it('should delete workspace and related metadata entities when last user is deleted', async () => { + const { data } = await signUp({ + input: { + email: `test-delete-5678@example.com`, + password: 'Test123!@#', + }, + expectToFail: false, + }); + + createdUserAccessToken = + data.signUp.tokens.accessOrWorkspaceAgnosticToken.token; + + const { + data: { signUpInNewWorkspace: signUpInNewWorkspaceData }, + } = await signUpInNewWorkspace({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + const workspaceId = signUpInNewWorkspaceData.workspace.id; + + const { + data: { getAuthTokensFromLoginToken: authTokensData }, + } = await getAuthTokensFromLoginToken({ + origin: signUpInNewWorkspaceData.workspace.workspaceUrls.subdomainUrl, + loginToken: signUpInNewWorkspaceData.loginToken.token, + expectToFail: false, + }); + + const newWorkspaceAccessToken = + authTokensData.tokens.accessOrWorkspaceAgnosticToken.token; + + await activateWorkspace({ + accessToken: newWorkspaceAccessToken, + displayName: 'Test Workspace for Deletion', + expectToFail: false, + }); + + await createOneObjectMetadata({ + input: { + nameSingular: 'workspaceEviction', + namePlural: 'workspaceEvictions', + labelPlural: 'whatevers', + labelSingular: 'whatever', + isLabelSyncedWithName: false, + }, + token: newWorkspaceAccessToken, + expectToFail: false, + }); + + const workspaceBeforeDeletion = await testDataSource.query( + 'SELECT * FROM core.workspace WHERE id = $1', + [workspaceId], + ); + + expect(workspaceBeforeDeletion).toHaveLength(1); + + const tablesToVerify = [ + 'dataSource', + 'objectMetadata', + 'fieldMetadata', + 'indexMetadata', + 'searchFieldMetadata', + 'workspaceMigration', + 'role', + 'roleTargets', + 'objectPermission', + 'fieldPermission', + 'permissionFlag', + 'serverlessFunction', + 'serverlessFunctionLayer', + 'agent', + 'agentHandoff', + 'remoteServer', + 'remoteTable', + 'databaseEventTrigger', + 'view', + 'viewField', + 'viewFilter', + 'viewFilterGroup', + 'viewGroup', + 'viewSort', + 'cronTrigger', + 'routeTrigger', + ]; + + let totalRecordsBefore = 0; + + for (const table of tablesToVerify) { + const result = await testDataSource.query( + `SELECT COUNT(*) as count FROM core."${table}" WHERE "workspaceId" = $1`, + [workspaceId], + ); + + totalRecordsBefore += parseInt(result[0].count); + } + + expect(totalRecordsBefore).toBeGreaterThan(0); + + await deleteUser({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + createdUserAccessToken = undefined; + + const workspaceAfterDeletion = await testDataSource.query( + 'SELECT * FROM core.workspace WHERE id = $1', + [workspaceId], + ); + + expect(workspaceAfterDeletion).toHaveLength(0); + + for (const table of tablesToVerify) { + const result = await testDataSource.query( + `SELECT COUNT(*) as count FROM core."${table}" WHERE "workspaceId" = $1`, + [workspaceId], + ); + const count = parseInt(result[0].count); + + expect({ count, table }).toEqual({ count: 0, table }); + } + }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util.ts index e13f915fed..aa827c42e7 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util.ts @@ -14,6 +14,7 @@ export const createOneObjectMetadata = async ({ input, gqlFields, expectToFail = false, + token, }: PerformMetadataQueryParams): CommonResponseBody<{ createOneObject: ObjectMetadataDTO; }> => { @@ -22,7 +23,7 @@ export const createOneObjectMetadata = async ({ gqlFields, }); - const response = await makeMetadataAPIRequest(graphqlOperation); + const response = await makeMetadataAPIRequest(graphqlOperation, token); if (expectToFail === true) { warnIfNoErrorButExpectedToFail({ diff --git a/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request.util.ts b/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request.util.ts index 0380f55987..b60d8d5ba7 100644 --- a/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request.util.ts @@ -6,12 +6,15 @@ type GraphqlOperation = { variables?: Record; }; -export const makeMetadataAPIRequest = (graphqlOperation: GraphqlOperation) => { +export const makeMetadataAPIRequest = ( + graphqlOperation: GraphqlOperation, + token: string = APPLE_JANE_ADMIN_ACCESS_TOKEN, +) => { const client = request(`http://localhost:${APP_PORT}`); return client .post('/metadata') - .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .set('Authorization', `Bearer ${token}`) .send({ query: print(graphqlOperation.query), variables: graphqlOperation.variables || {}, diff --git a/packages/twenty-server/test/integration/metadata/types/perform-metadata-query.type.ts b/packages/twenty-server/test/integration/metadata/types/perform-metadata-query.type.ts index db0ba48267..4a1ad49c96 100644 --- a/packages/twenty-server/test/integration/metadata/types/perform-metadata-query.type.ts +++ b/packages/twenty-server/test/integration/metadata/types/perform-metadata-query.type.ts @@ -2,4 +2,5 @@ export type PerformMetadataQueryParams = { input: T; gqlFields?: string; expectToFail?: boolean | null; + token?: string; };