@@ -27,7 +27,7 @@ import { mergeMap } from "rxjs/operators";
2727import filenameSanitizer from "sanitize-filename" ;
2828import { Readable } from "stream" ;
2929import { Throttle } from "stream-throttle" ;
30- import { Repository } from "typeorm" ;
30+ import { IsNull , Not , Repository } from "typeorm" ;
3131import unidecode from "unidecode" ;
3232
3333import { Cron , SchedulerRegistry } from "@nestjs/schedule" ;
@@ -408,70 +408,38 @@ export class FilesService implements OnApplicationBootstrap {
408408 gameId : number ,
409409 indexedGame : GamevaultGame ,
410410 ) : Promise < void > {
411- const query = {
412- where : {
413- game : { id : gameId } ,
411+ const now = new Date ( ) ;
412+
413+ await this . gameVersionRepository
414+ . createQueryBuilder ( )
415+ . insert ( )
416+ . into ( GameVersionEntity )
417+ . values ( {
418+ game : { id : gameId } as GamevaultGame ,
414419 file_path : indexedGame . file_path ,
415- } ,
416- relationLoadStrategy : "query" as const ,
417- relations : [ "game" ] ,
418- withDeleted : true ,
419- } ;
420-
421- let existingVersion = await this . gameVersionRepository . findOne ( query ) ;
422-
423- if ( ! existingVersion ) {
424- const newVersion = new GameVersionEntity ( ) ;
425- newVersion . game = { id : gameId } as GamevaultGame ;
426- newVersion . file_path = indexedGame . file_path ;
427- newVersion . version = indexedGame . version ;
428- newVersion . size = indexedGame . size ;
429- newVersion . release_date = indexedGame . release_date ;
430- newVersion . early_access = indexedGame . early_access ;
431- newVersion . type = indexedGame . type ;
432- newVersion . indexed_at = new Date ( ) ;
433-
434- try {
435- await this . gameVersionRepository . save ( newVersion ) ;
436- return ;
437- } catch ( error ) {
438- if ( ! this . isUniqueConstraintViolation ( error ) ) {
439- throw error ;
440- }
441-
442- existingVersion = await this . gameVersionRepository . findOne ( query ) ;
443- }
444- }
445-
446- if ( ! existingVersion ) {
447- throw new BadRequestException (
448- "Failed to upsert game version due to a concurrent write conflict." ,
449- ) ;
450- }
451-
452- if ( existingVersion . deleted_at ) {
453- await this . gameVersionRepository . recover ( existingVersion ) ;
454- }
455-
456- existingVersion . game = { id : gameId } as GamevaultGame ;
457- existingVersion . file_path = indexedGame . file_path ;
458- existingVersion . version = indexedGame . version ;
459- existingVersion . size = indexedGame . size ;
460- existingVersion . release_date = indexedGame . release_date ;
461- existingVersion . early_access = indexedGame . early_access ;
462- existingVersion . type = indexedGame . type ;
463- existingVersion . indexed_at = new Date ( ) ;
464- await this . gameVersionRepository . save ( existingVersion ) ;
465- }
466-
467- private isUniqueConstraintViolation ( error : unknown ) : boolean {
468- const code = ( error as { code ?: string } ) ?. code ;
469- if ( code === "23505" || code === "SQLITE_CONSTRAINT" ) {
470- return true ;
471- }
472-
473- const message = ( error as { message ?: string } ) ?. message || "" ;
474- return / d u p l i c a t e k e y v a l u e v i o l a t e s u n i q u e c o n s t r a i n t / i. test ( message ) ;
420+ version : indexedGame . version ,
421+ size : indexedGame . size ,
422+ release_date : indexedGame . release_date ,
423+ early_access : indexedGame . early_access ,
424+ type : indexedGame . type ,
425+ indexed_at : now ,
426+ deleted_at : null ,
427+ updated_at : now ,
428+ } )
429+ . orUpdate (
430+ [
431+ "version" ,
432+ "size" ,
433+ "release_date" ,
434+ "early_access" ,
435+ "type" ,
436+ "indexed_at" ,
437+ "deleted_at" ,
438+ "updated_at" ,
439+ ] ,
440+ [ "game_id" , "file_path" ] ,
441+ )
442+ . execute ( ) ;
475443 }
476444
477445 /** Returns all versions from normalized storage, falling back to legacy columns. */
@@ -894,6 +862,8 @@ export class FilesService implements OnApplicationBootstrap {
894862 count : gamesInDatabase . length ,
895863 } ) ;
896864
865+ await this . cleanupDanglingVersionsForDeletedGames ( ) ;
866+
897867 const fsPaths = new Set ( gamesInFileSystem . map ( ( f ) => f . path ) ) ;
898868 const checkedGames : GamevaultGame [ ] = [ ] ;
899869 for ( const gameInDatabase of gamesInDatabase ) {
@@ -902,11 +872,16 @@ export class FilesService implements OnApplicationBootstrap {
902872 where : { game : { id : gameInDatabase . id } } ,
903873 relationLoadStrategy : "query" ,
904874 relations : [ "game" ] ,
905- withDeleted : false ,
875+ withDeleted : true ,
906876 } ) ;
877+
878+ const activePersistedVersions = persistedVersions . filter (
879+ ( version ) => ! version . deleted_at ,
880+ ) ;
881+
907882 const availablePersistedVersions =
908- persistedVersions . length > 0
909- ? persistedVersions . map ( ( version ) =>
883+ activePersistedVersions . length > 0
884+ ? activePersistedVersions . map ( ( version ) =>
910885 Object . assign ( new GameVersionEntity ( ) , {
911886 id : version . id ,
912887 game : version . game ,
@@ -920,13 +895,22 @@ export class FilesService implements OnApplicationBootstrap {
920895 version . indexed_at || version . updated_at || new Date ( ) ,
921896 } ) ,
922897 )
923- : this . normalizeVersions ( gameInDatabase ) ;
898+ : persistedVersions . length > 0
899+ ? [ ]
900+ : this . normalizeVersions ( gameInDatabase ) ;
924901 const existingVersions = availablePersistedVersions . filter ( ( version ) =>
925902 fsPaths . has ( version . file_path ) ,
926903 ) ;
927904
928905 // If none of the versions are available anymore, mark game as deleted.
929906 if ( existingVersions . length === 0 ) {
907+ const activeVersionIds = activePersistedVersions . map (
908+ ( version ) => version . id ,
909+ ) ;
910+ if ( activeVersionIds . length > 0 ) {
911+ await this . gameVersionRepository . softDelete ( activeVersionIds ) ;
912+ }
913+
930914 await this . gamesService . delete ( gameInDatabase . id ) ;
931915 this . logger . log ( {
932916 message : `Game marked as soft-deleted.` ,
@@ -943,7 +927,7 @@ export class FilesService implements OnApplicationBootstrap {
943927 existingVersions . length !== availablePersistedVersions . length ;
944928
945929 if ( versionsChanged ) {
946- const staleVersionIds = persistedVersions
930+ const staleVersionIds = activePersistedVersions
947931 . filter ( ( version ) => ! fsPaths . has ( version . file_path ) )
948932 . map ( ( version ) => version . id ) ;
949933
@@ -982,6 +966,35 @@ export class FilesService implements OnApplicationBootstrap {
982966 return checkedGames ;
983967 }
984968
969+ /** Soft-deletes active versions that still belong to already deleted games. */
970+ private async cleanupDanglingVersionsForDeletedGames ( ) : Promise < void > {
971+ const danglingVersions = await this . gameVersionRepository . find ( {
972+ where : {
973+ deleted_at : IsNull ( ) ,
974+ game : {
975+ deleted_at : Not ( IsNull ( ) ) ,
976+ } ,
977+ } ,
978+ relationLoadStrategy : "query" ,
979+ relations : [ "game" ] ,
980+ withDeleted : true ,
981+ } ) ;
982+
983+ const danglingVersionIds = danglingVersions
984+ . map ( ( version ) => version . id )
985+ . filter ( ( id ) : id is number => Number . isFinite ( id ) ) ;
986+
987+ if ( danglingVersionIds . length === 0 ) {
988+ return ;
989+ }
990+
991+ await this . gameVersionRepository . softDelete ( danglingVersionIds ) ;
992+ this . logger . log ( {
993+ message : "Soft-deleted dangling game versions for already deleted games." ,
994+ count : danglingVersionIds . length ,
995+ } ) ;
996+ }
997+
985998 /** Checks whether a given filename should be included by the indexer. */
986999 private shouldIncludeFile ( filename : string ) : boolean {
9871000 const shouldExclude =
0 commit comments