Add auto-upgrade-in-apps (#23001)

We need to auto upgrade application, lets add this column in application
entity, and add an admin button to autoupgrade all applications to
latest app registrration version manually

<img width="1131" height="372" alt="image"
src="https://github.com/user-attachments/assets/4e755abc-38ad-4895-a2e8-d55ee1948ac2"
/>

<img width="906" height="533" alt="image"
src="https://github.com/user-attachments/assets/ce002057-0341-4581-bf9a-66ac2bd84a9b"
/>
This commit is contained in:
martmull
2026-07-20 16:12:44 +02:00
committed by GitHub
parent 5bf3472eb9
commit baa84bb2e0
36 changed files with 561 additions and 72 deletions
@@ -61,6 +61,10 @@ import {
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
type BackfillApplicationInstallationJobData,
} from 'src/engine/core-modules/application/jobs/backfill-application-installation.job-constants';
import {
UPGRADE_APPLICATIONS_JOB_NAME,
type UpgradeApplicationsJobData,
} from 'src/engine/core-modules/application/jobs/upgrade-applications.job-constants';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { AdminAiModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
@@ -545,6 +549,26 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async upgradeRegistrationApplications(
@Args('applicationRegistrationId') applicationRegistrationId: string,
): Promise<boolean> {
await this.applicationRegistrationService.findOneByIdGlobal(
applicationRegistrationId,
);
await this.workspaceQueueService.add<UpgradeApplicationsJobData>(
UPGRADE_APPLICATIONS_JOB_NAME,
{ applicationRegistrationId, onlyAutoUpgrade: false },
{
id: `${UPGRADE_APPLICATIONS_JOB_NAME}-${applicationRegistrationId}`,
}, // Avoids triggering multiple pending jobs for the same app
);
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => GraphQLJSON)
async getAiProviders(): Promise<Record<string, unknown>> {
@@ -7,6 +7,8 @@ import {
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
@@ -18,6 +20,7 @@ import { ApplicationException } from 'src/engine/core-modules/application/applic
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { UpdateApplicationInput } from 'src/engine/core-modules/application/dtos/update-application.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
@@ -128,6 +131,26 @@ export class ApplicationInstallResolver {
});
}
@Mutation(() => ApplicationDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async updateApplication(
@Args('id', { type: () => UUIDScalarType }) id: string,
@Args('input') input: UpdateApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.applicationService.findOneApplicationOrThrow({
id,
workspaceId,
});
return this.applicationService.update(id, {
...(isDefined(input.autoUpgrade)
? { autoUpgrade: input.autoUpgrade }
: {}),
workspaceId,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async uninstallApplication(
@@ -161,6 +161,7 @@ export class ApplicationSyncService {
defaultRole: null,
settingsCustomTabFrontComponentId: null,
canBeUninstalled: true,
autoUpgrade: false,
isSdkLayerStale: false,
applicationRegistrationId: null,
primaryPublicDomainId: null,
@@ -10,6 +10,8 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { ServerFileStorageService } from 'src/engine/core-modules/file-storage/services/server-file-storage.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -95,6 +97,10 @@ describe('ApplicationRegistrationService - upsertFromCatalog', () => {
provide: MetricsService,
useValue: { incrementCounterBy: jest.fn() },
},
{
provide: getQueueToken(MessageQueue.workspaceQueue),
useValue: { add: jest.fn() },
},
],
}).compile();
@@ -35,8 +35,15 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { buildRegistrationManifestUpdateFields } from 'src/engine/core-modules/application/application-registration/utils/build-registration-manifest-update-fields.util';
import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
UPGRADE_APPLICATIONS_JOB_NAME,
type UpgradeApplicationsJobData,
} from 'src/engine/core-modules/application/jobs/upgrade-applications.job-constants';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
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 { ServerFileStorageService } from 'src/engine/core-modules/file-storage/services/server-file-storage.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
@@ -115,8 +122,28 @@ export class ApplicationRegistrationService {
private readonly cacheLockService: CacheLockService,
private readonly coreEntityCacheService: CoreEntityCacheService,
private readonly metricsService: MetricsService,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly workspaceQueueService: MessageQueueService,
) {}
// Best-effort: a queue outage must not fail the publish flow that
// triggered the upgrade.
async enqueueAutoUpgradeApplications(
applicationRegistrationId: string,
): Promise<void> {
try {
await this.workspaceQueueService.add<UpgradeApplicationsJobData>(
UPGRADE_APPLICATIONS_JOB_NAME,
{ applicationRegistrationId, onlyAutoUpgrade: true },
);
} catch (error) {
this.logger.error(
`Failed to enqueue auto-upgrade for registration ${applicationRegistrationId}`,
error,
);
}
}
private async invalidateMarketplaceAppsCache(): Promise<void> {
try {
await this.coreEntityCacheService.invalidate(
@@ -618,6 +645,8 @@ export class ApplicationRegistrationService {
sourceType: params.sourceType,
version: params.latestAvailableVersion,
});
await this.enqueueAutoUpgradeApplications(existing.id);
}
return;
@@ -659,6 +688,8 @@ export class ApplicationRegistrationService {
sourceType: params.sourceType,
version: params.latestAvailableVersion,
});
await this.enqueueAutoUpgradeApplications(existing.id);
}
return;
@@ -135,6 +135,12 @@ export class ApplicationTarballService {
sourceType: ApplicationRegistrationSourceType.TARBALL,
version: incomingVersion,
});
if (!isNewRegistration) {
await this.applicationRegistrationService.enqueueAutoUpgradeApplications(
appRegistration.id,
);
}
}
return this.appRegistrationRepository.findOneOrFail({
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationUpgradeResolver } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.resolver';
@@ -14,7 +15,10 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
TypeOrmModule.forFeature([
ApplicationEntity,
ApplicationRegistrationEntity,
]),
ApplicationInstallModule,
ApplicationRegistrationModule,
FeatureFlagModule,
@@ -2,10 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { z } from 'zod';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
@@ -19,6 +21,8 @@ const npmPackageMetadataSchema = z.object({
version: z.string(),
});
const UPGRADE_APPLICATIONS_BATCH_SIZE = 20;
@Injectable()
export class ApplicationUpgradeService {
private readonly logger = new Logger(ApplicationUpgradeService.name);
@@ -26,6 +30,8 @@ export class ApplicationUpgradeService {
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly applicationInstallService: ApplicationInstallService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly twentyConfigService: TwentyConfigService,
@@ -79,6 +85,10 @@ export class ApplicationUpgradeService {
sourceType: appRegistration.sourceType,
version: parsed.data.version,
});
await this.applicationRegistrationService.enqueueAutoUpgradeApplications(
appRegistration.id,
);
}
return parsed.data.version;
@@ -101,6 +111,63 @@ export class ApplicationUpgradeService {
}
}
async upgradeAllApplications({
applicationRegistrationId,
onlyAutoUpgrade = false,
}: {
applicationRegistrationId: string;
onlyAutoUpgrade?: boolean;
}): Promise<void> {
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
where: { id: applicationRegistrationId },
});
const targetVersion = appRegistration.latestAvailableVersion;
if (!isDefined(targetVersion)) {
return;
}
const applications = await this.applicationRepository.find({
where: {
applicationRegistrationId,
...(onlyAutoUpgrade ? { autoUpgrade: true } : {}),
},
});
const applicationsToUpgrade = applications.filter(
(application) => application.version !== targetVersion,
);
for (
let batchStart = 0;
batchStart < applicationsToUpgrade.length;
batchStart += UPGRADE_APPLICATIONS_BATCH_SIZE
) {
const batch = applicationsToUpgrade.slice(
batchStart,
batchStart + UPGRADE_APPLICATIONS_BATCH_SIZE,
);
await Promise.all(
batch.map(async (application) => {
try {
await this.upgradeApplicationToVersion({
appRegistration,
targetVersion,
workspaceId: application.workspaceId,
});
} catch (error) {
this.logger.error(
`Failed to upgrade application ${application.id} to version ${targetVersion} in workspace ${application.workspaceId}`,
error,
);
}
}),
);
}
}
async upgradeApplication(params: {
appRegistrationId: string;
targetVersion: string;
@@ -110,6 +177,20 @@ export class ApplicationUpgradeService {
where: { id: params.appRegistrationId },
});
return this.upgradeApplicationToVersion({
appRegistration,
targetVersion: params.targetVersion,
workspaceId: params.workspaceId,
});
}
private async upgradeApplicationToVersion(params: {
appRegistration: ApplicationRegistrationEntity;
targetVersion: string;
workspaceId: string;
}): Promise<boolean> {
const { appRegistration } = params;
// LOCAL apps are updated by dev sync and OAUTH_ONLY registrations have no
// code artifacts.
if (
@@ -125,7 +206,7 @@ export class ApplicationUpgradeService {
try {
return await this.applicationInstallService.installApplication({
appRegistrationId: params.appRegistrationId,
appRegistrationId: appRegistration.id,
version: params.targetVersion,
workspaceId: params.workspaceId,
});
@@ -124,6 +124,13 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, type: 'boolean', default: true })
canBeUninstalled: boolean;
@Column({ nullable: false, type: 'boolean', default: false })
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.23.0_AddAutoUpgradeToApplicationFastInstanceCommand_1784297307235',
})
autoUpgrade: boolean;
@Column({ nullable: false, type: 'boolean', default: false })
isSdkLayerStale: boolean;
@@ -86,6 +86,10 @@ export class ApplicationDTO {
@IsBoolean()
canBeUninstalled: boolean;
@Field(() => Boolean)
@IsBoolean()
autoUpgrade: boolean;
@IsOptional()
@IsString()
@Field({ nullable: true })
@@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional } from 'class-validator';
@InputType()
export class UpdateApplicationInput {
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
autoUpgrade?: boolean;
}
@@ -0,0 +1,6 @@
export const UPGRADE_APPLICATIONS_JOB_NAME = 'UpgradeApplicationsJob';
export type UpgradeApplicationsJobData = {
applicationRegistrationId: string;
onlyAutoUpgrade: boolean;
};
@@ -0,0 +1,23 @@
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service';
import {
UPGRADE_APPLICATIONS_JOB_NAME,
type UpgradeApplicationsJobData,
} from 'src/engine/core-modules/application/jobs/upgrade-applications.job-constants';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Processor(MessageQueue.workspaceQueue)
export class UpgradeApplicationsJob {
constructor(
private readonly applicationUpgradeService: ApplicationUpgradeService,
) {}
@Process(UPGRADE_APPLICATIONS_JOB_NAME)
async handle(data: UpgradeApplicationsJobData): Promise<void> {
await this.applicationUpgradeService.upgradeAllApplications({
applicationRegistrationId: data.applicationRegistrationId,
onlyAutoUpgrade: data.onlyAutoUpgrade,
});
}
}
@@ -3,6 +3,7 @@ import { type FlatApplication } from 'src/engine/core-modules/application/types/
export const fromFlatApplicationToApplicationDto = ({
canBeUninstalled,
autoUpgrade,
description,
id,
logo,
@@ -18,6 +19,7 @@ export const fromFlatApplicationToApplicationDto = ({
}: FlatApplication): ApplicationDTO => {
return {
canBeUninstalled,
autoUpgrade,
description: description ?? undefined,
id,
logo: logo ?? undefined,
@@ -14,7 +14,9 @@ import { BillingReminderCronJob } from 'src/engine/core-modules/billing/reminder
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
import { BackfillApplicationInstallationJob } from 'src/engine/core-modules/application/jobs/backfill-application-installation.job';
import { UpgradeApplicationsJob } from 'src/engine/core-modules/application/jobs/upgrade-applications.job';
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
import { InstallOnboardingAppsJob } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
@@ -95,6 +97,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
PreInstalledAppsModule,
ApplicationInstallModule,
ApplicationRegistrationModule,
ApplicationUpgradeModule,
OnboardingModule,
BillingReminderModule,
],
@@ -112,6 +115,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
UpdateWorkspaceMemberEmailJob,
GenerateSdkClientJob,
BackfillApplicationInstallationJob,
UpgradeApplicationsJob,
InstallOnboardingAppsJob,
],
})
@@ -41,6 +41,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
defaultRole: null,
settingsCustomTabFrontComponentId: null,
canBeUninstalled: false,
autoUpgrade: false,
applicationRegistrationId: null,
primaryPublicDomainId: null,
createdAt: new Date(),
@@ -36,6 +36,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
defaultRole: null,
settingsCustomTabFrontComponentId: null,
canBeUninstalled: false,
autoUpgrade: false,
applicationRegistrationId: null,
primaryPublicDomainId: null,
createdAt: new Date(),