diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts index b1c1a4ab33..88b7e31c00 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts @@ -23,7 +23,7 @@ describe('FileStorageService', () => { const mockFileRepository = { save: jest.fn(), - upsert: jest.fn(), + upsertAndReturnOne: jest.fn(), findOneOrFail: jest.fn(), delete: jest.fn(), }; @@ -179,7 +179,11 @@ describe('FileStorageService', () => { id: 'app-id', universalIdentifier: 'app-uid', }); - mockFileRepository.upsert.mockResolvedValue(undefined); + mockFileRepository.upsertAndReturnOne.mockResolvedValue({ + id: 'file-id', + path: 'BuiltFrontComponent/file.mjs', + mimeType: 'application/javascript', + }); mockFileRepository.findOneOrFail.mockResolvedValue({ id: 'file-id', path: 'BuiltFrontComponent/file.mjs', @@ -442,10 +446,10 @@ describe('FileStorageService', () => { expect(mockDriver.writeFile).toHaveBeenCalledWith( expect.objectContaining({ mimeType: 'image/png' }), ); - expect(mockFileRepository.upsert).toHaveBeenCalledWith( + expect(mockFileRepository.upsertAndReturnOne).toHaveBeenCalledWith( 'workspace-123', expect.objectContaining({ mimeType: 'image/png' }), - expect.anything(), + ['path', 'workspaceId', 'applicationId'], ); }); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index f564e2cda9..d42c2c2867 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -170,7 +170,7 @@ export class FileStorageService { sourceFile: persistedSourceFile, }); - await fileRepository.upsert( + return fileRepository.upsertAndReturnOne( workspaceId, { path: filePath, @@ -185,13 +185,6 @@ export class FileStorageService { }, ['path', 'workspaceId', 'applicationId'], ); - - return fileRepository.findOneOrFail(workspaceId, { - where: { - path: filePath, - applicationId: application.id, - }, - }); } async getPresignedUrl( diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/__tests__/workspace-scoped-repository.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/__tests__/workspace-scoped-repository.spec.ts index f58b0eb35d..9efb71ab81 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/__tests__/workspace-scoped-repository.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/__tests__/workspace-scoped-repository.spec.ts @@ -29,6 +29,7 @@ const createMockRepository = (): jest.Mocked> => softDelete: jest.fn(), insert: jest.fn(), upsert: jest.fn(), + create: jest.fn(), save: jest.fn(), createQueryBuilder: jest.fn(), }) as unknown as jest.Mocked>; @@ -65,6 +66,10 @@ describe('WorkspaceScopedRepository', () => { ['softDelete', () => scoped.softDelete(undefined as never, {})], ['insert', () => scoped.insert(undefined as never, {})], ['upsert', () => scoped.upsert(undefined as never, {}, ['id'])], + [ + 'upsertAndReturnOne', + () => scoped.upsertAndReturnOne(undefined as never, {}, ['id']), + ], ['save', () => scoped.save(undefined as never, {})], ['saveMany', () => scoped.saveMany(undefined as never, [{}])], ['maximum', () => scoped.maximum(undefined as never, 'id')], @@ -381,6 +386,44 @@ describe('WorkspaceScopedRepository', () => { }); }); + describe('upsertAndReturnOne', () => { + it('upserts with RETURNING and hydrates the row from generatedMaps', async () => { + const persistedRow = { + id: 'a', + status: 'queued', + workspaceId: WORKSPACE_ID, + }; + + (repository.upsert as jest.Mock).mockResolvedValue({ + generatedMaps: [persistedRow], + }); + (repository.create as jest.Mock).mockReturnValue(persistedRow); + + const result = await scoped.upsertAndReturnOne( + WORKSPACE_ID, + { id: 'a', status: 'queued' }, + ['id'], + ); + + expect(repository.upsert).toHaveBeenCalledWith( + { id: 'a', status: 'queued', workspaceId: WORKSPACE_ID }, + { conflictPaths: ['id'], returning: '*' }, + ); + expect(repository.create).toHaveBeenCalledWith(persistedRow); + expect(result).toBe(persistedRow); + }); + + it('throws instead of returning a hollow entity when no row is returned', async () => { + (repository.upsert as jest.Mock).mockResolvedValue({ generatedMaps: [] }); + + await expect( + scoped.upsertAndReturnOne(WORKSPACE_ID, { id: 'a' }, ['id']), + ).rejects.toThrow(/upsert returned no row/); + + expect(repository.create).not.toHaveBeenCalled(); + }); + }); + describe('save', () => { it('stamps workspaceId on the entity passed to save', async () => { await scoped.save(WORKSPACE_ID, { id: 'a', status: 'queued' }); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts index 715db9c507..11e5be86a8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts @@ -11,6 +11,7 @@ import { type SelectQueryBuilder, type UpdateResult, } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { type UpsertOptions } from 'typeorm/repository/UpsertOptions'; @@ -210,6 +211,31 @@ export class WorkspaceScopedRepository { ); } + upsertAndReturnOne( + workspaceId: string, + entity: QueryDeepPartialEntity, + conflictPaths: string[], + ): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository + .upsert(this.stampWorkspaceIdOnEntities(workspaceId, entity), { + conflictPaths, + returning: '*', + }) + .then(({ generatedMaps }) => { + const [persistedRow] = generatedMaps; + + if (!isDefined(persistedRow)) { + throw new Error( + 'WorkspaceScopedRepository.upsertAndReturnOne: upsert returned no row.', + ); + } + + return this.repository.create(persistedRow as DeepPartial); + }); + } + save>( workspaceId: string, entity: E,