Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756)

This commit is contained in:
martmull
2026-07-10 12:10:00 +02:00
committed by GitHub
parent f8d3555fe7
commit 1c8b8970fd
14 changed files with 780 additions and 292 deletions
@@ -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,
],
})
@@ -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,
);
}
}
}
@@ -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,
);
}
}
}