Fix pre post logic function not executed (#19462)

- removes pre-install function 
- execute **asyncrhonously** post-install function at application
installation
- add optional `shouldRunOnVersionUpgrade` boolean value on post-install
function definition default false
- update PostInstallPayload to 
```
export type PostInstallPayload = {
  previousVersion?: string;
  newVersion: string;
};
```

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
martmull
2026-04-09 22:22:41 +02:00
committed by GitHub
parent 7a317b9182
commit d2f51cc939
33 changed files with 805 additions and 213 deletions
@@ -33,6 +33,8 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
throw new UserInputError(exception);
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
case ApplicationExceptionCode.POST_INSTALL_ERROR:
case ApplicationExceptionCode.PRE_INSTALL_ERROR:
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
case ApplicationExceptionCode.UPGRADE_FAILED:
throw new InternalServerError(exception);
@@ -10,8 +10,10 @@ import { ApplicationPackageModule } from 'src/engine/core-modules/application/ap
import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logic-function.module';
import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
@@ -21,9 +23,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
ApplicationPackageModule,
CacheLockModule,
FeatureFlagModule,
LogicFunctionModule,
SdkClientModule,
PermissionsModule,
FileStorageModule,
WorkspaceCacheModule,
],
providers: [ApplicationInstallResolver, ApplicationInstallService],
exports: [ApplicationInstallService],
@@ -25,7 +25,17 @@ import {
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import {
LogicFunctionTriggerJob,
type LogicFunctionTriggerJobData,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
@Injectable()
export class ApplicationInstallService {
private readonly logger = new Logger(ApplicationInstallService.name);
@@ -37,8 +47,12 @@ export class ApplicationInstallService {
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly fileStorageService: FileStorageService,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly cacheLockService: CacheLockService,
private readonly sdkClientGenerationService: SdkClientGenerationService,
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
private readonly messageQueueService: MessageQueueService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async installApplication(params: {
@@ -110,7 +124,27 @@ export class ApplicationInstallService {
const universalIdentifier = appRegistration.universalIdentifier;
const existingApplication =
await this.applicationService.findByUniversalIdentifier({
universalIdentifier,
workspaceId: params.workspaceId,
});
const previousVersion = existingApplication?.version ?? undefined;
const newVersion = resolvedPackage.packageJson.version;
if (!isDefined(newVersion)) {
throw new ApplicationException(
`Package ${universalIdentifier} has no version`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
const isVersionUpgrade = isDefined(existingApplication);
const { application, wasCreated } = await this.ensureApplicationExists({
existingApplication,
universalIdentifier,
name: resolvedPackage.manifest.application.displayName,
workspaceId: params.workspaceId,
@@ -156,6 +190,16 @@ export class ApplicationInstallService {
params.workspaceId,
);
await this.runPreInstallHook({
manifest: resolvedPackage.manifest,
workspaceId: params.workspaceId,
applicationRegistrationId: appRegistration.id,
previousVersion,
newVersion,
isVersionUpgrade,
universalIdentifier,
});
const { hasSchemaMetadataChanged } =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId: params.workspaceId,
@@ -171,6 +215,15 @@ export class ApplicationInstallService {
});
}
await this.runPostInstallHook({
manifest: resolvedPackage.manifest,
workspaceId: params.workspaceId,
previousVersion,
newVersion,
isVersionUpgrade,
universalIdentifier,
});
this.logger.log(
`Successfully installed app ${universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
);
@@ -191,6 +244,185 @@ export class ApplicationInstallService {
}
}
private async runPreInstallHook(params: {
manifest: Manifest;
workspaceId: string;
applicationRegistrationId?: string;
previousVersion?: string;
newVersion: string;
isVersionUpgrade: boolean;
universalIdentifier: string;
}): Promise<void> {
const {
manifest,
workspaceId,
applicationRegistrationId,
previousVersion,
newVersion,
isVersionUpgrade,
universalIdentifier,
} = params;
if (!isDefined(manifest.application.preInstallLogicFunction)) {
return;
}
await this.applicationSyncService.preInstallSynchronizeFromManifest({
workspaceId: params.workspaceId,
manifest,
applicationRegistrationId,
});
const {
universalIdentifier: preInstallLogicFunctionUniversalIdentifier,
shouldRunOnVersionUpgrade,
} = manifest.application.preInstallLogicFunction;
if (isVersionUpgrade && !shouldRunOnVersionUpgrade) {
this.logger.log(
`Skipping pre-install hook for app ${universalIdentifier}: version upgrade and shouldRunOnVersionUpgrade is false`,
);
return;
}
const { flatLogicFunctionMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatLogicFunctionMaps',
]);
const flatLogicFunction =
flatLogicFunctionMaps.byUniversalIdentifier[
preInstallLogicFunctionUniversalIdentifier
];
// preInstallSynchronizeFromManifest should have registered this function
// moments ago — a miss here means the pared-down sync did not persist the
// entry, which is a real failure and should abort the install.
if (!isDefined(flatLogicFunction)) {
throw new ApplicationException(
`Pre-install logic function "${preInstallLogicFunctionUniversalIdentifier}" not found for application "${universalIdentifier}" after pre-install sync. The pared-down sync did not register the function as expected.`,
ApplicationExceptionCode.ENTITY_NOT_FOUND,
);
}
const payload = { previousVersion, newVersion };
this.logger.log(
`Executing pre-install hook for app ${universalIdentifier} with payload:`,
JSON.stringify(payload),
);
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: flatLogicFunction.id,
workspaceId,
payload,
});
if (!isDefined(result)) {
this.logger.log('Pre-install hook executed successfully');
}
if (result.error) {
throw new ApplicationException(
result.error.errorMessage,
ApplicationExceptionCode.PRE_INSTALL_ERROR,
);
}
}
private async runPostInstallHook(params: {
manifest: Manifest;
workspaceId: string;
previousVersion?: string;
newVersion: string;
isVersionUpgrade: boolean;
universalIdentifier: string;
}): Promise<void> {
const {
manifest,
workspaceId,
previousVersion,
newVersion,
isVersionUpgrade,
universalIdentifier,
} = params;
if (!isDefined(manifest.application.postInstallLogicFunction)) {
return;
}
const {
universalIdentifier: postInstallLogicFunctionUniversalIdentifier,
shouldRunOnVersionUpgrade,
shouldRunSynchronously,
} = manifest.application.postInstallLogicFunction;
if (isVersionUpgrade && !shouldRunOnVersionUpgrade) {
this.logger.log(
`Skipping post-install hook for app ${universalIdentifier}: version upgrade and shouldRunOnVersionUpgrade is false`,
);
return;
}
const { flatLogicFunctionMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatLogicFunctionMaps',
]);
const flatLogicFunction =
flatLogicFunctionMaps.byUniversalIdentifier[
postInstallLogicFunctionUniversalIdentifier
];
if (!isDefined(flatLogicFunction)) {
throw new ApplicationException(
`Post-install logic function "${postInstallLogicFunctionUniversalIdentifier}" not found for application "${universalIdentifier}" after sync. Manifest may reference a stale identifier.`,
ApplicationExceptionCode.ENTITY_NOT_FOUND,
);
}
const payload = { previousVersion, newVersion };
this.logger.log(
`Enqueuing post-install hook for app ${universalIdentifier} with payload:`,
JSON.stringify(payload),
);
if (!shouldRunSynchronously) {
await this.messageQueueService.add<LogicFunctionTriggerJobData[]>(
LogicFunctionTriggerJob.name,
[
{
logicFunctionId: flatLogicFunction.id,
workspaceId,
payload,
},
],
{ retryLimit: 3 },
);
return;
}
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: flatLogicFunction.id,
workspaceId,
payload,
});
if (!isDefined(result)) {
this.logger.log('Post-install hook executed successfully');
}
if (result.error) {
throw new ApplicationException(
result.error.errorMessage,
ApplicationExceptionCode.POST_INSTALL_ERROR,
);
}
}
private async writeFilesToStorage(
extractedDir: string,
manifest: Manifest,
@@ -269,19 +501,15 @@ export class ApplicationInstallService {
}
private async ensureApplicationExists(params: {
existingApplication: ApplicationEntity | null;
universalIdentifier: string;
name: string;
workspaceId: string;
applicationRegistrationId: string;
sourceType: ApplicationRegistrationSourceType;
}): Promise<{ application: ApplicationEntity; wasCreated: boolean }> {
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier: params.universalIdentifier,
workspaceId: params.workspaceId,
});
if (isDefined(existing)) {
return { application: existing, wasCreated: false };
if (isDefined(params.existingApplication)) {
return { application: params.existingApplication, wasCreated: false };
}
const application = await this.applicationService.create({
@@ -4,15 +4,15 @@ import { type Manifest } from 'twenty-shared/application';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@@ -33,6 +33,149 @@ export class ApplicationManifestMigrationService {
private readonly applicationService: ApplicationService,
) {}
async syncPreInstallLogicFunctionFromManifest({
manifest,
workspaceId,
ownerFlatApplication,
}: {
manifest: Manifest;
workspaceId: string;
ownerFlatApplication: FlatApplication;
}): Promise<void> {
const preInstallLogicFunction =
manifest.application.preInstallLogicFunction;
if (!isDefined(preInstallLogicFunction)) {
return;
}
const preInstallLogicFunctionManifest = manifest.logicFunctions.find(
(logicFunction) =>
logicFunction.universalIdentifier ===
preInstallLogicFunction.universalIdentifier,
);
if (!isDefined(preInstallLogicFunctionManifest)) {
throw new ApplicationException(
`Pre-install logic function "${preInstallLogicFunction.universalIdentifier}" is declared on the application manifest but not present in manifest.logicFunctions`,
ApplicationExceptionCode.ENTITY_NOT_FOUND,
);
}
const defaultRoleManifest = manifest.roles.find(
(role) =>
role.universalIdentifier ===
manifest.application.defaultRoleUniversalIdentifier,
);
// Pared-down manifest: only the pre-install logic function, every other
// entity array intentionally empty. Combined with
// inferDeletionFromMissingEntities: false below, this produces a purely
// additive migration that registers the pre-install logic function without
// touching any previously-synced metadata (important on upgrades).
const strippedDefaultRoleManifest = isDefined(defaultRoleManifest)
? {
...defaultRoleManifest,
objectPermissions: [],
fieldPermissions: [],
}
: undefined;
const preInstallOnlyManifest: Manifest = {
application: manifest.application,
objects: [],
fields: [],
logicFunctions: [preInstallLogicFunctionManifest],
frontComponents: [],
roles: isDefined(strippedDefaultRoleManifest)
? [strippedDefaultRoleManifest]
: [],
skills: [],
agents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
};
const now = new Date().toISOString();
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const cacheResult = await this.workspaceCacheService.getOrRecompute(
workspaceId,
[
...Object.values(ALL_METADATA_NAME).map(getMetadataFlatEntityMapsKey),
'featureFlagsMap',
],
);
const { featureFlagsMap, ...existingAllFlatEntityMaps } = cacheResult;
const fromAllFlatEntityMaps = getApplicationSubAllFlatEntityMaps({
applicationIds: [ownerFlatApplication.id],
fromAllFlatEntityMaps: existingAllFlatEntityMaps,
});
const toAllUniversalFlatEntityMaps =
computeApplicationManifestAllUniversalFlatEntityMaps({
manifest: preInstallOnlyManifest,
ownerFlatApplication,
now,
});
const dependencyAllFlatEntityMaps = getApplicationSubAllFlatEntityMaps({
applicationIds:
ownerFlatApplication.universalIdentifier ===
TWENTY_STANDARD_APPLICATION.universalIdentifier
? [twentyStandardFlatApplication.id]
: [ownerFlatApplication.id, twentyStandardFlatApplication.id],
fromAllFlatEntityMaps: existingAllFlatEntityMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigrationFromTo(
{
// inferDeletionFromMissingEntities is intentionally omitted (undefined)
// so this pared-down sync is purely additive — existing metadata for
// objects/fields/other logic functions that are absent from
// preInstallOnlyManifest are left untouched on upgrades.
buildOptions: {
isSystemBuild: false,
applicationUniversalIdentifier:
ownerFlatApplication.universalIdentifier,
},
fromToAllFlatEntityMaps: buildFromToAllUniversalFlatEntityMaps({
fromAllFlatEntityMaps,
toAllUniversalFlatEntityMaps,
}),
workspaceId,
dependencyAllFlatEntityMaps,
additionalCacheDataMaps: { featureFlagsMap },
},
);
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Validation errors occurred while syncing pre-install logic function',
);
}
await this.syncDefaultRoleAndSettingsCustomTab({
manifest,
workspaceId,
ownerFlatApplication,
});
this.logger.log(
`Pre-install logic function synced for application ${ownerFlatApplication.universalIdentifier}`,
);
}
async syncMetadataFromManifest({
manifest,
workspaceId,
@@ -71,6 +71,42 @@ export class ApplicationSyncService {
return syncResult;
}
// Registers the application + only the pre-install logic function in
// workspace metadata so the pre-install hook can resolve and execute it
// before the main synchronizeFromManifest runs the full migrations.
// No-op when the manifest does not declare a pre-install logic function.
public async preInstallSynchronizeFromManifest({
workspaceId,
manifest,
applicationRegistrationId,
}: {
workspaceId: string;
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<void> {
if (!isDefined(manifest.application.preInstallLogicFunction)) {
return;
}
const application = await this.syncApplication({
workspaceId,
manifest,
applicationRegistrationId,
});
const ownerFlatApplication: FlatApplication = application;
await this.applicationManifestMigrationService.syncPreInstallLogicFunctionFromManifest(
{
manifest,
workspaceId,
ownerFlatApplication,
},
);
this.logger.log('Pre-install sync from manifest completed');
}
private async syncApplication({
workspaceId,
manifest,
@@ -18,6 +18,8 @@ export enum ApplicationExceptionCode {
PACKAGE_RESOLUTION_FAILED = 'PACKAGE_RESOLUTION_FAILED',
TARBALL_EXTRACTION_FAILED = 'TARBALL_EXTRACTION_FAILED',
UPGRADE_FAILED = 'UPGRADE_FAILED',
PRE_INSTALL_ERROR = 'PRE_INSTALL_ERROR',
POST_INSTALL_ERROR = 'POST_INSTALL_ERROR',
APP_ALREADY_INSTALLED = 'APP_ALREADY_INSTALLED',
CANNOT_DOWNGRADE_APPLICATION = 'CANNOT_DOWNGRADE_APPLICATION',
}
@@ -52,6 +54,10 @@ const getApplicationExceptionUserFriendlyMessage = (
return msg`Failed to extract tarball.`;
case ApplicationExceptionCode.UPGRADE_FAILED:
return msg`Application upgrade failed.`;
case ApplicationExceptionCode.PRE_INSTALL_ERROR:
return msg`Application pre-install logic function failed.`;
case ApplicationExceptionCode.POST_INSTALL_ERROR:
return msg`Application post-install logic function failed.`;
case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
return msg`This version of the application is already installed in this workspace.`;
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION: