diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.resolver.ts index 5575d88bf5..b771387128 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.resolver.ts @@ -19,6 +19,7 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { enrichCreateWorkspaceMigrationActionsWithIds } from 'src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util'; import { AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service'; @@ -48,12 +49,17 @@ export class ApplicationManifestResolver { }, ); - await this.workspaceMigrationRunnerService.run({ + const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({ workspaceMigration: { actions: actions as AllUniversalWorkspaceMigrationAction[], applicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, }, + idByUniversalIdentifierByMetadataName: {}, + }); + + await this.workspaceMigrationRunnerService.run({ + workspaceMigration, workspaceId, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts index 38b5c8d573..594d3b0378 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts @@ -14,6 +14,7 @@ import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/wo type ValidateJunctionTargetSettingsArgs = { universalFlatFieldMetadata: UniversalFlatFieldMetadata; flatFieldMetadataMaps: UniversalFlatEntityMaps; + remainingFlatFieldMetadataMaps?: UniversalFlatEntityMaps; }; const createError = ( @@ -31,6 +32,7 @@ const createError = ( export const validateJunctionTargetSettings = ({ universalFlatFieldMetadata, flatFieldMetadataMaps, + remainingFlatFieldMetadataMaps, }: ValidateJunctionTargetSettingsArgs): FlatFieldMetadataValidationError[] => { const { universalSettings } = universalFlatFieldMetadata; @@ -58,10 +60,17 @@ export const validateJunctionTargetSettings = ({ ]; } - const targetField = findFlatEntityByUniversalIdentifier({ - universalIdentifier: junctionTargetFieldUniversalIdentifier, - flatEntityMaps: flatFieldMetadataMaps, - }); + const targetField = + (isDefined(remainingFlatFieldMetadataMaps) + ? findFlatEntityByUniversalIdentifier({ + universalIdentifier: junctionTargetFieldUniversalIdentifier, + flatEntityMaps: remainingFlatFieldMetadataMaps, + }) + : undefined) ?? + findFlatEntityByUniversalIdentifier({ + universalIdentifier: junctionTargetFieldUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }); if (!isDefined(targetField)) { return [ diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util.ts index 1ab8de32bd..dd0e8e0826 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util.ts @@ -243,6 +243,7 @@ export const validateMorphOrRelationFlatFieldMetadata = ({ ...validateJunctionTargetSettings({ universalFlatFieldMetadata: universalFlatFieldMetadataToValidate, flatFieldMetadataMaps, + remainingFlatFieldMetadataMaps: remainingFlatEntityMapsToValidate, }), ); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/__tests__/enrich-create-workspace-migration-action-with-ids.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/__tests__/enrich-create-workspace-migration-action-with-ids.util.spec.ts new file mode 100644 index 0000000000..6c0efd2408 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/__tests__/enrich-create-workspace-migration-action-with-ids.util.spec.ts @@ -0,0 +1,133 @@ +import { enrichCreateWorkspaceMigrationActionsWithIds } from 'src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util'; +import { type UniversalCreateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action'; +import { type WorkspaceMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration.type'; + +const buildCreateFieldAction = ({ + fieldUniversalIdentifier, + relatedFieldUniversalIdentifier, + junctionTargetFieldUniversalIdentifier, +}: { + fieldUniversalIdentifier: string; + relatedFieldUniversalIdentifier?: string; + junctionTargetFieldUniversalIdentifier?: string; +}): UniversalCreateFieldAction => + ({ + type: 'create', + metadataName: 'fieldMetadata', + flatEntity: { + universalIdentifier: fieldUniversalIdentifier, + universalSettings: junctionTargetFieldUniversalIdentifier + ? { junctionTargetFieldUniversalIdentifier } + : undefined, + }, + relatedUniversalFlatFieldMetadata: relatedFieldUniversalIdentifier + ? { universalIdentifier: relatedFieldUniversalIdentifier } + : undefined, + }) as unknown as UniversalCreateFieldAction; + +const buildWorkspaceMigration = ( + actions: UniversalCreateFieldAction[], +): WorkspaceMigration => + ({ + applicationUniversalIdentifier: 'app', + actions, + }) as unknown as WorkspaceMigration; + +describe('enrichCreateWorkspaceMigrationActionsWithIds', () => { + it('should assign an id to a create field action that has none', () => { + const action = buildCreateFieldAction({ fieldUniversalIdentifier: 'a' }); + + const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({ + workspaceMigration: buildWorkspaceMigration([action]), + idByUniversalIdentifierByMetadataName: {}, + }); + + const [enrichedAction] = + workspaceMigration.actions as UniversalCreateFieldAction[]; + + expect(enrichedAction.id).toEqual(expect.any(String)); + }); + + it('should stamp a field id and a related field id consistently', () => { + const action = buildCreateFieldAction({ + fieldUniversalIdentifier: 'source', + relatedFieldUniversalIdentifier: 'target', + }); + + const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({ + workspaceMigration: buildWorkspaceMigration([action]), + idByUniversalIdentifierByMetadataName: {}, + }); + + const [enrichedAction] = + workspaceMigration.actions as UniversalCreateFieldAction[]; + + expect(enrichedAction.id).toEqual(expect.any(String)); + expect(enrichedAction.relatedFieldId).toEqual(expect.any(String)); + expect(enrichedAction.id).not.toBe(enrichedAction.relatedFieldId); + }); + + it('should resolve a junction target field id to the same id the target action is created with', () => { + const targetAction = buildCreateFieldAction({ + fieldUniversalIdentifier: 'target', + }); + const junctionAction = buildCreateFieldAction({ + fieldUniversalIdentifier: 'junction', + junctionTargetFieldUniversalIdentifier: 'target', + }); + + const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({ + workspaceMigration: buildWorkspaceMigration([ + junctionAction, + targetAction, + ]), + idByUniversalIdentifierByMetadataName: {}, + }); + + const [enrichedJunctionAction, enrichedTargetAction] = + workspaceMigration.actions as UniversalCreateFieldAction[]; + + expect(enrichedJunctionAction.fieldIdByUniversalIdentifier?.target).toBe( + enrichedTargetAction.id, + ); + }); + + it('should set relatedFieldId to the id already minted for the target by an earlier action', () => { + const targetAction = buildCreateFieldAction({ + fieldUniversalIdentifier: 'target', + }); + const junctionAction = buildCreateFieldAction({ + fieldUniversalIdentifier: 'junction', + relatedFieldUniversalIdentifier: 'target', + }); + + const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({ + workspaceMigration: buildWorkspaceMigration([ + targetAction, + junctionAction, + ]), + idByUniversalIdentifierByMetadataName: {}, + }); + + const [enrichedTargetAction, enrichedJunctionAction] = + workspaceMigration.actions as UniversalCreateFieldAction[]; + + expect(enrichedJunctionAction.relatedFieldId).toBe(enrichedTargetAction.id); + }); + + it('should use the provided external id over a generated one', () => { + const action = buildCreateFieldAction({ fieldUniversalIdentifier: 'a' }); + + const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({ + workspaceMigration: buildWorkspaceMigration([action]), + idByUniversalIdentifierByMetadataName: { + fieldMetadata: { a: 'external-id' }, + }, + }); + + const [enrichedAction] = + workspaceMigration.actions as UniversalCreateFieldAction[]; + + expect(enrichedAction.id).toBe('external-id'); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts index 591038de9e..535b6c699b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts @@ -1,6 +1,8 @@ import { type AllMetadataName } from 'twenty-shared/metadata'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; +import { type UniversalCreateFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action'; import { type UniversalCreateObjectAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/types/workspace-migration-object-action'; import { type UniversalCreatePageLayoutAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/page-layout/types/workspace-migration-page-layout-action.type'; import { type WorkspaceMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration.type'; @@ -59,6 +61,116 @@ const buildTabIdByUniversalIdentifier = ({ return tabIdByUniversalIdentifier; }; +const buildFieldIdByUniversalIdentifierForFieldActions = ({ + actions, + providedFieldIdByUniversalIdentifier, +}: { + actions: WorkspaceMigration['actions']; + providedFieldIdByUniversalIdentifier?: Record; +}): Record | undefined => { + const fieldIdByUniversalIdentifier = { + ...providedFieldIdByUniversalIdentifier, + }; + + const setFieldIdIfMissing = ({ + universalIdentifier, + fallbackId, + }: { + universalIdentifier: string; + fallbackId?: string; + }) => { + if (isDefined(fieldIdByUniversalIdentifier[universalIdentifier])) { + return; + } + + fieldIdByUniversalIdentifier[universalIdentifier] = fallbackId ?? v4(); + }; + + for (const action of actions) { + if (action.type !== 'create' || action.metadataName !== 'fieldMetadata') { + continue; + } + + setFieldIdIfMissing({ + universalIdentifier: action.flatEntity.universalIdentifier, + fallbackId: action.id, + }); + + if (isDefined(action.relatedUniversalFlatFieldMetadata)) { + setFieldIdIfMissing({ + universalIdentifier: + action.relatedUniversalFlatFieldMetadata.universalIdentifier, + fallbackId: action.relatedFieldId, + }); + } + } + + if (Object.keys(fieldIdByUniversalIdentifier).length === 0) { + return undefined; + } + + return fieldIdByUniversalIdentifier; +}; + +const getJunctionTargetFieldUniversalIdentifier = ( + universalSettings: UniversalCreateFieldAction['flatEntity']['universalSettings'], +): string | null | undefined => { + if ( + !isDefined(universalSettings) || + !('junctionTargetFieldUniversalIdentifier' in universalSettings) + ) { + return undefined; + } + + return universalSettings.junctionTargetFieldUniversalIdentifier; +}; + +const buildReferencedFieldIdByUniversalIdentifierForFieldAction = ({ + action, + fieldIdByUniversalIdentifier, +}: { + action: UniversalCreateFieldAction; + fieldIdByUniversalIdentifier?: Record; +}): Record | undefined => { + if (!isDefined(fieldIdByUniversalIdentifier)) { + return undefined; + } + + const referencedFieldIdByUniversalIdentifier: Record = {}; + + const addReference = (universalIdentifier: string | null | undefined) => { + if ( + !isDefined(universalIdentifier) || + !isDefined(fieldIdByUniversalIdentifier[universalIdentifier]) + ) { + return; + } + + referencedFieldIdByUniversalIdentifier[universalIdentifier] = + fieldIdByUniversalIdentifier[universalIdentifier]; + }; + + for (const universalFlatFieldMetadata of [ + action.flatEntity, + action.relatedUniversalFlatFieldMetadata, + ].filter(isDefined)) { + addReference( + universalFlatFieldMetadata.relationTargetFieldMetadataUniversalIdentifier, + ); + addReference( + getJunctionTargetFieldUniversalIdentifier( + universalFlatFieldMetadata.universalSettings, + ), + ); + } + + if (Object.keys(referencedFieldIdByUniversalIdentifier).length === 0) { + return undefined; + } + + return referencedFieldIdByUniversalIdentifier; +}; + export const enrichCreateWorkspaceMigrationActionsWithIds = ({ workspaceMigration, idByUniversalIdentifierByMetadataName, @@ -71,6 +183,13 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({ const pageLayoutTabIdByUniversalIdentifier = idByUniversalIdentifierByMetadataName.pageLayoutTab; + const fieldIdByUniversalIdentifier = + buildFieldIdByUniversalIdentifierForFieldActions({ + actions: workspaceMigration.actions, + providedFieldIdByUniversalIdentifier: + fieldMetadataIdByUniversalIdentifier, + }); + const enrichedActions = workspaceMigration.actions.map((action) => { if (action.type !== 'create') { return action; @@ -80,6 +199,7 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({ idByUniversalIdentifierByMetadataName[action.metadataName]; if ( + action.metadataName !== 'fieldMetadata' && !isDefined(idByUniversalIdentifier) && !isDefined(fieldMetadataIdByUniversalIdentifier) && !isDefined(pageLayoutTabIdByUniversalIdentifier) @@ -92,7 +212,7 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({ const id = isDefined(idByUniversalIdentifier) ? idByUniversalIdentifier[action.flatEntity.universalIdentifier] : undefined; - const fieldIdByUniversalIdentifier = isDefined( + const objectFieldIdByUniversalIdentifier = isDefined( fieldMetadataIdByUniversalIdentifier, ) ? buildFieldIdByUniversalIdentifierForObjectAction({ @@ -104,28 +224,39 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({ return { ...action, id, - fieldIdByUniversalIdentifier, + fieldIdByUniversalIdentifier: objectFieldIdByUniversalIdentifier, }; } case 'fieldMetadata': { - if (!isDefined(fieldMetadataIdByUniversalIdentifier)) { - return action; - } + const typedAction = action as UniversalCreateFieldAction; + const id = + fieldIdByUniversalIdentifier?.[ + typedAction.flatEntity.universalIdentifier + ]; - const relatedFieldId = isDefined( - action.relatedUniversalFlatFieldMetadata, - ) - ? fieldMetadataIdByUniversalIdentifier[ - action.relatedUniversalFlatFieldMetadata.universalIdentifier - ] - : undefined; + const relatedFieldId = + isDefined(typedAction.relatedUniversalFlatFieldMetadata) && + isDefined(fieldIdByUniversalIdentifier) + ? fieldIdByUniversalIdentifier[ + typedAction.relatedUniversalFlatFieldMetadata + .universalIdentifier + ] + : typedAction.relatedFieldId; + + const referencedFieldIdByUniversalIdentifier = + buildReferencedFieldIdByUniversalIdentifierForFieldAction({ + action: typedAction, + fieldIdByUniversalIdentifier, + }); return { - ...action, - id: fieldMetadataIdByUniversalIdentifier[ - action.flatEntity.universalIdentifier - ], + ...typedAction, + id, relatedFieldId, + ...(isDefined(referencedFieldIdByUniversalIdentifier) && { + fieldIdByUniversalIdentifier: + referencedFieldIdByUniversalIdentifier, + }), }; } case 'pageLayout': { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts index 88be686107..713822ad70 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service.ts @@ -386,12 +386,11 @@ export class WorkspaceMigrationValidateBuildAndRunService { return validateAndBuildResult; } - const workspaceMigration = isDefined(idByUniversalIdentifierByMetadataName) - ? enrichCreateWorkspaceMigrationActionsWithIds({ - idByUniversalIdentifierByMetadataName, - workspaceMigration: validateAndBuildResult.workspaceMigration, - }) - : validateAndBuildResult.workspaceMigration; + const workspaceMigration = enrichCreateWorkspaceMigrationActionsWithIds({ + idByUniversalIdentifierByMetadataName: + idByUniversalIdentifierByMetadataName ?? {}, + workspaceMigration: validateAndBuildResult.workspaceMigration, + }); if (workspaceMigration.actions.length === 0) { return { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action.ts index d42dcae8a4..2f50626a66 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action.ts @@ -18,6 +18,8 @@ export type UniversalCreateFieldAction = >; // Optional ID for the related field (for API metadata). relatedFieldId?: string; + // IDs for fields created in the same migration, keyed by universal identifier. + fieldIdByUniversalIdentifier?: Record; }; export type UniversalUpdateFieldAction = diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/create-field-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/create-field-action-handler.service.ts index e72c5a40b9..3e3d012dd8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/create-field-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/create-field-action-handler.service.ts @@ -52,19 +52,31 @@ export class CreateFieldActionHandlerService extends WorkspaceMigrationRunnerAct const allFieldIdToBeCreatedInActionByUniversalIdentifierMap = new Map< string, string - >(); + >(Object.entries(action.fieldIdByUniversalIdentifier ?? {})); - allFieldIdToBeCreatedInActionByUniversalIdentifierMap.set( - action.flatEntity.universalIdentifier, - action.id ?? v4(), - ); + if ( + !allFieldIdToBeCreatedInActionByUniversalIdentifierMap.has( + action.flatEntity.universalIdentifier, + ) + ) { + allFieldIdToBeCreatedInActionByUniversalIdentifierMap.set( + action.flatEntity.universalIdentifier, + action.id ?? v4(), + ); + } - if (isDefined(action.relatedUniversalFlatFieldMetadata)) { + if ( + isDefined(action.relatedUniversalFlatFieldMetadata) && + !allFieldIdToBeCreatedInActionByUniversalIdentifierMap.has( + action.relatedUniversalFlatFieldMetadata.universalIdentifier, + ) + ) { allFieldIdToBeCreatedInActionByUniversalIdentifierMap.set( action.relatedUniversalFlatFieldMetadata.universalIdentifier, action.relatedFieldId ?? v4(), ); } + const universalFlatFieldMetadatas = isDefined( action.relatedUniversalFlatFieldMetadata, ) 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 0b0f2c825e..45755ad97a 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 @@ -31,6 +31,7 @@ exports[`syncApplication should create a TEXT field on the standard Company obje "universalSettings": null, "updatedAt": Any, }, + "id": Any, "metadataName": "fieldMetadata", "type": "create", }, @@ -412,6 +413,7 @@ exports[`syncApplication should delete old field and create equivalent one when "universalSettings": null, "updatedAt": Any, }, + "id": Any, "metadataName": "fieldMetadata", "type": "create", },