From 32e9499caf6870cb9b536cadc745799ad2eafdb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 9 Mar 2026 15:35:33 +0100 Subject: [PATCH 1/9] Add onConflict query parameter to POST /locations endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `onConflict` query parameter to the location creation endpoint. When set to 'refresh', a conflict due to an already-registered location triggers a refresh of the existing location entity instead of returning a 409 error. The default behavior ('reject') is unchanged. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- packages/catalog-client/report.api.md | 1 + packages/catalog-client/src/CatalogClient.ts | 7 +- .../openapi/generated/apis/Api.client.ts | 4 +- packages/catalog-client/src/types/api.ts | 6 ++ .../catalog-backend/src/schema/openapi.yaml | 12 +++ .../openapi/generated/apis/Api.server.ts | 1 + .../src/schema/openapi/generated/router.ts | 11 +++ .../src/service/AuthorizedLocationService.ts | 1 + .../src/service/CatalogBuilder.ts | 6 +- .../service/DefaultLocationService.test.ts | 84 ++++++++++++++++++- .../src/service/DefaultLocationService.ts | 57 ++++++++++++- .../src/service/createRouter.ts | 5 ++ plugins/catalog-backend/src/service/types.ts | 1 + 13 files changed, 187 insertions(+), 9 deletions(-) diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index c155084ccd..73eb280d87 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -15,6 +15,7 @@ export type AddLocationRequest = { type?: string; target: string; dryRun?: boolean; + onConflict?: 'refresh' | 'reject'; }; // @public diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a671a155d3..cf363e1823 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -567,12 +567,15 @@ export class CatalogClient implements CatalogApi { request: AddLocationRequest, options?: CatalogRequestOptions, ): Promise { - const { type = 'url', target, dryRun } = request; + const { type = 'url', target, dryRun, onConflict } = request; const response = await this.apiClient.createLocation( { body: { type, target }, - query: { dryRun: dryRun ? 'true' : undefined }, + query: { + dryRun: dryRun ? 'true' : undefined, + onConflict, + }, }, options, ); diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index 63d55aea55..4b3e088fe2 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -178,6 +178,7 @@ export type CreateLocation = { body: CreateLocationRequest; query: { dryRun?: string; + onConflict?: 'refresh' | 'reject'; }; }; /** @@ -592,6 +593,7 @@ export class DefaultApiClient { * Create a location for a given target. * @param createLocationRequest - * @param dryRun - + * @param onConflict - Behavior when the location already exists. \'reject\' (default) returns a 409 error, \'refresh\' triggers a refresh of the existing location entity and returns 201. */ public async createLocation( // @ts-ignore @@ -600,7 +602,7 @@ export class DefaultApiClient { ): Promise> { const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - const uriTemplate = `/locations{?dryRun}`; + const uriTemplate = `/locations{?dryRun,onConflict}`; const uri = parser.parse(uriTemplate).expand({ ...request.query, diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index fac4ed5928..d6d5392fcf 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -396,6 +396,12 @@ export type AddLocationRequest = { * contain the entities that match the given location. */ dryRun?: boolean; + /** + * Behavior when the location already exists. If set to `'reject'` (the + * default), a conflict error is returned. If set to `'refresh'`, the + * existing location entity is marked for refresh and a 201 is returned. + */ + onConflict?: 'refresh' | 'reject'; }; /** diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 57d950b9f8..a13990f021 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1267,6 +1267,18 @@ paths: allowReserved: true schema: type: string + - in: query + name: onConflict + required: false + schema: + type: string + enum: + - refresh + - reject + description: >- + Behavior when the location already exists. 'reject' (default) + returns a 409 error, 'refresh' triggers a refresh of the + existing location entity and returns 201. requestBody: required: true content: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index cbde6365ea..81a952a166 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -172,6 +172,7 @@ export type CreateLocation = { body: CreateLocationRequest; query: { dryRun?: string; + onConflict?: 'refresh' | 'reject'; }; response: CreateLocation201Response | Error | Error; }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index 56ee4cc036..c1ba9c7cf3 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1490,6 +1490,17 @@ export const spec = { type: 'string', }, }, + { + in: 'query', + name: 'onConflict', + required: false, + schema: { + type: 'string', + enum: ['refresh', 'reject'], + }, + description: + "Behavior when the location already exists. 'reject' (default) returns a 409 error, 'refresh' triggers a refresh of the existing location entity and returns 201.", + }, ], requestBody: { required: true, diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts index 83f5118153..1b18d50324 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -46,6 +46,7 @@ export class AuthorizedLocationService implements LocationService { spec: LocationInput, dryRun: boolean, options: { + onConflict?: 'refresh' | 'reject'; credentials: BackstageCredentials; }, ): Promise<{ diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index d0f2442f0c..e2fdaf35c2 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -591,14 +591,18 @@ export class CatalogBuilder { new RepoLocationAnalyzer(logger, integrations, this.locationAnalyzers), permissionsService, ); + const innerRefreshService = new DefaultRefreshService({ + database: catalogDatabase, + }); const locationService = new AuthorizedLocationService( new DefaultLocationService(locationStore, orchestrator, { allowedLocationTypes: this.allowedLocationType, + refreshService: innerRefreshService, }), permissionsService, ); const refreshService = new AuthorizedRefreshService( - new DefaultRefreshService({ database: catalogDatabase }), + innerRefreshService, permissionsService, ); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index 822706ada4..222689e109 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -16,8 +16,8 @@ import { DefaultLocationService } from './DefaultLocationService'; import { CatalogProcessingOrchestrator } from '../processing/types'; -import { LocationStore } from './types'; -import { InputError } from '@backstage/errors'; +import { LocationStore, RefreshService } from './types'; +import { ConflictError, InputError } from '@backstage/errors'; describe('DefaultLocationServiceTest', () => { const orchestrator: jest.Mocked = { @@ -309,6 +309,86 @@ describe('DefaultLocationServiceTest', () => { ).rejects.toThrow(InputError); }); + it('should refresh existing location on conflict when onConflict is refresh', async () => { + const locationSpec = { + type: 'url', + target: 'https://backstage.io/catalog-info.yaml', + }; + + const refreshService: jest.Mocked = { + refresh: jest.fn(), + }; + + const locationServiceWithRefresh = new DefaultLocationService( + store, + orchestrator, + { allowedLocationTypes: ['url'], refreshService }, + ); + + store.createLocation.mockRejectedValueOnce( + new ConflictError( + `Location ${locationSpec.type}:${locationSpec.target} already exists`, + ), + ); + store.getLocationByEntity.mockResolvedValueOnce({ + id: 'existing-id', + ...locationSpec, + }); + + const credentials = {} as any; + const result = await locationServiceWithRefresh.createLocation( + locationSpec, + false, + { onConflict: 'refresh', credentials }, + ); + + expect(result).toEqual({ + location: { id: 'existing-id', ...locationSpec }, + entities: [], + }); + expect(refreshService.refresh).toHaveBeenCalledWith({ + entityRef: expect.stringMatching(/^location:default\/generated-/), + credentials, + }); + }); + + it('should still throw conflict error when onConflict is reject', async () => { + const locationSpec = { + type: 'url', + target: 'https://backstage.io/catalog-info.yaml', + }; + + store.createLocation.mockRejectedValueOnce( + new ConflictError( + `Location ${locationSpec.type}:${locationSpec.target} already exists`, + ), + ); + + await expect( + locationService.createLocation(locationSpec, false, { + onConflict: 'reject', + credentials: {} as any, + }), + ).rejects.toThrow(ConflictError); + }); + + it('should throw conflict error by default when onConflict is not set', async () => { + const locationSpec = { + type: 'url', + target: 'https://backstage.io/catalog-info.yaml', + }; + + store.createLocation.mockRejectedValueOnce( + new ConflictError( + `Location ${locationSpec.type}:${locationSpec.target} already exists`, + ), + ); + + await expect( + locationService.createLocation(locationSpec, false), + ).rejects.toThrow(ConflictError); + }); + it('should return default InputError for failed processed entities in dryRun mode', async () => { store.listLocations.mockResolvedValueOnce([]); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index ad3044ffd9..4057068ff7 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -24,15 +24,21 @@ import { } from '@backstage/catalog-model'; import { Location } from '@backstage/catalog-client'; import { CatalogProcessingOrchestrator } from '../processing/types'; -import { LocationInput, LocationService, LocationStore } from './types'; +import { + LocationInput, + LocationService, + LocationStore, + RefreshService, +} from './types'; import { locationSpecToMetadataName } from '../util/conversion'; -import { InputError } from '@backstage/errors'; +import { ConflictError, InputError } from '@backstage/errors'; import { DeferredEntity } from '@backstage/plugin-catalog-node'; import { FilterPredicate } from '@backstage/filter-predicates'; import { BackstageCredentials } from '@backstage/backend-plugin-api'; export type DefaultLocationServiceOptions = { allowedLocationTypes: string[]; + refreshService?: RefreshService; }; export class DefaultLocationService implements LocationService { @@ -55,6 +61,10 @@ export class DefaultLocationService implements LocationService { async createLocation( input: LocationInput, dryRun: boolean, + options?: { + onConflict?: 'refresh' | 'reject'; + credentials?: BackstageCredentials; + }, ): Promise<{ location: Location; entities: Entity[]; exists?: boolean }> { if (!this.options.allowedLocationTypes.includes(input.type)) { throw new InputError( @@ -66,7 +76,48 @@ export class DefaultLocationService implements LocationService { if (dryRun) { return this.dryRunCreateLocation(input); } - const location = await this.store.createLocation(input); + try { + const location = await this.store.createLocation(input); + return { location, entities: [] }; + } catch (error) { + if (options?.onConflict === 'refresh' && error instanceof ConflictError) { + return this.refreshExistingLocation(input, options); + } + throw error; + } + } + + private async refreshExistingLocation( + input: LocationInput, + options: { credentials?: BackstageCredentials }, + ): Promise<{ location: Location; entities: Entity[] }> { + const { refreshService } = this.options; + if (!refreshService) { + throw new InputError( + 'onConflict refresh is not supported: no refresh service available', + ); + } + if (!options.credentials) { + throw new InputError( + 'onConflict refresh requires credentials to be provided', + ); + } + + const location = await this.store.getLocationByEntity( + `location:default/${locationSpecToMetadataName(input)}`, + ); + + const entityRef = stringifyEntityRef({ + kind: 'Location', + namespace: 'default', + name: locationSpecToMetadataName(input), + }); + + await refreshService.refresh({ + entityRef, + credentials: options.credentials, + }); + return { location, entities: [] }; } diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 1a7f1bf6fc..8a173e98e1 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -606,6 +606,10 @@ export async function createRouter( .post('/locations', async (req, res) => { const location = await validateRequestBody(req, locationInput); const dryRun = yn(req.query.dryRun, { default: false }); + const onConflict = req.query.onConflict as + | 'refresh' + | 'reject' + | undefined; const auditorEvent = await auditor.createEvent({ eventId: 'location-mutate', @@ -629,6 +633,7 @@ export async function createRouter( location, dryRun, { + onConflict, credentials: await httpAuth.credentials(req), }, ); diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index f452484b72..787eecb497 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -35,6 +35,7 @@ export interface LocationService { location: LocationInput, dryRun: boolean, options: { + onConflict?: 'refresh' | 'reject'; credentials: BackstageCredentials; }, ): Promise<{ location: Location; entities: Entity[]; exists?: boolean }>; From 5d95e8e7ac0261b730fac8dbbc73dfabaf84f3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 9 Mar 2026 15:41:34 +0100 Subject: [PATCH 2/9] add changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/eight-poems-take.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/eight-poems-take.md diff --git a/.changeset/eight-poems-take.md b/.changeset/eight-poems-take.md new file mode 100644 index 0000000000..2d04d3806f --- /dev/null +++ b/.changeset/eight-poems-take.md @@ -0,0 +1,6 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-backend': minor +--- + +Add an `onConflict` option to location creation, that enables it to perform refreshes instead of throwing conflict errors when there's a pre-existing location already From ced73ba3a0d88263b6cef047c568b81922bdbe77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 9 Mar 2026 16:21:29 +0100 Subject: [PATCH 3/9] Move onConflict refresh logic into DefaultLocationStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store now owns the conflict resolution and refresh logic directly, keeping DefaultLocationService as a thin pass-through layer. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../src/providers/DefaultLocationStore.ts | 57 +++++++++-- .../src/service/CatalogBuilder.ts | 2 +- .../service/DefaultLocationService.test.ts | 95 +++++-------------- .../src/service/DefaultLocationService.ts | 54 +---------- plugins/catalog-backend/src/service/types.ts | 8 +- 5 files changed, 85 insertions(+), 131 deletions(-) diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index bfd4270696..233f5f5273 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -29,13 +29,14 @@ import { EntityProviderConnection, } from '@backstage/plugin-catalog-node'; import { locationSpecToLocationEntity } from '../util/conversion'; -import { LocationInput, LocationStore } from '../service/types'; +import { LocationInput, LocationStore, RefreshService } from '../service/types'; import { ANNOTATION_ORIGIN_LOCATION, CompoundEntityRef, parseLocationRef, stringifyEntityRef, } from '@backstage/catalog-model'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { CatalogScmEvent, CatalogScmEventsService, @@ -53,6 +54,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { private readonly db: Knex; private readonly scmEvents: CatalogScmEventsService; private readonly scmEventHandlingConfig: ScmEventHandlingConfig; + private refreshService?: RefreshService; constructor( db: Knex, @@ -64,20 +66,36 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { this.scmEventHandlingConfig = scmEventHandlingConfig; } + setRefreshService(refreshService: RefreshService): void { + this.refreshService = refreshService; + } + getProviderName(): string { return 'DefaultLocationStore'; } - async createLocation(input: LocationInput): Promise { + async createLocation( + input: LocationInput, + options?: { + onConflict?: 'refresh' | 'reject'; + credentials?: BackstageCredentials; + }, + ): Promise { + let existed = false; + const location = await this.db.transaction(async tx => { // Attempt to find a previous location matching the input const previousLocations = await this.locations(tx); // TODO: when location id's are a compilation of input target we can remove this full // lookup of locations first and just grab the by that instead. - const previousLocation = previousLocations.some( + const previousLocation = previousLocations.find( l => input.type === l.type && input.target === l.target, ); if (previousLocation) { + if (options?.onConflict === 'refresh') { + existed = true; + return previousLocation; + } throw new ConflictError( `Location ${input.type}:${input.target} already exists`, ); @@ -93,12 +111,33 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { return inner; }); - const entity = locationSpecToLocationEntity({ location }); - await this.connection.applyMutation({ - type: 'delta', - added: [{ entity, locationKey: getEntityLocationRef(entity) }], - removed: [], - }); + + if (existed) { + if (!this.refreshService) { + throw new InputError( + 'onConflict refresh is not supported: no refresh service available', + ); + } + if (!options?.credentials) { + throw new InputError( + 'onConflict refresh requires credentials to be provided', + ); + } + const entityRef = stringifyEntityRef( + locationSpecToLocationEntity({ location }), + ); + await this.refreshService.refresh({ + entityRef, + credentials: options.credentials, + }); + } else { + const entity = locationSpecToLocationEntity({ location }); + await this.connection.applyMutation({ + type: 'delta', + added: [{ entity, locationKey: getEntityLocationRef(entity) }], + removed: [], + }); + } return location; } diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index e2fdaf35c2..5cc06b5e75 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -594,10 +594,10 @@ export class CatalogBuilder { const innerRefreshService = new DefaultRefreshService({ database: catalogDatabase, }); + locationStore.setRefreshService(innerRefreshService); const locationService = new AuthorizedLocationService( new DefaultLocationService(locationStore, orchestrator, { allowedLocationTypes: this.allowedLocationType, - refreshService: innerRefreshService, }), permissionsService, ); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index 222689e109..2071836c60 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -16,8 +16,8 @@ import { DefaultLocationService } from './DefaultLocationService'; import { CatalogProcessingOrchestrator } from '../processing/types'; -import { LocationStore, RefreshService } from './types'; -import { ConflictError, InputError } from '@backstage/errors'; +import { LocationStore } from './types'; +import { InputError } from '@backstage/errors'; describe('DefaultLocationServiceTest', () => { const orchestrator: jest.Mocked = { @@ -257,10 +257,13 @@ describe('DefaultLocationServiceTest', () => { type: 'url', }, }); - expect(store.createLocation).toHaveBeenCalledWith({ - target: 'https://backstage.io/catalog-info.yaml', - type: 'url', - }); + expect(store.createLocation).toHaveBeenCalledWith( + { + target: 'https://backstage.io/catalog-info.yaml', + type: 'url', + }, + { onConflict: undefined }, + ); }); it('should create location with unknown type if configuration allows it', async () => { @@ -291,10 +294,13 @@ describe('DefaultLocationServiceTest', () => { type: 'unknown', }, }); - expect(store.createLocation).toHaveBeenCalledWith({ - target: 'https://backstage.io/catalog-info.yaml', - type: 'unknown', - }); + expect(store.createLocation).toHaveBeenCalledWith( + { + target: 'https://backstage.io/catalog-info.yaml', + type: 'unknown', + }, + { onConflict: undefined }, + ); }); it('should not allow locations of unknown types by default', async () => { @@ -309,86 +315,33 @@ describe('DefaultLocationServiceTest', () => { ).rejects.toThrow(InputError); }); - it('should refresh existing location on conflict when onConflict is refresh', async () => { + it('should pass onConflict and credentials through to store', async () => { const locationSpec = { type: 'url', target: 'https://backstage.io/catalog-info.yaml', }; - const refreshService: jest.Mocked = { - refresh: jest.fn(), - }; - - const locationServiceWithRefresh = new DefaultLocationService( - store, - orchestrator, - { allowedLocationTypes: ['url'], refreshService }, - ); - - store.createLocation.mockRejectedValueOnce( - new ConflictError( - `Location ${locationSpec.type}:${locationSpec.target} already exists`, - ), - ); - store.getLocationByEntity.mockResolvedValueOnce({ + store.createLocation.mockResolvedValueOnce({ id: 'existing-id', ...locationSpec, }); const credentials = {} as any; - const result = await locationServiceWithRefresh.createLocation( - locationSpec, - false, - { onConflict: 'refresh', credentials }, - ); + const result = await locationService.createLocation(locationSpec, false, { + onConflict: 'refresh', + credentials, + }); expect(result).toEqual({ location: { id: 'existing-id', ...locationSpec }, entities: [], }); - expect(refreshService.refresh).toHaveBeenCalledWith({ - entityRef: expect.stringMatching(/^location:default\/generated-/), + expect(store.createLocation).toHaveBeenCalledWith(locationSpec, { + onConflict: 'refresh', credentials, }); }); - it('should still throw conflict error when onConflict is reject', async () => { - const locationSpec = { - type: 'url', - target: 'https://backstage.io/catalog-info.yaml', - }; - - store.createLocation.mockRejectedValueOnce( - new ConflictError( - `Location ${locationSpec.type}:${locationSpec.target} already exists`, - ), - ); - - await expect( - locationService.createLocation(locationSpec, false, { - onConflict: 'reject', - credentials: {} as any, - }), - ).rejects.toThrow(ConflictError); - }); - - it('should throw conflict error by default when onConflict is not set', async () => { - const locationSpec = { - type: 'url', - target: 'https://backstage.io/catalog-info.yaml', - }; - - store.createLocation.mockRejectedValueOnce( - new ConflictError( - `Location ${locationSpec.type}:${locationSpec.target} already exists`, - ), - ); - - await expect( - locationService.createLocation(locationSpec, false), - ).rejects.toThrow(ConflictError); - }); - it('should return default InputError for failed processed entities in dryRun mode', async () => { store.listLocations.mockResolvedValueOnce([]); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index 4057068ff7..99c2ffa16d 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -24,21 +24,15 @@ import { } from '@backstage/catalog-model'; import { Location } from '@backstage/catalog-client'; import { CatalogProcessingOrchestrator } from '../processing/types'; -import { - LocationInput, - LocationService, - LocationStore, - RefreshService, -} from './types'; +import { LocationInput, LocationService, LocationStore } from './types'; import { locationSpecToMetadataName } from '../util/conversion'; -import { ConflictError, InputError } from '@backstage/errors'; +import { InputError } from '@backstage/errors'; import { DeferredEntity } from '@backstage/plugin-catalog-node'; import { FilterPredicate } from '@backstage/filter-predicates'; import { BackstageCredentials } from '@backstage/backend-plugin-api'; export type DefaultLocationServiceOptions = { allowedLocationTypes: string[]; - refreshService?: RefreshService; }; export class DefaultLocationService implements LocationService { @@ -76,48 +70,10 @@ export class DefaultLocationService implements LocationService { if (dryRun) { return this.dryRunCreateLocation(input); } - try { - const location = await this.store.createLocation(input); - return { location, entities: [] }; - } catch (error) { - if (options?.onConflict === 'refresh' && error instanceof ConflictError) { - return this.refreshExistingLocation(input, options); - } - throw error; - } - } - - private async refreshExistingLocation( - input: LocationInput, - options: { credentials?: BackstageCredentials }, - ): Promise<{ location: Location; entities: Entity[] }> { - const { refreshService } = this.options; - if (!refreshService) { - throw new InputError( - 'onConflict refresh is not supported: no refresh service available', - ); - } - if (!options.credentials) { - throw new InputError( - 'onConflict refresh requires credentials to be provided', - ); - } - - const location = await this.store.getLocationByEntity( - `location:default/${locationSpecToMetadataName(input)}`, - ); - - const entityRef = stringifyEntityRef({ - kind: 'Location', - namespace: 'default', - name: locationSpecToMetadataName(input), + const location = await this.store.createLocation(input, { + onConflict: options?.onConflict, + credentials: options?.credentials, }); - - await refreshService.refresh({ - entityRef, - credentials: options.credentials, - }); - return { location, entities: [] }; } diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index 787eecb497..33b37c8f3f 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -85,7 +85,13 @@ export interface RefreshService { * Interacts with the database to manage locations. */ export interface LocationStore { - createLocation(location: LocationInput): Promise; + createLocation( + location: LocationInput, + options?: { + onConflict?: 'refresh' | 'reject'; + credentials?: BackstageCredentials; + }, + ): Promise; listLocations(): Promise; queryLocations(options: { limit: number; From 67fa7055155f15d0920c8f9b9e2cd12ab9401560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 9 Mar 2026 16:37:50 +0100 Subject: [PATCH 4/9] Use direct DB update for onConflict refresh instead of RefreshService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify by updating refresh_state directly (next_update_at=now, result_hash='') to force reprocessing, removing the need for RefreshService wiring in the location store. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../src/providers/DefaultLocationStore.ts | 29 +++++-------------- .../src/service/CatalogBuilder.ts | 6 +--- .../service/DefaultLocationService.test.ts | 6 ++-- .../src/service/DefaultLocationService.ts | 1 - plugins/catalog-backend/src/service/types.ts | 1 - 5 files changed, 10 insertions(+), 33 deletions(-) diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index 233f5f5273..ab9ffdefbb 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -29,14 +29,13 @@ import { EntityProviderConnection, } from '@backstage/plugin-catalog-node'; import { locationSpecToLocationEntity } from '../util/conversion'; -import { LocationInput, LocationStore, RefreshService } from '../service/types'; +import { LocationInput, LocationStore } from '../service/types'; import { ANNOTATION_ORIGIN_LOCATION, CompoundEntityRef, parseLocationRef, stringifyEntityRef, } from '@backstage/catalog-model'; -import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { CatalogScmEvent, CatalogScmEventsService, @@ -54,7 +53,6 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { private readonly db: Knex; private readonly scmEvents: CatalogScmEventsService; private readonly scmEventHandlingConfig: ScmEventHandlingConfig; - private refreshService?: RefreshService; constructor( db: Knex, @@ -66,10 +64,6 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { this.scmEventHandlingConfig = scmEventHandlingConfig; } - setRefreshService(refreshService: RefreshService): void { - this.refreshService = refreshService; - } - getProviderName(): string { return 'DefaultLocationStore'; } @@ -78,7 +72,6 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { input: LocationInput, options?: { onConflict?: 'refresh' | 'reject'; - credentials?: BackstageCredentials; }, ): Promise { let existed = false; @@ -113,23 +106,15 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { }); if (existed) { - if (!this.refreshService) { - throw new InputError( - 'onConflict refresh is not supported: no refresh service available', - ); - } - if (!options?.credentials) { - throw new InputError( - 'onConflict refresh requires credentials to be provided', - ); - } const entityRef = stringifyEntityRef( locationSpecToLocationEntity({ location }), ); - await this.refreshService.refresh({ - entityRef, - credentials: options.credentials, - }); + await this.db('refresh_state') + .where({ entity_ref: entityRef }) + .update({ + next_update_at: this.db.fn.now(), + result_hash: '', + }); } else { const entity = locationSpecToLocationEntity({ location }); await this.connection.applyMutation({ diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 5cc06b5e75..d0f2442f0c 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -591,10 +591,6 @@ export class CatalogBuilder { new RepoLocationAnalyzer(logger, integrations, this.locationAnalyzers), permissionsService, ); - const innerRefreshService = new DefaultRefreshService({ - database: catalogDatabase, - }); - locationStore.setRefreshService(innerRefreshService); const locationService = new AuthorizedLocationService( new DefaultLocationService(locationStore, orchestrator, { allowedLocationTypes: this.allowedLocationType, @@ -602,7 +598,7 @@ export class CatalogBuilder { permissionsService, ); const refreshService = new AuthorizedRefreshService( - innerRefreshService, + new DefaultRefreshService({ database: catalogDatabase }), permissionsService, ); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index 2071836c60..910a538031 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -315,7 +315,7 @@ describe('DefaultLocationServiceTest', () => { ).rejects.toThrow(InputError); }); - it('should pass onConflict and credentials through to store', async () => { + it('should pass onConflict through to store', async () => { const locationSpec = { type: 'url', target: 'https://backstage.io/catalog-info.yaml', @@ -326,10 +326,9 @@ describe('DefaultLocationServiceTest', () => { ...locationSpec, }); - const credentials = {} as any; const result = await locationService.createLocation(locationSpec, false, { onConflict: 'refresh', - credentials, + credentials: {} as any, }); expect(result).toEqual({ @@ -338,7 +337,6 @@ describe('DefaultLocationServiceTest', () => { }); expect(store.createLocation).toHaveBeenCalledWith(locationSpec, { onConflict: 'refresh', - credentials, }); }); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index 99c2ffa16d..fe3d8c96a7 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -72,7 +72,6 @@ export class DefaultLocationService implements LocationService { } const location = await this.store.createLocation(input, { onConflict: options?.onConflict, - credentials: options?.credentials, }); return { location, entities: [] }; } diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index 33b37c8f3f..b9c1214acb 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -89,7 +89,6 @@ export interface LocationStore { location: LocationInput, options?: { onConflict?: 'refresh' | 'reject'; - credentials?: BackstageCredentials; }, ): Promise; listLocations(): Promise; From e8dc06d2e94101d3da75cdf2171922aba939e058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 9 Mar 2026 16:40:48 +0100 Subject: [PATCH 5/9] Clean up test assertions for createLocation options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../src/service/DefaultLocationService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index 910a538031..db95b0f19a 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -262,7 +262,7 @@ describe('DefaultLocationServiceTest', () => { target: 'https://backstage.io/catalog-info.yaml', type: 'url', }, - { onConflict: undefined }, + expect.anything(), ); }); @@ -299,7 +299,7 @@ describe('DefaultLocationServiceTest', () => { target: 'https://backstage.io/catalog-info.yaml', type: 'unknown', }, - { onConflict: undefined }, + expect.anything(), ); }); From 05a3e13b8807aa5f04e8a9606343d522894bcc7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 9 Mar 2026 16:52:05 +0100 Subject: [PATCH 6/9] Add test for onConflict refresh updating refresh_state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .../providers/DefaultLocationStore.test.ts | 48 ++++++++++++++++++- .../src/providers/DefaultLocationStore.ts | 22 +++++---- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts index e1bd8ec386..d1cd6e7c5e 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts @@ -15,7 +15,10 @@ */ import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; -import { ANNOTATION_ORIGIN_LOCATION } from '@backstage/catalog-model'; +import { + ANNOTATION_ORIGIN_LOCATION, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { v4 as uuid } from 'uuid'; import { applyDatabaseMigrations } from '../database/migrations'; import { @@ -25,6 +28,7 @@ import { DbSearchRow, } from '../database/tables'; import { DefaultLocationStore } from './DefaultLocationStore'; +import { locationSpecToLocationEntity } from '../util/conversion'; import { CatalogScmEventsServiceSubscriber } from '@backstage/plugin-catalog-node/alpha'; import waitFor from 'wait-for-expect'; @@ -154,6 +158,48 @@ describe('DefaultLocationStore', () => { }); }, ); + + it.each(databases.eachSupportedId())( + 'updates refresh_state when onConflict is refresh, %p', + async databaseId => { + const { store, knex } = await createLocationStore(databaseId); + const spec = { + type: 'url', + target: + 'https://github.com/backstage/demo/blob/master/catalog-info.yml', + }; + + // Create the location initially + await store.createLocation(spec); + + // Seed a refresh_state row for the corresponding Location entity + const entity = locationSpecToLocationEntity({ location: spec }); + const entityRef = stringifyEntityRef(entity); + const entityId = uuid(); + const oldDate = new Date('2020-01-01T00:00:00Z'); + await knex('refresh_state').insert({ + entity_id: entityId, + entity_ref: entityRef, + unprocessed_entity: '{}', + errors: '[]', + next_update_at: oldDate, + last_discovery_at: oldDate, + result_hash: 'old-hash', + }); + + // Re-register the same location with onConflict: 'refresh' + await store.createLocation(spec, { onConflict: 'refresh' }); + + // Verify that the refresh_state row was updated + const [row] = await knex('refresh_state').where({ + entity_ref: entityRef, + }); + expect(row.result_hash).toBe(''); + expect(new Date(row.next_update_at).getTime()).toBeGreaterThan( + oldDate.getTime(), + ); + }, + ); }); describe('deleteLocation', () => { diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index ab9ffdefbb..8619d13459 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -105,23 +105,25 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { return inner; }); + // Always upsert the entity, even if the location already existed, to + // recover from cases where the entity was inadvertently deleted. + const entity = locationSpecToLocationEntity({ location }); + await this.connection.applyMutation({ + type: 'delta', + added: [{ entity, locationKey: getEntityLocationRef(entity) }], + removed: [], + }); + if (existed) { - const entityRef = stringifyEntityRef( - locationSpecToLocationEntity({ location }), - ); + // This is the "onConflict refresh" case, where a re-registration safely + // tries to recover from a bad state. + const entityRef = stringifyEntityRef(entity); await this.db('refresh_state') .where({ entity_ref: entityRef }) .update({ next_update_at: this.db.fn.now(), result_hash: '', }); - } else { - const entity = locationSpecToLocationEntity({ location }); - await this.connection.applyMutation({ - type: 'delta', - added: [{ entity, locationKey: getEntityLocationRef(entity) }], - removed: [], - }); } return location; From d3796b6165b7f1192c06f80ee5a3c173cb5c4cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 9 Mar 2026 17:20:23 +0100 Subject: [PATCH 7/9] Fix OpenAPI spec linting errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- plugins/catalog-backend/src/schema/openapi.yaml | 4 ++++ .../src/schema/openapi/generated/router.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index a13990f021..d0266e4d46 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -25,6 +25,9 @@ info: name: Apache-2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html contact: {} +tags: + - name: Entity + - name: Locations servers: - url: / components: @@ -1270,6 +1273,7 @@ paths: - in: query name: onConflict required: false + allowReserved: true schema: type: string enum: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index c1ba9c7cf3..6b92c7ae63 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -33,6 +33,14 @@ export const spec = { }, contact: {}, }, + tags: [ + { + name: 'Entity', + }, + { + name: 'Locations', + }, + ], servers: [ { url: '/', @@ -1494,6 +1502,7 @@ export const spec = { in: 'query', name: 'onConflict', required: false, + allowReserved: true, schema: { type: 'string', enum: ['refresh', 'reject'], From dfdb8e3a730e6d9fe6ba45d65a10d7a09fa50e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 10 Mar 2026 14:12:20 +0100 Subject: [PATCH 8/9] Add defaultLocationConflictStrategy config option for catalog locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a catalog config option to set the default conflict strategy when registering locations, so adopters can default to 'refresh' instead of 'reject' without requiring each caller to specify it explicitly. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Fredrik Adelöw --- .changeset/eight-poems-take.md | 2 +- plugins/catalog-backend/config.d.ts | 9 +++++++++ plugins/catalog-backend/src/service/CatalogBuilder.ts | 4 ++++ .../src/service/DefaultLocationService.test.ts | 1 + .../src/service/DefaultLocationService.ts | 5 ++++- plugins/catalog-backend/src/service/createRouter.test.ts | 5 ++++- plugins/catalog-backend/src/service/types.ts | 2 +- 7 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.changeset/eight-poems-take.md b/.changeset/eight-poems-take.md index 2d04d3806f..00f1d09466 100644 --- a/.changeset/eight-poems-take.md +++ b/.changeset/eight-poems-take.md @@ -3,4 +3,4 @@ '@backstage/plugin-catalog-backend': minor --- -Add an `onConflict` option to location creation, that enables it to perform refreshes instead of throwing conflict errors when there's a pre-existing location already +Add an `onConflict` option to location creation that can refresh an existing location instead of throwing a conflict error. diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts index 48dae2d747..433d8c494c 100644 --- a/plugins/catalog-backend/config.d.ts +++ b/plugins/catalog-backend/config.d.ts @@ -191,6 +191,15 @@ export interface Config { stitchTimeout?: HumanDuration | string; }; + /** + * The strategy to use when there is a conflict with a location being registered. + * + * The default value is "reject". + * + * The "refresh" strategy will refresh the existing location instead of throwing a conflict error. + */ + defaultLocationConflictStrategy?: 'refresh' | 'reject'; + /** * The interval at which the catalog should process its entities. * @remarks diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index d0f2442f0c..d3bf47874d 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -594,6 +594,10 @@ export class CatalogBuilder { const locationService = new AuthorizedLocationService( new DefaultLocationService(locationStore, orchestrator, { allowedLocationTypes: this.allowedLocationType, + defaultLocationConflictStrategy: + (config.getOptionalString( + 'catalog.defaultLocationConflictStrategy', + ) as 'refresh' | 'reject') || 'reject', }), permissionsService, ); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index db95b0f19a..3cce0efc3f 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -282,6 +282,7 @@ describe('DefaultLocationServiceTest', () => { orchestrator, { allowedLocationTypes: ['url', 'unknown'], + defaultLocationConflictStrategy: 'reject', }, ); await expect( diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index fe3d8c96a7..7effaa2e88 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -33,6 +33,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; export type DefaultLocationServiceOptions = { allowedLocationTypes: string[]; + defaultLocationConflictStrategy: 'refresh' | 'reject'; }; export class DefaultLocationService implements LocationService { @@ -45,6 +46,7 @@ export class DefaultLocationService implements LocationService { orchestrator: CatalogProcessingOrchestrator, options: DefaultLocationServiceOptions = { allowedLocationTypes: ['url'], + defaultLocationConflictStrategy: 'reject', }, ) { this.store = store; @@ -71,7 +73,8 @@ export class DefaultLocationService implements LocationService { return this.dryRunCreateLocation(input); } const location = await this.store.createLocation(input, { - onConflict: options?.onConflict, + onConflict: + options?.onConflict ?? this.options.defaultLocationConflictStrategy, }); return { location, entities: [] }; } diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 1984f2f04f..88cd5abde3 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -1685,7 +1685,10 @@ describe('POST /locations/by-query works end to end', () => { const locationService = new DefaultLocationService( store, { process: jest.fn() }, - { allowedLocationTypes: ['url'] }, + { + allowedLocationTypes: ['url'], + defaultLocationConflictStrategy: 'reject', + }, ); const router = await createRouter({ diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index b9c1214acb..c949da1eda 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -88,7 +88,7 @@ export interface LocationStore { createLocation( location: LocationInput, options?: { - onConflict?: 'refresh' | 'reject'; + onConflict: 'refresh' | 'reject'; }, ): Promise; listLocations(): Promise; From 5dc320ed5e540cf1c7d0f3dc7bc9b1c32e9e1072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 10 Mar 2026 14:39:01 +0100 Subject: [PATCH 9/9] remove unnecessary cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-backend/src/service/createRouter.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 8a173e98e1..89377c9e12 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -606,10 +606,7 @@ export async function createRouter( .post('/locations', async (req, res) => { const location = await validateRequestBody(req, locationInput); const dryRun = yn(req.query.dryRun, { default: false }); - const onConflict = req.query.onConflict as - | 'refresh' - | 'reject' - | undefined; + const onConflict = req.query.onConflict; const auditorEvent = await auditor.createEvent({ eventId: 'location-mutate',