fix(server): resolve foreign key violation blocking application uninstall (#22502)

Fixes [sonarly issue #54192](https://sonarly.com/issue/54192)

## Problem

Uninstalling an application fails with a DB error when its
`packageJsonFileId` / `yarnLockFileId` columns are populated:

    update or delete on table "file" violates foreign key constraint
    "FK_3818380258798f9ffa9963b6dc4" on table "application"

Storage was also wiped before the failing DB delete, leaving the app
half-uninstalled.

## Root cause

`application` and `file` reference each other through `ON DELETE
RESTRICT` FKs (`application.packageJsonFileId/yarnLockFileId → file.id`
and `file.applicationId → application.id`), so no deletion order works
on its own. The deferrable-FK migration doesn't help: in Postgres,
`RESTRICT` fires immediately even on `DEFERRABLE INITIALLY DEFERRED`
constraints (only `NO ACTION` honors deferral). Uninstall deleted file
rows first, in autocommit statements.

## Fix

`ApplicationService.delete()` now runs in a single transaction:

1. Clear `packageJsonFileId` / `yarnLockFileId` (breaks the FK cycle)
2. Delete the app's `file` rows
3. Delete the `application` row

Storage cleanup moved after commit and made non-fatal, so a failure can
no longer leave partial state. `deleteApplicationFiles` is split into
`deleteApplicationFileRows` (DB, transactional) and
`deleteApplicationFilesFromStorage` (blobs). The test cleanup util had
the same file-first ordering bug and is fixed the same way.


## Questions / Follow-ups

- **Should the FK cycle be resolved at the schema level?** Both legs
could be switched to `ON DELETE NO ACTION DEFERRABLE INITIALLY
DEFERRED`, which appears to be what the deferrable-FK migration intended
— deferral would then actually apply to deletes, making transactional
deletion order-independent. Happy to open a separate PR if there's
interest.

- **Should the marketplace install path set the package file FKs?** It
stores `package.json` in the `file` table but never populates
`application.packageJsonFileId` / `yarnLockFileId` — today only
workspace creation and `application:rebuild-default-deps` set them.
Marketplace packages also don't ship a `yarn.lock`, so this needs a
product decision.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22502?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Abdul Rahman
2026-07-03 15:45:48 +05:30
committed by GitHub
parent af1b89c788
commit 9a7c0f25a5
4 changed files with 184 additions and 28 deletions
@@ -1,21 +1,21 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryRunner, type Repository } from 'typeorm';
import { type DataSource, type QueryRunner, type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { getDefaultApplicationPackageFields } from 'src/engine/core-modules/application/application-package/utils/get-default-application-package-fields.util';
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/application-package/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { WORKSPACE_CUSTOM_APPLICATION_NAME } from 'src/engine/core-modules/application/constants/workspace-custom-application.constant';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -34,7 +34,11 @@ import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty
@Injectable()
export class ApplicationService {
private readonly logger = new Logger(ApplicationService.name);
constructor(
@InjectDataSource()
private readonly coreDataSource: DataSource,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(ApplicationRegistrationEntity)
@@ -584,15 +588,50 @@ export class ApplicationService {
);
}
await this.fileStorageService.deleteApplicationFiles({
workspaceId,
applicationUniversalIdentifier: universalIdentifier,
});
const queryRunner = this.coreDataSource.createQueryRunner();
await this.applicationRepository.delete({
universalIdentifier,
workspaceId,
});
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.update(
ApplicationEntity,
{ id: application.id },
{ packageJsonFileId: null, yarnLockFileId: null },
);
await this.fileStorageService.deleteApplicationFileRows({
applicationId: application.id,
workspaceId,
queryRunner,
});
await queryRunner.manager.delete(ApplicationEntity, {
universalIdentifier,
workspaceId,
});
await queryRunner.commitTransaction();
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
try {
await this.fileStorageService.deleteApplicationFilesFromStorage({
workspaceId,
applicationUniversalIdentifier: universalIdentifier,
});
} catch (error) {
this.logger.warn(
`Failed to delete storage folder for application ${universalIdentifier} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',