diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts index 2dba95eac9..b8e0fd3fb7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts @@ -103,79 +103,7 @@ export abstract class WorkspaceEntityMigrationBuilderService< ); const allValidationResult: FailedFlatEntityValidateAndBuild['errors'] = []; - const remainingFlatEntityMapsToCreate = structuredClone( - createdFlatEntityMaps, - ); - this.logger.time( - `EntityBuilder ${this.metadataName}`, - 'creation validation', - ); - for (const flatEntityToCreateUniversalIdentifier in createdFlatEntityMaps.byUniversalIdentifier) { - const rawUniversalflatEntityToCreate = - findFlatEntityByUniversalIdentifierOrThrow({ - universalIdentifier: flatEntityToCreateUniversalIdentifier, - flatEntityMaps: createdFlatEntityMaps, - }); - - const universalFlatEntityToCreate = - resetUniversalFlatEntityForeignKeyAggregators({ - metadataName: this.metadataName, - universalFlatEntity: rawUniversalflatEntityToCreate, - }); - - const universalIdentifierToDelete = - universalFlatEntityToCreate.universalIdentifier; - - deleteUniversalFlatEntityFromUniversalFlatEntityMapsThroughMutationOrThrow( - { - universalIdentifierToDelete, - universalFlatEntityMapsToMutate: remainingFlatEntityMapsToCreate, - }, - ); - - const validationResult = await this.innerValidateFlatEntityCreation({ - additionalCacheDataMaps, - flatEntityToValidate: universalFlatEntityToCreate, - workspaceId, - optimisticFlatEntityMapsAndRelatedFlatEntityMaps, - remainingFlatEntityMapsToValidate: remainingFlatEntityMapsToCreate, - buildOptions, - }); - - if (validationResult.status === 'fail') { - allValidationResult.push(validationResult); - continue; - } - - addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow( - { - universalFlatEntity: universalFlatEntityToCreate, - universalFlatEntityAndRelatedMapsToMutate: - optimisticFlatEntityMapsAndRelatedFlatEntityMaps, - metadataName: this.metadataName, - }, - ); - - const formattedNewCreateAction: AllUniversalWorkspaceMigrationAction< - 'create', - typeof this.metadataName - > = { - ...validationResult.action, - flatEntity: deleteUniversalFlatEntityForeignKeyAggregators({ - metadataName: this.metadataName, - universalFlatEntity: validationResult.action - .flatEntity as MetadataFlatEntity, - }), - }; - - actionsResult.create.push(formattedNewCreateAction); - } - - this.logger.timeEnd( - `EntityBuilder ${this.metadataName}`, - 'creation validation', - ); this.logger.time( `EntityBuilder ${this.metadataName}`, 'deletion validation', @@ -310,6 +238,80 @@ export abstract class WorkspaceEntityMigrationBuilderService< `EntityBuilder ${this.metadataName}`, 'update validation', ); + + const remainingFlatEntityMapsToCreate = structuredClone( + createdFlatEntityMaps, + ); + + this.logger.time( + `EntityBuilder ${this.metadataName}`, + 'creation validation', + ); + for (const flatEntityToCreateUniversalIdentifier in createdFlatEntityMaps.byUniversalIdentifier) { + const rawUniversalflatEntityToCreate = + findFlatEntityByUniversalIdentifierOrThrow({ + universalIdentifier: flatEntityToCreateUniversalIdentifier, + flatEntityMaps: createdFlatEntityMaps, + }); + + const universalFlatEntityToCreate = + resetUniversalFlatEntityForeignKeyAggregators({ + metadataName: this.metadataName, + universalFlatEntity: rawUniversalflatEntityToCreate, + }); + + const universalIdentifierToDelete = + universalFlatEntityToCreate.universalIdentifier; + + deleteUniversalFlatEntityFromUniversalFlatEntityMapsThroughMutationOrThrow( + { + universalIdentifierToDelete, + universalFlatEntityMapsToMutate: remainingFlatEntityMapsToCreate, + }, + ); + + const validationResult = await this.innerValidateFlatEntityCreation({ + additionalCacheDataMaps, + flatEntityToValidate: universalFlatEntityToCreate, + workspaceId, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps, + remainingFlatEntityMapsToValidate: remainingFlatEntityMapsToCreate, + buildOptions, + }); + + if (validationResult.status === 'fail') { + allValidationResult.push(validationResult); + continue; + } + + addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow( + { + universalFlatEntity: universalFlatEntityToCreate, + universalFlatEntityAndRelatedMapsToMutate: + optimisticFlatEntityMapsAndRelatedFlatEntityMaps, + metadataName: this.metadataName, + }, + ); + + const formattedNewCreateAction: AllUniversalWorkspaceMigrationAction< + 'create', + typeof this.metadataName + > = { + ...validationResult.action, + flatEntity: deleteUniversalFlatEntityForeignKeyAggregators({ + metadataName: this.metadataName, + universalFlatEntity: validationResult.action + .flatEntity as MetadataFlatEntity, + }), + }; + + actionsResult.create.push(formattedNewCreateAction); + } + + this.logger.timeEnd( + `EntityBuilder ${this.metadataName}`, + 'creation validation', + ); this.logger.timeEnd( `EntityBuilder ${this.metadataName}`, 'entity processing', diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap index dff10e94de..97bc4f193a 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap @@ -63,6 +63,358 @@ exports[`syncApplication should create a TEXT field on the standard Company obje } `; +exports[`syncApplication should delete old field and create equivalent one when field universalIdentifier changes 1`] = ` +{ + "syncApplication": { + "actions": [ + { + "flatEntity": { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "description": "A support ticket", + "duplicateCriteria": null, + "icon": "IconTicket", + "imageIdentifierFieldMetadataUniversalIdentifier": null, + "isActive": true, + "isAuditLogged": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isRemote": false, + "isSearchable": false, + "isSystem": false, + "isUIReadOnly": false, + "labelIdentifierFieldMetadataUniversalIdentifier": Any, + "labelPlural": "Tickets", + "labelSingular": "Ticket", + "namePlural": "tickets", + "nameSingular": "ticket", + "shortcut": null, + "standardOverrides": null, + "targetTableName": "DEPRECATED", + "universalIdentifier": Any, + "updatedAt": Any, + }, + "metadataName": "objectMetadata", + "type": "create", + "universalFlatFieldMetadatas": [ + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": null, + "description": null, + "icon": null, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": true, + "isUIReadOnly": false, + "isUnique": false, + "label": "Id", + "morphId": null, + "name": "id", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "UUID", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": null, + "description": null, + "icon": null, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": true, + "isUIReadOnly": false, + "isUnique": false, + "label": "Creation date", + "morphId": null, + "name": "createdAt", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "DATE_TIME", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": null, + "description": null, + "icon": null, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": true, + "isUIReadOnly": false, + "isUnique": false, + "label": "Last update", + "morphId": null, + "name": "updatedAt", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "DATE_TIME", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": null, + "description": null, + "icon": null, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": true, + "isUIReadOnly": false, + "isUnique": false, + "label": "Deleted at", + "morphId": null, + "name": "deletedAt", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "DATE_TIME", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": { + "name": "'System'", + "source": "'MANUAL'", + "workspaceMemberId": null, + }, + "description": null, + "icon": null, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": true, + "isUIReadOnly": false, + "isUnique": false, + "label": "Created by", + "morphId": null, + "name": "createdBy", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "ACTOR", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": { + "name": "'System'", + "source": "'MANUAL'", + "workspaceMemberId": null, + }, + "description": null, + "icon": null, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": true, + "isUIReadOnly": false, + "isUnique": false, + "label": "Updated by", + "morphId": null, + "name": "updatedBy", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "ACTOR", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": null, + "description": null, + "icon": null, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": true, + "isUIReadOnly": false, + "isUnique": false, + "label": "Position", + "morphId": null, + "name": "position", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "POSITION", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": null, + "description": null, + "icon": null, + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": true, + "isUIReadOnly": false, + "isUnique": false, + "label": "Search vector", + "morphId": null, + "name": "searchVector", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "TS_VECTOR", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": null, + "description": "Ticket description", + "icon": "IconFileDescription", + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": false, + "isUIReadOnly": false, + "isUnique": false, + "label": "Description", + "morphId": null, + "name": "description", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "TEXT", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + ], + }, + { + "flatEntity": { + "applicationUniversalIdentifier": Any, + "canAccessAllTools": false, + "canBeAssignedToAgents": true, + "canBeAssignedToApiKeys": true, + "canBeAssignedToUsers": true, + "canDestroyAllObjectRecords": false, + "canReadAllObjectRecords": false, + "canSoftDeleteAllObjectRecords": false, + "canUpdateAllObjectRecords": false, + "canUpdateAllSettings": false, + "createdAt": Any, + "description": "A test role", + "icon": null, + "isEditable": true, + "label": "Test Role", + "universalIdentifier": Any, + "updatedAt": Any, + }, + "metadataName": "role", + "type": "create", + }, + ], + "applicationUniversalIdentifier": Any, + }, +} +`; + +exports[`syncApplication should delete old field and create equivalent one when field universalIdentifier changes 2`] = ` +{ + "syncApplication": { + "actions": [ + { + "metadataName": "fieldMetadata", + "type": "delete", + "universalIdentifier": Any, + }, + { + "flatEntity": { + "applicationUniversalIdentifier": Any, + "createdAt": Any, + "defaultValue": null, + "description": "Ticket description", + "icon": "IconFileDescription", + "isActive": true, + "isCustom": true, + "isLabelSyncedWithName": false, + "isNullable": true, + "isSystem": false, + "isUIReadOnly": false, + "isUnique": false, + "label": "Description", + "morphId": null, + "name": "description", + "objectMetadataUniversalIdentifier": Any, + "options": null, + "relationTargetFieldMetadataUniversalIdentifier": null, + "relationTargetObjectMetadataUniversalIdentifier": null, + "standardOverrides": null, + "type": "TEXT", + "universalIdentifier": Any, + "universalSettings": null, + "updatedAt": Any, + }, + "metadataName": "fieldMetadata", + "type": "create", + }, + ], + "applicationUniversalIdentifier": Any, + }, +} +`; + exports[`syncApplication should return workspace migration actions on initial sync then on second sync with field rename and new role 1`] = ` { "syncApplication": { diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.integration-spec.ts index 420ca2326e..3259187b36 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-workspace-migration.integration-spec.ts @@ -1,7 +1,9 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util'; import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util'; +import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util'; import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; import { findRoles } from 'test/integration/metadata/suites/role/utils/find-roles.util'; import { findSkills } from 'test/integration/metadata/suites/skill/utils/find-skills.util'; @@ -10,8 +12,6 @@ import { type FieldManifest, type Manifest } from 'twenty-shared/application'; import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { FieldMetadataType } from 'twenty-shared/types'; import { v4 as uuidv4 } from 'uuid'; -import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; -import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util'; const TEST_APP_ID = uuidv4(); const TEST_ROLE_ID = uuidv4(); @@ -177,6 +177,67 @@ describe('syncApplication', () => { }); }, 60000); + it('should delete old field and create equivalent one when field universalIdentifier changes', async () => { + const originalFieldId = uuidv4(); + const updatedFieldId = uuidv4(); + + const testObject = buildDefaultObjectManifest({ + nameSingular: 'ticket', + namePlural: 'tickets', + labelSingular: 'Ticket', + labelPlural: 'Tickets', + description: 'A support ticket', + icon: 'IconTicket', + }); + + const baseField: FieldManifest = { + universalIdentifier: originalFieldId, + type: FieldMetadataType.TEXT, + name: 'description', + label: 'Description', + description: 'Ticket description', + icon: 'IconFileDescription', + objectUniversalIdentifier: testObject.universalIdentifier, + }; + + const { data: firstSyncData } = await syncApplication({ + manifest: buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + objects: [testObject], + fields: [baseField], + }, + }), + expectToFail: false, + }); + + expect(firstSyncData).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny(firstSyncData), + ); + + const { data: secondSyncData } = await syncApplication({ + manifest: buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + objects: [testObject], + fields: [ + { + ...baseField, + universalIdentifier: updatedFieldId, + }, + ], + }, + }), + expectToFail: false, + }); + + expect(secondSyncData).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny(secondSyncData), + ); + }, 60000); + it('should create a TEXT field on the standard Company object', async () => { const companyFieldId = uuidv4();