Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756)
This commit is contained in:
+2
@@ -5,6 +5,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
|
||||
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
|
||||
import { ApplicationDevelopmentService } from 'src/engine/core-modules/application/application-development/application-development.service';
|
||||
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
@@ -28,6 +29,7 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
|
||||
],
|
||||
providers: [
|
||||
ApplicationDevelopmentResolver,
|
||||
ApplicationDevelopmentService,
|
||||
WorkspaceMigrationGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
})
|
||||
|
||||
+10
-265
@@ -8,38 +8,19 @@ import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationDevelopmentService } from 'src/engine/core-modules/application/application-development/application-development.service';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
|
||||
import { CreateDevelopmentApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/create-development-application.input';
|
||||
import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/application-development/dtos/development-application.dto';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
|
||||
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service';
|
||||
import { VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
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';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
@@ -47,11 +28,6 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
const APP_DEV_RATE_LIMIT_MAX = 30;
|
||||
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
|
||||
|
||||
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@@ -62,16 +38,7 @@ const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
|
||||
)
|
||||
export class ApplicationDevelopmentResolver {
|
||||
constructor(
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly sdkClientGenerationService: SdkClientGenerationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
private readonly cacheLockService: CacheLockService,
|
||||
private readonly applicationDevelopmentService: ApplicationDevelopmentService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => DevelopmentApplicationDTO)
|
||||
@@ -79,36 +46,11 @@ export class ApplicationDevelopmentResolver {
|
||||
@Args() { universalIdentifier, name }: CreateDevelopmentApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<DevelopmentApplicationDTO> {
|
||||
await this.throttlePerApplication(universalIdentifier, workspaceId);
|
||||
|
||||
const applicationRegistrationId =
|
||||
await this.findApplicationRegistrationId(universalIdentifier);
|
||||
|
||||
const existing = await this.applicationService.findByUniversalIdentifier({
|
||||
universalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.id,
|
||||
universalIdentifier: existing.universalIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
const application = await this.applicationService.create({
|
||||
return this.applicationDevelopmentService.createDevelopmentApplication({
|
||||
universalIdentifier,
|
||||
name,
|
||||
sourcePath: universalIdentifier,
|
||||
sourceType: ApplicationRegistrationSourceType.LOCAL,
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: application.id,
|
||||
universalIdentifier: application.universalIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceMigrationDTO)
|
||||
@@ -116,101 +58,11 @@ export class ApplicationDevelopmentResolver {
|
||||
@Args() { manifest, dryRun }: ApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
await this.throttlePerApplication(
|
||||
manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const versionValidation =
|
||||
await this.applicationVersionValidationService.validateWorkspaceCompatibility(
|
||||
{
|
||||
requiredServerVersion:
|
||||
manifest.application.requiredServerVersionRange ?? undefined,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!versionValidation.compatible) {
|
||||
throw new ApplicationException(
|
||||
versionValidation.message,
|
||||
VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE[versionValidation.reason],
|
||||
);
|
||||
}
|
||||
|
||||
if (dryRun === true) {
|
||||
const { workspaceMigration } =
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
return {
|
||||
applicationUniversalIdentifier:
|
||||
workspaceMigration.applicationUniversalIdentifier,
|
||||
actions: workspaceMigration.actions,
|
||||
};
|
||||
}
|
||||
|
||||
return this.cacheLockService.withLock(
|
||||
() => this.applyManifestSync(manifest, workspaceId),
|
||||
`app-sync:${workspaceId}`,
|
||||
APP_SYNC_LOCK_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
private async applyManifestSync(
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
const applicationRegistrationId = await this.findApplicationRegistrationId(
|
||||
manifest.application.universalIdentifier,
|
||||
);
|
||||
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
{
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(application)) {
|
||||
throw new ApplicationException(
|
||||
`Application "${manifest.application.universalIdentifier}" not found in workspace "${workspaceId}". Run createDevelopmentApplication first.`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const isFirstSync = !isDefined(application.version);
|
||||
|
||||
const { workspaceMigration, hasSchemaMetadataChanged } =
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
if (isFirstSync || hasSchemaMetadataChanged) {
|
||||
await this.sdkClientGenerationService.generateSdkClientForApplication({
|
||||
workspaceId,
|
||||
applicationId: application.id,
|
||||
applicationUniversalIdentifier:
|
||||
manifest.application.universalIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
await this.syncRegistrationMetadata(
|
||||
applicationRegistrationId,
|
||||
return this.applicationDevelopmentService.syncApplication({
|
||||
manifest,
|
||||
dryRun,
|
||||
workspaceId,
|
||||
application.id,
|
||||
);
|
||||
|
||||
return {
|
||||
applicationUniversalIdentifier:
|
||||
workspaceMigration.applicationUniversalIdentifier,
|
||||
actions: workspaceMigration.actions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => FileDTO)
|
||||
@@ -226,119 +78,12 @@ export class ApplicationDevelopmentResolver {
|
||||
filePath,
|
||||
}: UploadApplicationFileInput,
|
||||
): Promise<FileDTO> {
|
||||
await this.throttlePerApplication(
|
||||
applicationUniversalIdentifier,
|
||||
return this.applicationDevelopmentService.uploadApplicationFile({
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const allowedApplicationFileFolders: FileFolder[] = [
|
||||
FileFolder.BuiltLogicFunction,
|
||||
FileFolder.BuiltFrontComponent,
|
||||
FileFolder.PublicAsset,
|
||||
FileFolder.Source,
|
||||
FileFolder.Dependencies,
|
||||
];
|
||||
|
||||
if (!allowedApplicationFileFolders.includes(fileFolder)) {
|
||||
throw new ApplicationException(
|
||||
`Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`,
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const pathValidationResult = validateFilePath({
|
||||
resourcePath: filePath,
|
||||
fileFolder,
|
||||
});
|
||||
|
||||
if (!pathValidationResult.isValid) {
|
||||
throw new ApplicationException(
|
||||
pathValidationResult.error,
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
{
|
||||
universalIdentifier: applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(application)) {
|
||||
throw new ApplicationException(
|
||||
'Application not found in workspace.',
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await streamToBuffer(createReadStream());
|
||||
|
||||
return await this.fileStorageService.writeFile({
|
||||
sourceFile: buffer,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
resourcePath: filePath,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
fileFolder,
|
||||
filePath,
|
||||
getFileBuffer: () => streamToBuffer(createReadStream()),
|
||||
});
|
||||
}
|
||||
|
||||
private async throttlePerApplication(
|
||||
applicationIdentifier: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.throttlerService.tokenBucketThrottleOrThrow(
|
||||
`app-dev:${workspaceId}:${applicationIdentifier}`,
|
||||
1,
|
||||
APP_DEV_RATE_LIMIT_MAX,
|
||||
APP_DEV_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private async findApplicationRegistrationId(
|
||||
universalIdentifier: string,
|
||||
): Promise<string> {
|
||||
const existingRegistration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (!existingRegistration) {
|
||||
throw new ApplicationException(
|
||||
`No registration found for "${universalIdentifier}". Create one first with createApplicationRegistration.`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return existingRegistration.id;
|
||||
}
|
||||
|
||||
private async syncRegistrationMetadata(
|
||||
applicationRegistrationId: string,
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
applicationId: string,
|
||||
): Promise<void> {
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
(filePath) =>
|
||||
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.updateFromManifest({
|
||||
applicationRegistrationId,
|
||||
manifest: manifestWithResolvedUrls,
|
||||
sourceType: ApplicationRegistrationSourceType.LOCAL,
|
||||
});
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
applicationRegistrationId,
|
||||
manifest.application.serverVariables,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
|
||||
import { type DevelopmentApplicationDTO } from 'src/engine/core-modules/application/application-development/dtos/development-application.dto';
|
||||
import { type WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service';
|
||||
import { VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
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';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { type FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const APP_DEV_RATE_LIMIT_MAX = 30;
|
||||
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
|
||||
|
||||
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
|
||||
|
||||
const ALLOWED_APPLICATION_FILE_FOLDERS: FileFolder[] = [
|
||||
FileFolder.BuiltLogicFunction,
|
||||
FileFolder.BuiltFrontComponent,
|
||||
FileFolder.PublicAsset,
|
||||
FileFolder.Source,
|
||||
FileFolder.Dependencies,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationDevelopmentService {
|
||||
constructor(
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly sdkClientGenerationService: SdkClientGenerationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
private readonly cacheLockService: CacheLockService,
|
||||
) {}
|
||||
|
||||
async createDevelopmentApplication({
|
||||
universalIdentifier,
|
||||
name,
|
||||
workspaceId,
|
||||
}: {
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
}): Promise<DevelopmentApplicationDTO> {
|
||||
await this.throttlePerApplication(universalIdentifier, workspaceId);
|
||||
|
||||
const applicationRegistrationId =
|
||||
await this.findApplicationRegistrationId(universalIdentifier);
|
||||
|
||||
const existing = await this.applicationService.findByUniversalIdentifier({
|
||||
universalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.id,
|
||||
universalIdentifier: existing.universalIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
const application = await this.applicationService.create({
|
||||
universalIdentifier,
|
||||
name,
|
||||
sourcePath: universalIdentifier,
|
||||
sourceType: ApplicationRegistrationSourceType.LOCAL,
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: application.id,
|
||||
universalIdentifier: application.universalIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
async syncApplication({
|
||||
manifest,
|
||||
dryRun,
|
||||
workspaceId,
|
||||
}: {
|
||||
manifest: ApplicationInput['manifest'];
|
||||
dryRun?: boolean;
|
||||
workspaceId: string;
|
||||
}): Promise<WorkspaceMigrationDTO> {
|
||||
await this.throttlePerApplication(
|
||||
manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const versionValidation =
|
||||
await this.applicationVersionValidationService.validateWorkspaceCompatibility(
|
||||
{
|
||||
requiredServerVersion:
|
||||
manifest.application.requiredServerVersionRange ?? undefined,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!versionValidation.compatible) {
|
||||
throw new ApplicationException(
|
||||
versionValidation.message,
|
||||
VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE[versionValidation.reason],
|
||||
);
|
||||
}
|
||||
|
||||
if (dryRun === true) {
|
||||
const { workspaceMigration } =
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
return {
|
||||
applicationUniversalIdentifier:
|
||||
workspaceMigration.applicationUniversalIdentifier,
|
||||
actions: workspaceMigration.actions,
|
||||
};
|
||||
}
|
||||
|
||||
return this.cacheLockService.withLock(
|
||||
() => this.applyManifestSync(manifest, workspaceId),
|
||||
`app-sync:${workspaceId}`,
|
||||
APP_SYNC_LOCK_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
async uploadApplicationFile({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
filePath,
|
||||
getFileBuffer,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
fileFolder: FileFolder;
|
||||
filePath: string;
|
||||
// Lazy so rejected or rate-limited uploads are not buffered into memory.
|
||||
getFileBuffer: () => Promise<Buffer>;
|
||||
}): Promise<FileDTO> {
|
||||
await this.throttlePerApplication(
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!ALLOWED_APPLICATION_FILE_FOLDERS.includes(fileFolder)) {
|
||||
throw new ApplicationException(
|
||||
`Invalid fileFolder for application file upload. Allowed values: ${ALLOWED_APPLICATION_FILE_FOLDERS.join(', ')}`,
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const pathValidationResult = validateFilePath({
|
||||
resourcePath: filePath,
|
||||
fileFolder,
|
||||
});
|
||||
|
||||
if (!pathValidationResult.isValid) {
|
||||
throw new ApplicationException(
|
||||
pathValidationResult.error,
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
{
|
||||
universalIdentifier: applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(application)) {
|
||||
throw new ApplicationException(
|
||||
'Application not found in workspace.',
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.fileStorageService.writeFile({
|
||||
sourceFile: await getFileBuffer(),
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
resourcePath: filePath,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
}
|
||||
|
||||
private async applyManifestSync(
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
const applicationRegistrationId = await this.findApplicationRegistrationId(
|
||||
manifest.application.universalIdentifier,
|
||||
);
|
||||
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
{
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(application)) {
|
||||
throw new ApplicationException(
|
||||
`Application "${manifest.application.universalIdentifier}" not found in workspace "${workspaceId}". Run createDevelopmentApplication first.`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const isFirstSync = !isDefined(application.version);
|
||||
|
||||
const { workspaceMigration, hasSchemaMetadataChanged } =
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
if (isFirstSync || hasSchemaMetadataChanged) {
|
||||
await this.sdkClientGenerationService.generateSdkClientForApplication({
|
||||
workspaceId,
|
||||
applicationId: application.id,
|
||||
applicationUniversalIdentifier:
|
||||
manifest.application.universalIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
await this.syncRegistrationMetadata(
|
||||
applicationRegistrationId,
|
||||
manifest,
|
||||
workspaceId,
|
||||
application.id,
|
||||
);
|
||||
|
||||
return {
|
||||
applicationUniversalIdentifier:
|
||||
workspaceMigration.applicationUniversalIdentifier,
|
||||
actions: workspaceMigration.actions,
|
||||
};
|
||||
}
|
||||
|
||||
private async throttlePerApplication(
|
||||
applicationIdentifier: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.throttlerService.tokenBucketThrottleOrThrow(
|
||||
`app-dev:${workspaceId}:${applicationIdentifier}`,
|
||||
1,
|
||||
APP_DEV_RATE_LIMIT_MAX,
|
||||
APP_DEV_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private async findApplicationRegistrationId(
|
||||
universalIdentifier: string,
|
||||
): Promise<string> {
|
||||
const existingRegistration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (!existingRegistration) {
|
||||
throw new ApplicationException(
|
||||
`No registration found for "${universalIdentifier}". Create one first with createApplicationRegistration.`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return existingRegistration.id;
|
||||
}
|
||||
|
||||
private async syncRegistrationMetadata(
|
||||
applicationRegistrationId: string,
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
applicationId: string,
|
||||
): Promise<void> {
|
||||
const registration =
|
||||
await this.applicationRegistrationService.findOneByIdGlobal(
|
||||
applicationRegistrationId,
|
||||
);
|
||||
|
||||
// The registration is instance-global: for catalog-synced (npm) apps it is
|
||||
// the marketplace entry and OAuth identity shared by every workspace, so
|
||||
// dev-mode sync must not overwrite its manifest or flip its sourceType.
|
||||
// Only registrations owned by the syncing workspace (and not npm-sourced)
|
||||
// reflect local dev state.
|
||||
if (
|
||||
registration.sourceType === ApplicationRegistrationSourceType.NPM ||
|
||||
registration.ownerWorkspaceId !== workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
(filePath) =>
|
||||
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.updateFromManifest({
|
||||
applicationRegistrationId,
|
||||
manifest: manifestWithResolvedUrls,
|
||||
sourceType: ApplicationRegistrationSourceType.LOCAL,
|
||||
});
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
applicationRegistrationId,
|
||||
manifest.application.serverVariables,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user