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
This commit is contained in:
@@ -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<WorkspaceEntity> {
|
||||
private readonly featureLookUpKey = BillingEntitlementKey.CUSTOM_DOMAIN;
|
||||
protected readonly logger = new Logger(WorkspaceService.name);
|
||||
|
||||
private readonly WORKSPACE_FIELD_PERMISSIONS: Record<
|
||||
|
||||
+132
-28
@@ -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<ObjectMetadataDTO> {
|
||||
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<ObjectMetadataDTO[]> {
|
||||
if (deleteObjectInputs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatIndexMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: [
|
||||
'flatObjectMetadataMaps',
|
||||
'flatIndexMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const initialAccumulator: {
|
||||
flatFieldMetadatasToDeleteById: Record<string, FlatFieldMetadata>;
|
||||
flatObjectMetadatasToDeleteById: Record<string, FlatObjectMetadata>;
|
||||
flatIndexToDeleteById: Record<string, FlatIndexMetadata>;
|
||||
} = {
|
||||
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<DeleteOneObjectInput>((flatObjectMetadata) => ({
|
||||
id: flatObjectMetadata.id,
|
||||
}));
|
||||
|
||||
await this.deleteManyObjectMetadatas({
|
||||
deleteObjectInputs,
|
||||
workspaceId,
|
||||
isSystemBuild: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+4
-1
@@ -33,20 +33,23 @@ export class WorkspaceSchemaColumnManagerService {
|
||||
schemaName,
|
||||
tableName,
|
||||
columnNames,
|
||||
cascade = false,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
columnNames: string[];
|
||||
cascade?: boolean;
|
||||
}): Promise<void> {
|
||||
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(', ')}`;
|
||||
|
||||
|
||||
+4
-1
@@ -39,14 +39,17 @@ export class WorkspaceSchemaTableManagerService {
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
cascade = false,
|
||||
}: {
|
||||
queryRunner: QueryRunner;
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
cascade?: boolean;
|
||||
}): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
+13
-7
@@ -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<WorkspaceMigrationEntity>;
|
||||
let dataSourceRepository: Repository<DataSourceEntity>;
|
||||
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>(WorkspaceManagerService);
|
||||
objectMetadataService = module.get<ObjectMetadataService>(
|
||||
ObjectMetadataService,
|
||||
objectMetadataServiceV2 = module.get<ObjectMetadataServiceV2>(
|
||||
ObjectMetadataServiceV2,
|
||||
);
|
||||
workspaceMigrationRepository = module.get<
|
||||
Repository<WorkspaceMigrationEntity>
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
//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(
|
||||
|
||||
+1
@@ -103,6 +103,7 @@ export class DeleteFieldActionHandlerService extends WorkspaceMigrationRunnerAct
|
||||
schemaName,
|
||||
tableName,
|
||||
columnNames: columnNamesToDrop,
|
||||
cascade: true,
|
||||
});
|
||||
|
||||
const enumOperations = collectEnumOperationsForField({
|
||||
|
||||
+1
@@ -89,6 +89,7 @@ export class DeleteObjectActionHandlerService extends WorkspaceMigrationRunnerAc
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
cascade: true,
|
||||
});
|
||||
const objectFlatFieldMetadatas =
|
||||
findManyFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
|
||||
+19
-14
@@ -73,21 +73,26 @@ export abstract class BaseWorkspaceMigrationRunnerActionHandlerService<
|
||||
| MetadataToFlatEntityMapsKey<TMetadataName>
|
||||
>
|
||||
> {
|
||||
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(
|
||||
|
||||
+125
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ export const createOneObjectMetadata = async ({
|
||||
input,
|
||||
gqlFields,
|
||||
expectToFail = false,
|
||||
token,
|
||||
}: PerformMetadataQueryParams<CreateOneObjectFactoryInput>): 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({
|
||||
|
||||
+5
-2
@@ -6,12 +6,15 @@ type GraphqlOperation = {
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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 || {},
|
||||
|
||||
@@ -2,4 +2,5 @@ export type PerformMetadataQueryParams<T> = {
|
||||
input: T;
|
||||
gqlFields?: string;
|
||||
expectToFail?: boolean | null;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user