[Slow db query] Resolve applicationId from cache in FileStorageService (#22870)
## Context Sentry flagged a recurring slow DB query (TWENTY-SERVER-HZJ): `SELECT ... FROM core.application WHERE workspaceId = $1 AND universalIdentifier = $2 AND deletedAt IS NULL LIMIT 1`, emitted on every file write under `POST /graphql` (record avatar/file fields) and `POST /metadata`. `FileStorageService` re-resolved the owning application row from `core.application` by `(workspaceId, universalIdentifier)` on every file write, uncached and synchronously in the request path. The row was only used to recover `application.id`. The workspace cache already exposes this mapping via `flatApplicationMaps.idByUniversalIdentifier`. Closes twentyhq/core-team-issues#2668. ## Changes - Injected `WorkspaceCacheService` into `FileStorageService` in place of the `ApplicationEntity` repository. - Added `resolveApplicationIdOrThrow`: resolves `applicationId` from `flatApplicationMaps.idByUniversalIdentifier` on the normal (already-committed) path, throwing `FileStorageException(FILE_NOT_FOUND)` on a cache miss. When a `queryRunner` is provided (application-creating transactions, where the freshly created row is not yet in cache), it keeps the DB read through `queryRunner.manager` so it can see uncommitted rows. - Added `resolveApplicationUniversalIdentifierOrThrow` for the by-id lookup in `deleteByFileId`, resolved from `flatApplicationMaps.byId`. - Applied the cache path to `writeFile`, `createPendingFile`, `deleteFile`, `deleteFolder`, and `deleteByFileId`. Only `writeFile` carries a `queryRunner`; the others never do. - Updated `FileStorageModule` to import `WorkspaceCacheModule` and drop the now-unused `ApplicationEntity` repository registration. No migration needed: a partial unique composite index on `(universalIdentifier, workspaceId) WHERE deletedAt IS NULL AND universalIdentifier IS NOT NULL` already exists on `ApplicationEntity` and covers the query. ## Tests Extended `file-storage.service.spec.ts`: - cache hit resolves `applicationId` without a DB call, - cache miss throws `FILE_NOT_FOUND`, - the `queryRunner` path still reads from the DB and skips the cache. All 95 file-storage unit tests pass; typecheck, oxlint, and oxfmt are clean on the touched files. --- _Generated by [Claude Code](https://claude.ai/code/session_018GUrJ26xvZpjtrGGev9jsk)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22870?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:
@@ -492,6 +492,7 @@ export class ApplicationService {
|
||||
sourceFile: defaultPackageFields.packageJsonContent,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
applicationId: application.id,
|
||||
workspaceId: application.workspaceId,
|
||||
resourcePath: 'package.json',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
@@ -502,6 +503,7 @@ export class ApplicationService {
|
||||
sourceFile: defaultPackageFields.yarnLockContent,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
applicationId: application.id,
|
||||
workspaceId: application.workspaceId,
|
||||
resourcePath: 'yarn.lock',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
|
||||
export const findActiveFlatApplicationById = (
|
||||
flatApplicationMaps: FlatApplicationCacheMaps,
|
||||
applicationId: string,
|
||||
): FlatApplication | undefined => {
|
||||
const application = flatApplicationMaps.byId[applicationId];
|
||||
|
||||
if (!isDefined(application) || isDefined(application.deletedAt)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return application;
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
|
||||
import { findActiveFlatApplicationById } from 'src/engine/core-modules/application/utils/find-active-flat-application-by-id.util';
|
||||
|
||||
export const findActiveFlatApplicationByUniversalIdentifier = (
|
||||
flatApplicationMaps: FlatApplicationCacheMaps,
|
||||
applicationUniversalIdentifier: string,
|
||||
): FlatApplication | undefined => {
|
||||
const applicationId =
|
||||
flatApplicationMaps.idByUniversalIdentifier[applicationUniversalIdentifier];
|
||||
|
||||
return isDefined(applicationId)
|
||||
? findActiveFlatApplicationById(flatApplicationMaps, applicationId)
|
||||
: undefined;
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { type DynamicModule, Global } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service';
|
||||
@@ -10,6 +9,7 @@ import { ServerFileStorageService } from 'src/engine/core-modules/file-storage/s
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
@Global()
|
||||
export class FileStorageModule {
|
||||
static forRoot(): DynamicModule {
|
||||
@@ -17,7 +17,8 @@ export class FileStorageModule {
|
||||
module: FileStorageModule,
|
||||
imports: [
|
||||
TwentyConfigModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
|
||||
TypeOrmModule.forFeature([FileEntity]),
|
||||
WorkspaceCacheModule,
|
||||
],
|
||||
providers: [
|
||||
FileStorageDriverFactory,
|
||||
|
||||
+181
-22
@@ -1,5 +1,4 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import {
|
||||
@@ -7,12 +6,12 @@ import {
|
||||
eachTestingContextFilter,
|
||||
} from 'twenty-shared/testing';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service';
|
||||
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
describe('FileStorageService', () => {
|
||||
let service: FileStorageService;
|
||||
let fileStorageDriverFactory: FileStorageDriverFactory;
|
||||
@@ -26,10 +25,11 @@ describe('FileStorageService', () => {
|
||||
upsertAndReturnOne: jest.fn(),
|
||||
findOneOrFail: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
withManager: jest.fn(),
|
||||
};
|
||||
|
||||
const mockApplicationRepository = {
|
||||
findOneOrFail: jest.fn(),
|
||||
const mockWorkspaceCacheService = {
|
||||
getOrRecompute: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -45,8 +45,8 @@ describe('FileStorageService', () => {
|
||||
useValue: mockFileRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ApplicationEntity),
|
||||
useValue: mockApplicationRepository,
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: mockWorkspaceCacheService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
@@ -123,10 +123,6 @@ describe('FileStorageService', () => {
|
||||
'https://s3.example.com/signed',
|
||||
);
|
||||
|
||||
mockApplicationRepository.findOneOrFail.mockResolvedValue({
|
||||
universalIdentifier: 'app-uid',
|
||||
});
|
||||
|
||||
const result = await service.getPresignedUrl({
|
||||
resourcePath: 'file.txt',
|
||||
fileFolder: 'workflow' as any,
|
||||
@@ -143,10 +139,6 @@ describe('FileStorageService', () => {
|
||||
it('should return null when driver returns null', async () => {
|
||||
mockDriver.getPresignedUrl.mockResolvedValue(null);
|
||||
|
||||
mockApplicationRepository.findOneOrFail.mockResolvedValue({
|
||||
universalIdentifier: 'app-uid',
|
||||
});
|
||||
|
||||
const result = await service.getPresignedUrl({
|
||||
resourcePath: 'file.txt',
|
||||
fileFolder: 'workflow' as any,
|
||||
@@ -175,9 +167,13 @@ describe('FileStorageService', () => {
|
||||
};
|
||||
|
||||
mockFileStorageDriverFactory.getCurrentDriver.mockReturnValue(mockDriver);
|
||||
mockApplicationRepository.findOneOrFail.mockResolvedValue({
|
||||
id: 'app-id',
|
||||
universalIdentifier: 'app-uid',
|
||||
mockWorkspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
flatApplicationMaps: {
|
||||
byId: {
|
||||
'app-id': { id: 'app-id', universalIdentifier: 'app-456' },
|
||||
},
|
||||
idByUniversalIdentifier: { 'app-456': 'app-id' },
|
||||
},
|
||||
});
|
||||
mockFileRepository.upsertAndReturnOne.mockResolvedValue({
|
||||
id: 'file-id',
|
||||
@@ -409,6 +405,174 @@ describe('FileStorageService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('applicationId resolution', () => {
|
||||
it('should resolve applicationId from the workspace cache by universalIdentifier', async () => {
|
||||
await service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockWorkspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
|
||||
'workspace-123',
|
||||
['flatApplicationMaps'],
|
||||
);
|
||||
expect(mockFileRepository.upsertAndReturnOne).toHaveBeenCalledWith(
|
||||
'workspace-123',
|
||||
expect.objectContaining({ applicationId: 'app-id' }),
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw FILE_NOT_FOUND when the application is not in the cache', async () => {
|
||||
mockWorkspaceCacheService.getOrRecompute.mockResolvedValueOnce({
|
||||
flatApplicationMaps: { byId: {}, idByUniversalIdentifier: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw FILE_NOT_FOUND when the cached application is soft-deleted', async () => {
|
||||
mockWorkspaceCacheService.getOrRecompute.mockResolvedValueOnce({
|
||||
flatApplicationMaps: {
|
||||
byId: {
|
||||
'app-id': {
|
||||
id: 'app-id',
|
||||
universalIdentifier: 'app-456',
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
},
|
||||
idByUniversalIdentifier: { 'app-456': 'app-id' },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use the passed applicationId without consulting the cache', async () => {
|
||||
await service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
applicationId: 'passed-app-id',
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(
|
||||
mockWorkspaceCacheService.getOrRecompute,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(mockFileRepository.upsertAndReturnOne).toHaveBeenCalledWith(
|
||||
'workspace-123',
|
||||
expect.objectContaining({ applicationId: 'passed-app-id' }),
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the passed applicationId and write through the queryRunner without consulting the cache', async () => {
|
||||
const queryRunner = { manager: {} };
|
||||
|
||||
mockFileRepository.withManager.mockReturnValue(mockFileRepository);
|
||||
|
||||
await service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
applicationId: 'passed-app-id',
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
queryRunner: queryRunner as any,
|
||||
});
|
||||
|
||||
expect(mockFileRepository.withManager).toHaveBeenCalledWith(
|
||||
queryRunner.manager,
|
||||
);
|
||||
expect(
|
||||
mockWorkspaceCacheService.getOrRecompute,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(mockFileRepository.upsertAndReturnOne).toHaveBeenCalledWith(
|
||||
'workspace-123',
|
||||
expect.objectContaining({ applicationId: 'passed-app-id' }),
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to a queryRunner DB read when the application is not yet in the cache', async () => {
|
||||
mockWorkspaceCacheService.getOrRecompute.mockResolvedValueOnce({
|
||||
flatApplicationMaps: { byId: {}, idByUniversalIdentifier: {} },
|
||||
});
|
||||
|
||||
const findOne = jest.fn().mockResolvedValue({ id: 'uncommitted-id' });
|
||||
const queryRunner = {
|
||||
manager: { getRepository: jest.fn().mockReturnValue({ findOne }) },
|
||||
};
|
||||
|
||||
mockFileRepository.withManager.mockReturnValue(mockFileRepository);
|
||||
|
||||
await service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
queryRunner: queryRunner as any,
|
||||
});
|
||||
|
||||
expect(mockWorkspaceCacheService.getOrRecompute).toHaveBeenCalled();
|
||||
expect(findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
universalIdentifier: 'app-456',
|
||||
workspaceId: 'workspace-123',
|
||||
},
|
||||
});
|
||||
expect(mockFileRepository.upsertAndReturnOne).toHaveBeenCalledWith(
|
||||
'workspace-123',
|
||||
expect.objectContaining({ applicationId: 'uncommitted-id' }),
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw FILE_NOT_FOUND when the application is missing from both the cache and the queryRunner DB read', async () => {
|
||||
mockWorkspaceCacheService.getOrRecompute.mockResolvedValueOnce({
|
||||
flatApplicationMaps: { byId: {}, idByUniversalIdentifier: {} },
|
||||
});
|
||||
|
||||
const findOne = jest.fn().mockResolvedValue(null);
|
||||
const queryRunner = {
|
||||
manager: { getRepository: jest.fn().mockReturnValue({ findOne }) },
|
||||
};
|
||||
|
||||
mockFileRepository.withManager.mockReturnValue(mockFileRepository);
|
||||
|
||||
await expect(
|
||||
service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
queryRunner: queryRunner as any,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('magic-byte backstop', () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
@@ -696,11 +860,6 @@ describe('FileStorageService', () => {
|
||||
workspaceId: 'workspace-123',
|
||||
});
|
||||
|
||||
mockApplicationRepository.findOneOrFail.mockResolvedValue({
|
||||
id: 'app-id',
|
||||
universalIdentifier: 'app-456',
|
||||
});
|
||||
|
||||
await service.deleteByFileId({
|
||||
fileId: 'file-id',
|
||||
workspaceId: 'workspace-123',
|
||||
|
||||
+113
-39
@@ -1,13 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { basename, dirname, join } from 'path';
|
||||
import { type Readable } from 'stream';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { Like, Repository, type QueryRunner } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Like, type QueryRunner } from 'typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { findActiveFlatApplicationById } from 'src/engine/core-modules/application/utils/find-active-flat-application-by-id.util';
|
||||
import { findActiveFlatApplicationByUniversalIdentifier } from 'src/engine/core-modules/application/utils/find-active-flat-application-by-universal-identifier.util';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import {
|
||||
FileStorageException,
|
||||
@@ -23,6 +25,7 @@ import { FILE_STATUS } from 'src/engine/core-modules/file/types/file-status.type
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
export type ResourceIdentifier = {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
@@ -36,10 +39,80 @@ export class FileStorageService {
|
||||
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
private async resolveApplicationIdOrThrow({
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
queryRunner,
|
||||
}: {
|
||||
applicationUniversalIdentifier: string;
|
||||
workspaceId: string;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<string> {
|
||||
const { flatApplicationMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatApplicationMaps',
|
||||
]);
|
||||
|
||||
const cachedApplication = findActiveFlatApplicationByUniversalIdentifier(
|
||||
flatApplicationMaps,
|
||||
applicationUniversalIdentifier,
|
||||
);
|
||||
|
||||
if (isDefined(cachedApplication)) {
|
||||
return cachedApplication.id;
|
||||
}
|
||||
|
||||
if (isDefined(queryRunner)) {
|
||||
const application = await queryRunner.manager
|
||||
.getRepository(ApplicationEntity)
|
||||
.findOne({
|
||||
where: {
|
||||
universalIdentifier: applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(application)) {
|
||||
return application.id;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileStorageException(
|
||||
`Application with universalIdentifier "${applicationUniversalIdentifier}" not found`,
|
||||
FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveApplicationUniversalIdentifierOrThrow({
|
||||
applicationId,
|
||||
workspaceId,
|
||||
}: {
|
||||
applicationId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<string> {
|
||||
const { flatApplicationMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatApplicationMaps',
|
||||
]);
|
||||
|
||||
const application = findActiveFlatApplicationById(
|
||||
flatApplicationMaps,
|
||||
applicationId,
|
||||
);
|
||||
|
||||
if (!isDefined(application)) {
|
||||
throw new FileStorageException(
|
||||
`Application with id "${applicationId}" not found`,
|
||||
FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return application.universalIdentifier;
|
||||
}
|
||||
|
||||
private buildStoragePathWithinWorkspaceOrThrow({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
@@ -124,6 +197,7 @@ export class FileStorageService {
|
||||
sourceFile,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
resourcePath,
|
||||
fileId,
|
||||
@@ -131,26 +205,25 @@ export class FileStorageService {
|
||||
queryRunner,
|
||||
}: ResourceIdentifier & {
|
||||
sourceFile: string | Buffer | Uint8Array;
|
||||
applicationId?: string;
|
||||
fileId?: string;
|
||||
settings: FileSettings;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<FileEntity> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
const applicationRepository = queryRunner
|
||||
? queryRunner.manager.getRepository(ApplicationEntity)
|
||||
: this.applicationRepository;
|
||||
const fileRepository = queryRunner
|
||||
const resolvedApplicationId =
|
||||
applicationId ??
|
||||
(await this.resolveApplicationIdOrThrow({
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
queryRunner,
|
||||
}));
|
||||
|
||||
const fileRepository = isDefined(queryRunner)
|
||||
? this.fileRepository.withManager(queryRunner.manager)
|
||||
: this.fileRepository;
|
||||
|
||||
const application = await applicationRepository.findOneOrFail({
|
||||
where: {
|
||||
universalIdentifier: applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
const { onStorageFilePath, filePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow({
|
||||
workspaceId,
|
||||
@@ -175,7 +248,7 @@ export class FileStorageService {
|
||||
workspaceId,
|
||||
{
|
||||
path: filePath,
|
||||
applicationId: application.id,
|
||||
applicationId: resolvedApplicationId,
|
||||
id: fileId,
|
||||
mimeType,
|
||||
size:
|
||||
@@ -206,11 +279,9 @@ export class FileStorageService {
|
||||
mimeType: string;
|
||||
settings: FileSettings;
|
||||
}): Promise<FileEntity> {
|
||||
const application = await this.applicationRepository.findOneOrFail({
|
||||
where: {
|
||||
universalIdentifier: applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
const applicationId = await this.resolveApplicationIdOrThrow({
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const { filePath } = this.validateAndBuildFileStoragePathOrThrow({
|
||||
@@ -224,7 +295,7 @@ export class FileStorageService {
|
||||
workspaceId,
|
||||
{
|
||||
path: filePath,
|
||||
applicationId: application.id,
|
||||
applicationId,
|
||||
id: fileId,
|
||||
mimeType,
|
||||
size,
|
||||
@@ -354,7 +425,9 @@ export class FileStorageService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFile(params: ResourceIdentifier): Promise<void> {
|
||||
async deleteFile(
|
||||
params: ResourceIdentifier & { applicationId?: string },
|
||||
): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath, filePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
@@ -364,16 +437,16 @@ export class FileStorageService {
|
||||
filename: basename(onStorageFilePath),
|
||||
});
|
||||
|
||||
const application = await this.applicationRepository.findOneOrFail({
|
||||
where: {
|
||||
universalIdentifier: params.applicationUniversalIdentifier,
|
||||
const applicationId =
|
||||
params.applicationId ??
|
||||
(await this.resolveApplicationIdOrThrow({
|
||||
applicationUniversalIdentifier: params.applicationUniversalIdentifier,
|
||||
workspaceId: params.workspaceId,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
await this.fileRepository.delete(params.workspaceId, {
|
||||
path: filePath,
|
||||
applicationId: application.id,
|
||||
applicationId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -399,16 +472,14 @@ export class FileStorageService {
|
||||
|
||||
await driver.delete({ folderPath: onStorageFolderPath });
|
||||
|
||||
const application = await this.applicationRepository.findOneOrFail({
|
||||
where: {
|
||||
universalIdentifier: applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
},
|
||||
const applicationId = await this.resolveApplicationIdOrThrow({
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.fileRepository.delete(workspaceId, {
|
||||
path: Like(`${validatedFolderPath}%`),
|
||||
applicationId: application.id,
|
||||
applicationId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -428,13 +499,16 @@ export class FileStorageService {
|
||||
},
|
||||
});
|
||||
|
||||
const application = await this.applicationRepository.findOneOrFail({
|
||||
where: { id: file.applicationId, workspaceId },
|
||||
});
|
||||
const applicationUniversalIdentifier =
|
||||
await this.resolveApplicationUniversalIdentifierOrThrow({
|
||||
applicationId: file.applicationId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.deleteFile({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
applicationId: file.applicationId,
|
||||
fileFolder,
|
||||
resourcePath: removeFileFolderFromFileEntityPath(file.path),
|
||||
});
|
||||
|
||||
+53
-45
@@ -1,15 +1,12 @@
|
||||
import crypto from 'crypto';
|
||||
import gql from 'graphql-tag';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
|
||||
|
||||
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
||||
|
||||
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
|
||||
export const setupApplicationForSync = async ({
|
||||
applicationUniversalIdentifier,
|
||||
name,
|
||||
description,
|
||||
sourcePath,
|
||||
}: {
|
||||
applicationUniversalIdentifier: string;
|
||||
@@ -17,47 +14,58 @@ export const setupApplicationForSync = async ({
|
||||
description: string;
|
||||
sourcePath: string;
|
||||
}) => {
|
||||
const registrationId = crypto.randomUUID();
|
||||
const applicationId = crypto.randomUUID();
|
||||
const oAuthClientId = crypto.randomUUID();
|
||||
|
||||
await globalThis.testDataSource.query(
|
||||
`INSERT INTO core."applicationRegistration"
|
||||
(id, "universalIdentifier", name, "oAuthClientId",
|
||||
"oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
registrationId,
|
||||
applicationUniversalIdentifier,
|
||||
name,
|
||||
oAuthClientId,
|
||||
[],
|
||||
[],
|
||||
TEST_WORKSPACE_ID,
|
||||
'local',
|
||||
],
|
||||
);
|
||||
|
||||
await globalThis.testDataSource.query(
|
||||
`INSERT INTO core."application"
|
||||
(id, "universalIdentifier", name, description, version, "sourcePath",
|
||||
"sourceType", "workspaceId", "applicationRegistrationId")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
name,
|
||||
description,
|
||||
'1.0.0',
|
||||
sourcePath,
|
||||
'local',
|
||||
TEST_WORKSPACE_ID,
|
||||
registrationId,
|
||||
],
|
||||
);
|
||||
|
||||
jest.useRealTimers();
|
||||
|
||||
const registrationResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateApplicationRegistration(
|
||||
$input: CreateApplicationRegistrationInput!
|
||||
) {
|
||||
createApplicationRegistration(input: $input) {
|
||||
applicationRegistration {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: { name, universalIdentifier: applicationUniversalIdentifier },
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(registrationResponse.body.errors)) {
|
||||
throw new Error(
|
||||
`Failed to create application registration: ${JSON.stringify(
|
||||
registrationResponse.body.errors,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const developmentApplicationResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateDevelopmentApplication(
|
||||
$universalIdentifier: String!
|
||||
$name: String!
|
||||
) {
|
||||
createDevelopmentApplication(
|
||||
universalIdentifier: $universalIdentifier
|
||||
name: $name
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { universalIdentifier: applicationUniversalIdentifier, name },
|
||||
});
|
||||
|
||||
if (isDefined(developmentApplicationResponse.body.errors)) {
|
||||
throw new Error(
|
||||
`Failed to create development application: ${JSON.stringify(
|
||||
developmentApplicationResponse.body.errors,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const packageJson = JSON.stringify({
|
||||
name: sourcePath,
|
||||
version: '1.0.0',
|
||||
|
||||
Reference in New Issue
Block a user