From e6a4a06e0e9fbee92c1c57f0e74b402661ba748e Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:11:20 +0000 Subject: [PATCH 1/3] feat(#4313): apply connector config defaults at resolve time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional `defaultValue` field to `ConfigFieldMeta` and apply it as a third precedence layer in `RuntimeConfigResolver`: DB override → YAML baseline → field default → undefined. Default values per OpenSpec task 1.8: - schedule.intervalMs: 300000 (5 min) — jira, github, gitlab - batchSize: 100 — jira, github, gitlab - timeout.connectionMs: 30000 (30 s) — jira only Defaults are NOT added via Zod `.default()` to preserve "unset" semantics: `validateConfigValue(key, undefined)` still returns undefined, and resolver precedence is respected. Instead, the `defaultValue` property on `ConfigFieldMeta` is read by a new `getFieldDefault()` helper, which the resolver calls when neither DB nor YAML provides a value. Fields without defaults (`enabled`, `endpoint`, `schedule.cron`) continue to return `undefined` when absent. Bumps BOOST_CONFIG_SCHEMA_VERSION from 4 to 5. Closes #4313 --- .../boost/plugins/boost-backend/report.api.md | 10 +- .../src/config/RuntimeConfigResolver.test.ts | 274 ++++++++++++++++++ .../src/config/RuntimeConfigResolver.ts | 30 +- .../plugins/boost-backend/src/config/index.ts | 1 + .../boost-backend/src/config/schemas.test.ts | 51 +++- .../boost-backend/src/config/schemas.ts | 27 +- workspaces/boost/plugins/boost/report.api.md | 40 +-- 7 files changed, 403 insertions(+), 30 deletions(-) diff --git a/workspaces/boost/plugins/boost-backend/report.api.md b/workspaces/boost/plugins/boost-backend/report.api.md index 8500bcd5bbb..8f1c51a52f6 100644 --- a/workspaces/boost/plugins/boost-backend/report.api.md +++ b/workspaces/boost/plugins/boost-backend/report.api.md @@ -119,7 +119,7 @@ export interface BackendApprovalStoreOptions { } // @public -export const BOOST_CONFIG_SCHEMA_VERSION = 4; +export const BOOST_CONFIG_SCHEMA_VERSION = 5; // @public export const boostAiProviderServiceFactory: ServiceFactory< @@ -235,6 +235,7 @@ export const boostConfigFields: { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 300000; }; readonly 'boost.connectors.jira.schedule.cron': { readonly schema: z.ZodOptional>; @@ -245,11 +246,13 @@ export const boostConfigFields: { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 100; }; readonly 'boost.connectors.jira.timeout.connectionMs': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 30000; }; readonly 'boost.connectors.github.enabled': { readonly schema: z.ZodOptional; @@ -265,11 +268,13 @@ export const boostConfigFields: { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 300000; }; readonly 'boost.connectors.github.batchSize': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 100; }; readonly 'boost.connectors.gitlab.enabled': { readonly schema: z.ZodOptional; @@ -285,11 +290,13 @@ export const boostConfigFields: { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 300000; }; readonly 'boost.connectors.gitlab.batchSize': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; + readonly defaultValue: 100; }; }; @@ -318,6 +325,7 @@ export interface ClassifyOptions { // @public export interface ConfigFieldMeta { configScope: ConfigScope; + defaultValue?: z.output; description: string; schema: T; sensitive?: boolean; diff --git a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.test.ts b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.test.ts index 00d569f8ace..a8c4b6098af 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.test.ts @@ -587,4 +587,278 @@ describe('RuntimeConfigResolver', () => { expect(allConfig.get('boost.connectors.gitlab.enabled')).toBe(true); }); }); + + describe('field defaults', () => { + it('returns field default when DB and YAML are both unset', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect( + await resolver.resolve('boost.connectors.jira.schedule.intervalMs'), + ).toBe(300000); + expect( + await resolver.resolve('boost.connectors.github.schedule.intervalMs'), + ).toBe(300000); + expect( + await resolver.resolve('boost.connectors.gitlab.schedule.intervalMs'), + ).toBe(300000); + }); + + it('returns batchSize default when DB and YAML are both unset', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 100, + ); + expect(await resolver.resolve('boost.connectors.github.batchSize')).toBe( + 100, + ); + expect(await resolver.resolve('boost.connectors.gitlab.batchSize')).toBe( + 100, + ); + }); + + it('returns timeout.connectionMs default for Jira when unset', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect( + await resolver.resolve('boost.connectors.jira.timeout.connectionMs'), + ).toBe(30000); + }); + + it('YAML value takes precedence over field default', async () => { + const config = createMockConfig({ + boost: { + connectors: { + jira: { + schedule: { intervalMs: 600000 }, + batchSize: 50, + timeout: { connectionMs: 15000 }, + }, + }, + }, + }); + + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect( + await resolver.resolve('boost.connectors.jira.schedule.intervalMs'), + ).toBe(600000); + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 50, + ); + expect( + await resolver.resolve('boost.connectors.jira.timeout.connectionMs'), + ).toBe(15000); + }); + + it('DB override takes precedence over YAML and field default', async () => { + const config = createMockConfig({ + boost: { + connectors: { + jira: { + schedule: { intervalMs: 600000 }, + batchSize: 50, + }, + }, + }, + }); + + const dbOverrides = new Map([ + ['boost.connectors.jira.schedule.intervalMs', 120000], + ['boost.connectors.jira.batchSize', 25], + ]); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(dbOverrides), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + expect( + await resolver.resolve('boost.connectors.jira.schedule.intervalMs'), + ).toBe(120000); + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 25, + ); + }); + + it('falls back to default after removeOverride when no YAML', async () => { + const config = createMockConfig({}); + + let dbOverrides = new Map([ + ['boost.connectors.jira.batchSize', 25], + ]); + const adminConfigService = { + getAllOverrides: jest.fn().mockImplementation(async () => dbOverrides), + removeOverride: jest.fn().mockImplementation(async () => { + dbOverrides = new Map(); + }), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + // DB override is present + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 25, + ); + + // Remove override and invalidate + await resolver.remove('boost.connectors.jira.batchSize'); + + // Should fall back to field default + expect(await resolver.resolve('boost.connectors.jira.batchSize')).toBe( + 100, + ); + }); + + it('returns undefined for fields without defaults', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + // enabled, endpoint, schedule.cron have no defaults + expect( + await resolver.resolve('boost.connectors.jira.enabled'), + ).toBeUndefined(); + expect( + await resolver.resolve('boost.connectors.jira.endpoint'), + ).toBeUndefined(); + expect( + await resolver.resolve('boost.connectors.jira.schedule.cron'), + ).toBeUndefined(); + }); + + it('resolveAll includes field defaults for unset keys', async () => { + const config = createMockConfig({}); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(new Map()), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + const allConfig = await resolver.resolveAll(); + + // Defaults should be present + expect(allConfig.get('boost.connectors.jira.schedule.intervalMs')).toBe( + 300000, + ); + expect(allConfig.get('boost.connectors.jira.batchSize')).toBe(100); + expect(allConfig.get('boost.connectors.jira.timeout.connectionMs')).toBe( + 30000, + ); + expect(allConfig.get('boost.connectors.github.schedule.intervalMs')).toBe( + 300000, + ); + expect(allConfig.get('boost.connectors.github.batchSize')).toBe(100); + expect(allConfig.get('boost.connectors.gitlab.schedule.intervalMs')).toBe( + 300000, + ); + expect(allConfig.get('boost.connectors.gitlab.batchSize')).toBe(100); + + // Fields without defaults should NOT be present + expect(allConfig.has('boost.connectors.jira.enabled')).toBe(false); + expect(allConfig.has('boost.connectors.jira.endpoint')).toBe(false); + expect(allConfig.has('boost.connectors.jira.schedule.cron')).toBe(false); + }); + + it('resolveAll does not override YAML or DB values with defaults', async () => { + const config = createMockConfig({ + boost: { + connectors: { + jira: { + schedule: { intervalMs: 600000 }, + }, + }, + }, + }); + + const dbOverrides = new Map([ + ['boost.connectors.jira.batchSize', 250], + ]); + const adminConfigService = { + getAllOverrides: jest.fn().mockResolvedValue(dbOverrides), + } as unknown as AdminConfigService; + + const resolver = new RuntimeConfigResolver({ + cache, + config, + adminConfigService, + logger, + }); + + const allConfig = await resolver.resolveAll(); + + // YAML value beats default + expect(allConfig.get('boost.connectors.jira.schedule.intervalMs')).toBe( + 600000, + ); + // DB override beats default + expect(allConfig.get('boost.connectors.jira.batchSize')).toBe(250); + // No YAML or DB — default applied + expect(allConfig.get('boost.connectors.jira.timeout.connectionMs')).toBe( + 30000, + ); + }); + }); }); diff --git a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts index 6d0d2d5b980..888a8ba49c0 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts @@ -23,6 +23,7 @@ import type { JsonValue } from '@backstage/types'; import { AdminConfigService } from './AdminConfigService'; import { boostConfigFields, + getFieldDefault, isSensitiveField, type BoostConfigKey, } from './schemas'; @@ -83,25 +84,42 @@ export class RuntimeConfigResolver { } /** - * Resolve a single config value. Checks DB override first, then - * YAML baseline. The merged result is cached for 30 seconds. + * Resolve a single config value. Precedence: + * 1. DB override (highest) + * 2. YAML baseline + * 3. Field default from schema metadata (lowest) * * @param key - The config field key. - * @returns The resolved value, or `undefined` if not set anywhere. + * @returns The resolved value, or `undefined` if not set anywhere + * and no field default is defined. */ async resolve(key: BoostConfigKey): Promise { const effectiveConfig = await this.getEffectiveConfig(); - return effectiveConfig.get(key); + const value = effectiveConfig.get(key); + if (value !== undefined) { + return value; + } + return getFieldDefault(key); } /** * Resolve all config values. Returns a map of key → resolved value - * with DB overrides taking precedence over YAML baseline. + * with precedence: DB override → YAML baseline → field default. * * @returns Map of all resolved config values. */ async resolveAll(): Promise> { - return this.getEffectiveConfig(); + const effective = await this.getEffectiveConfig(); + // Layer 3: apply field defaults for keys not already set + for (const key of Object.keys(boostConfigFields) as BoostConfigKey[]) { + if (!effective.has(key)) { + const fieldDefault = getFieldDefault(key); + if (fieldDefault !== undefined) { + effective.set(key, fieldDefault); + } + } + } + return effective; } /** diff --git a/workspaces/boost/plugins/boost-backend/src/config/index.ts b/workspaces/boost/plugins/boost-backend/src/config/index.ts index 6bb69654428..d4ac9df5196 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/index.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/index.ts @@ -28,6 +28,7 @@ export { validateConfigValue, isDbWritable, isSensitiveField, + getFieldDefault, type BoostConfigKey, type ConfigScope, type ConfigFieldMeta, diff --git a/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts b/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts index 0332d2fbafd..76fc6a71d1f 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts @@ -22,11 +22,12 @@ import { isDbWritable, isSensitiveField, isValidCronExpression, + getFieldDefault, } from './schemas'; describe('boostConfigFields', () => { - it('has schema version 4', () => { - expect(BOOST_CONFIG_SCHEMA_VERSION).toBe(4); + it('has schema version 5', () => { + expect(BOOST_CONFIG_SCHEMA_VERSION).toBe(5); }); it('has entries for all expected config keys', () => { @@ -542,4 +543,50 @@ describe('connector config schemas', () => { }); }); }); + + describe('getFieldDefault', () => { + it('returns 300000 for all schedule.intervalMs fields', () => { + expect(getFieldDefault('boost.connectors.jira.schedule.intervalMs')).toBe( + 300000, + ); + expect( + getFieldDefault('boost.connectors.github.schedule.intervalMs'), + ).toBe(300000); + expect( + getFieldDefault('boost.connectors.gitlab.schedule.intervalMs'), + ).toBe(300000); + }); + + it('returns 100 for all batchSize fields', () => { + expect(getFieldDefault('boost.connectors.jira.batchSize')).toBe(100); + expect(getFieldDefault('boost.connectors.github.batchSize')).toBe(100); + expect(getFieldDefault('boost.connectors.gitlab.batchSize')).toBe(100); + }); + + it('returns 30000 for Jira timeout.connectionMs', () => { + expect( + getFieldDefault('boost.connectors.jira.timeout.connectionMs'), + ).toBe(30000); + }); + + it('returns undefined for fields without defaults', () => { + expect(getFieldDefault('boost.connectors.jira.enabled')).toBeUndefined(); + expect(getFieldDefault('boost.connectors.jira.endpoint')).toBeUndefined(); + expect( + getFieldDefault('boost.connectors.jira.schedule.cron'), + ).toBeUndefined(); + expect(getFieldDefault('boost.model.baseUrl')).toBeUndefined(); + }); + + it('does not use Zod .default() on connector schemas', () => { + const connectorKeys = Object.keys(boostConfigFields).filter(key => + key.startsWith('boost.connectors.'), + ) as Array; + connectorKeys.forEach(key => { + // validateConfigValue(key, undefined) must still return + // undefined — Zod .default() would return the default instead + expect(validateConfigValue(key, undefined)).toBeUndefined(); + }); + }); + }); }); diff --git a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts index fd99b08b2a3..480b828cd4a 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts @@ -167,6 +167,14 @@ export interface ConfigFieldMeta { description: string; /** Whether this field contains sensitive credentials. */ sensitive?: boolean; + /** + * Default value applied by {@link RuntimeConfigResolver} when neither + * DB override nor YAML baseline provides a value. Intentionally kept + * outside Zod `.default()` so `validateConfigValue(key, undefined)` + * preserves "unset" semantics and resolver precedence + * (DB → YAML → field default) is respected. + */ + defaultValue?: z.output; } // --------------------------------------------------------------------------- @@ -179,7 +187,7 @@ export interface ConfigFieldMeta { * * @public */ -export const BOOST_CONFIG_SCHEMA_VERSION = 4; +export const BOOST_CONFIG_SCHEMA_VERSION = 5; // --------------------------------------------------------------------------- // Connector field factories — shared patterns for per-connector leaves @@ -223,6 +231,7 @@ function connectorIntervalMs(label: string) { description: `Interval in milliseconds between ${label} sync runs. ` + `Defaults to 300000 (5 minutes) when not set.`, + defaultValue: 300_000, } as const; } @@ -240,6 +249,7 @@ function connectorBatchSize(label: string) { description: `Number of ${label} items to fetch per sync batch. ` + `Defaults to 100 when not set.`, + defaultValue: 100, } as const; } @@ -464,6 +474,7 @@ export const boostConfigFields = { description: 'Connection timeout in milliseconds for Jira API requests. ' + 'Defaults to 30000 (30 seconds) when not set.', + defaultValue: 30_000, }, // -- Connector config: GitHub -- @@ -535,3 +546,17 @@ export function isSensitiveField(key: BoostConfigKey): boolean { const field = boostConfigFields[key] as ConfigFieldMeta | undefined; return field?.sensitive === true; } + +/** + * Returns the default value for a config field, or `undefined` if no + * default is defined. + * + * @param key - The config field key. + * @returns The default value, or `undefined`. + * + * @public + */ +export function getFieldDefault(key: BoostConfigKey): unknown | undefined { + const field = boostConfigFields[key] as ConfigFieldMeta | undefined; + return field?.defaultValue; +} diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index 94a2a9756c0..c2bdc7c0eab 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -293,8 +293,8 @@ const boostPlugin: OverridableFrontendPlugin< icon?: string | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef + | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', @@ -340,10 +340,10 @@ const boostPlugin: OverridableFrontendPlugin< defaultGroup?: [Error: `Use the 'group' param instead`]; group?: | ( - | 'overview' - | 'documentation' | 'development' | 'deployment' + | 'overview' + | 'documentation' | 'operation' | 'observability' ) @@ -364,6 +364,7 @@ const boostPlugin: OverridableFrontendPlugin< title?: string | undefined | undefined; }; output: + | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -372,7 +373,6 @@ const boostPlugin: OverridableFrontendPlugin< optional: true; } > - | ExtensionDataRef | ExtensionDataRef< string, 'core.title', @@ -451,40 +451,40 @@ export const boostTranslationRef: TranslationRef< 'plugin.boost', { readonly 'nav.aiCatalog': string; - readonly 'catalog.table.name': string; - readonly 'catalog.table.type': string; - readonly 'catalog.table.owner': string; - readonly 'catalog.table.provider': string; - readonly 'catalog.table.description': string; readonly 'catalog.filter.type': string; + readonly 'catalog.filter.tag': string; readonly 'catalog.filter.owner': string; readonly 'catalog.filter.provider': string; - readonly 'catalog.filter.tag': string; - readonly 'catalog.page.title': string; - readonly 'catalog.page.subtitle': string; readonly 'catalog.error.title': string; readonly 'catalog.error.description': string; readonly 'catalog.error.retry': string; + readonly 'catalog.page.title': string; + readonly 'catalog.page.subtitle': string; + readonly 'catalog.table.name': string; + readonly 'catalog.table.type': string; + readonly 'catalog.table.description': string; + readonly 'catalog.table.owner': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.empty.title': string; + readonly 'catalog.empty.description': string; + readonly 'catalog.empty.learnMore': string; + readonly 'catalog.toolbar.search': string; + readonly 'catalog.toolbar.allPrefix': string; + readonly 'catalog.toolbar.viewGrid': string; + readonly 'catalog.toolbar.viewTable': string; readonly 'catalog.tab.usageTitle': string; readonly 'catalog.tab.usageDocumentation': string; readonly 'catalog.tab.usageViewTechDocs': string; readonly 'catalog.tab.usageExternalLinks': string; readonly 'catalog.tab.usageNoDocumentation': string; - readonly 'catalog.toolbar.search': string; - readonly 'catalog.toolbar.allPrefix': string; - readonly 'catalog.toolbar.viewGrid': string; - readonly 'catalog.toolbar.viewTable': string; + readonly 'catalog.card.copied': string; readonly 'catalog.card.summaryTitle': string; readonly 'catalog.card.adoptionTitle': string; readonly 'catalog.card.versionTitle': string; readonly 'catalog.card.versionCurrent': string; readonly 'catalog.card.copyCommand': string; - readonly 'catalog.card.copied': string; readonly 'catalog.card.copyAriaLabel': string; readonly 'catalog.card.adoptionDownloadZip': string; - readonly 'catalog.empty.title': string; - readonly 'catalog.empty.description': string; - readonly 'catalog.empty.learnMore': string; readonly 'catalog.emptyFiltered.title': string; readonly 'catalog.emptyFiltered.description': string; readonly 'catalog.emptyFiltered.clearFilters': string; From e079b54ed0324f8ad64d18c503406e605f2944d2 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:07:32 +0000 Subject: [PATCH 2/3] fix: add changeset and regenerate API report for PR #4314 - Add changeset for connector config defaults feature (minor bump) - Remove stale changeset from previous release (already consumed on main) - Regenerate frontend report.api.md to match API Extractor output, fixing the CI "check api reports" step failure Addresses review feedback on #4314 --- .../.changeset/connector-config-defaults.md | 9 +++++ .../.changeset/connector-config-schemas.md | 5 --- workspaces/boost/plugins/boost/report.api.md | 40 +++++++++---------- 3 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 workspaces/boost/.changeset/connector-config-defaults.md delete mode 100644 workspaces/boost/.changeset/connector-config-schemas.md diff --git a/workspaces/boost/.changeset/connector-config-defaults.md b/workspaces/boost/.changeset/connector-config-defaults.md new file mode 100644 index 00000000000..d56bd5c9889 --- /dev/null +++ b/workspaces/boost/.changeset/connector-config-defaults.md @@ -0,0 +1,9 @@ +--- +'@red-hat-developer-hub/backstage-plugin-boost-backend': minor +--- + +Add optional `defaultValue` field to `ConfigFieldMeta` and apply it as a third +precedence layer in `RuntimeConfigResolver`: DB override, YAML baseline, field +default, undefined. Connector defaults: `schedule.intervalMs` 300000, +`batchSize` 100, `timeout.connectionMs` 30000 (Jira only). Bumps +`BOOST_CONFIG_SCHEMA_VERSION` from 4 to 5. diff --git a/workspaces/boost/.changeset/connector-config-schemas.md b/workspaces/boost/.changeset/connector-config-schemas.md deleted file mode 100644 index 6eb72ae614e..00000000000 --- a/workspaces/boost/.changeset/connector-config-schemas.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@red-hat-developer-hub/backstage-plugin-boost-backend': minor ---- - -Add connector config Zod schemas and RuntimeConfigResolver support for Jira, GitHub, and GitLab connectors. Registers flat leaf keys in `boostConfigFields` with `db-overridable` scope covering enabled, endpoint, schedule, batchSize, timeout, and cron fields. Bumps `BOOST_CONFIG_SCHEMA_VERSION` to 4. diff --git a/workspaces/boost/plugins/boost/report.api.md b/workspaces/boost/plugins/boost/report.api.md index c2bdc7c0eab..94a2a9756c0 100644 --- a/workspaces/boost/plugins/boost/report.api.md +++ b/workspaces/boost/plugins/boost/report.api.md @@ -293,8 +293,8 @@ const boostPlugin: OverridableFrontendPlugin< icon?: string | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef + | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', @@ -340,10 +340,10 @@ const boostPlugin: OverridableFrontendPlugin< defaultGroup?: [Error: `Use the 'group' param instead`]; group?: | ( - | 'development' - | 'deployment' | 'overview' | 'documentation' + | 'development' + | 'deployment' | 'operation' | 'observability' ) @@ -364,7 +364,6 @@ const boostPlugin: OverridableFrontendPlugin< title?: string | undefined | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -373,6 +372,7 @@ const boostPlugin: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< string, 'core.title', @@ -451,40 +451,40 @@ export const boostTranslationRef: TranslationRef< 'plugin.boost', { readonly 'nav.aiCatalog': string; + readonly 'catalog.table.name': string; + readonly 'catalog.table.type': string; + readonly 'catalog.table.owner': string; + readonly 'catalog.table.provider': string; + readonly 'catalog.table.description': string; readonly 'catalog.filter.type': string; - readonly 'catalog.filter.tag': string; readonly 'catalog.filter.owner': string; readonly 'catalog.filter.provider': string; + readonly 'catalog.filter.tag': string; + readonly 'catalog.page.title': string; + readonly 'catalog.page.subtitle': string; readonly 'catalog.error.title': string; readonly 'catalog.error.description': string; readonly 'catalog.error.retry': string; - readonly 'catalog.page.title': string; - readonly 'catalog.page.subtitle': string; - readonly 'catalog.table.name': string; - readonly 'catalog.table.type': string; - readonly 'catalog.table.description': string; - readonly 'catalog.table.owner': string; - readonly 'catalog.table.provider': string; - readonly 'catalog.empty.title': string; - readonly 'catalog.empty.description': string; - readonly 'catalog.empty.learnMore': string; - readonly 'catalog.toolbar.search': string; - readonly 'catalog.toolbar.allPrefix': string; - readonly 'catalog.toolbar.viewGrid': string; - readonly 'catalog.toolbar.viewTable': string; readonly 'catalog.tab.usageTitle': string; readonly 'catalog.tab.usageDocumentation': string; readonly 'catalog.tab.usageViewTechDocs': string; readonly 'catalog.tab.usageExternalLinks': string; readonly 'catalog.tab.usageNoDocumentation': string; - readonly 'catalog.card.copied': string; + readonly 'catalog.toolbar.search': string; + readonly 'catalog.toolbar.allPrefix': string; + readonly 'catalog.toolbar.viewGrid': string; + readonly 'catalog.toolbar.viewTable': string; readonly 'catalog.card.summaryTitle': string; readonly 'catalog.card.adoptionTitle': string; readonly 'catalog.card.versionTitle': string; readonly 'catalog.card.versionCurrent': string; readonly 'catalog.card.copyCommand': string; + readonly 'catalog.card.copied': string; readonly 'catalog.card.copyAriaLabel': string; readonly 'catalog.card.adoptionDownloadZip': string; + readonly 'catalog.empty.title': string; + readonly 'catalog.empty.description': string; + readonly 'catalog.empty.learnMore': string; readonly 'catalog.emptyFiltered.title': string; readonly 'catalog.emptyFiltered.description': string; readonly 'catalog.emptyFiltered.clearFilters': string; From 3d124d13f9462c7a888c9fa0109c84df52c1905f Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Mon, 17 Aug 2026 12:04:41 +0200 Subject: [PATCH 3/3] clarify resolver docs and type getFieldDefault() return - Document that RuntimeConfigResolver.resolveAll() returns a fresh per-call snapshot Map, safe to mutate/retain without affecting subsequent resolver calls or the underlying cache - Note that resolveAll() cannot distinguish synthesized field defaults from real DB/YAML values; flagged for the admin UI work in #4066 - Change getFieldDefault() to a generic signature keyed on BoostConfigKey so callers get the field's typed schema output instead of `unknown` Signed-off-by: Marek Libra --- .../src/config/RuntimeConfigResolver.ts | 16 +++++++++++++++- .../plugins/boost-backend/src/config/schemas.ts | 13 +++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts index 888a8ba49c0..c5b7a90dac3 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/RuntimeConfigResolver.ts @@ -106,11 +106,25 @@ export class RuntimeConfigResolver { * Resolve all config values. Returns a map of key → resolved value * with precedence: DB override → YAML baseline → field default. * + * The returned Map is a fresh snapshot for this call only (defaults + * are layered on top of a copy of the effective config and are never + * written back to the cache) — safe for callers to mutate or retain + * without affecting subsequent resolver calls. + * + * Note: the returned map does not indicate *which* layer a value + * came from — synthesized field defaults are indistinguishable from + * real DB overrides or YAML baseline values. Consumers that need to + * make that distinction (e.g. an admin UI showing "using default" + * vs. "explicitly set", see issue #4066) will need a separate + * mechanism (e.g. a per-key `source` tag) — not implemented here. + * * @returns Map of all resolved config values. */ async resolveAll(): Promise> { const effective = await this.getEffectiveConfig(); - // Layer 3: apply field defaults for keys not already set + // Layer 3: apply field defaults for keys not already set. `effective` + // is a fresh Map from getEffectiveConfig() (see method docs above), + // so mutating it here is safe and never leaks into the cache. for (const key of Object.keys(boostConfigFields) as BoostConfigKey[]) { if (!effective.has(key)) { const fieldDefault = getFieldDefault(key); diff --git a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts index 480b828cd4a..6b4d7126569 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts @@ -552,11 +552,16 @@ export function isSensitiveField(key: BoostConfigKey): boolean { * default is defined. * * @param key - The config field key. - * @returns The default value, or `undefined`. + * @returns The default value, typed to the field's schema output, or + * `undefined`. * * @public */ -export function getFieldDefault(key: BoostConfigKey): unknown | undefined { - const field = boostConfigFields[key] as ConfigFieldMeta | undefined; - return field?.defaultValue; +export function getFieldDefault( + key: K, +): z.output<(typeof boostConfigFields)[K]['schema']> | undefined { + const field = boostConfigFields[key] as ConfigFieldMeta; + return field.defaultValue as + | z.output<(typeof boostConfigFields)[K]['schema']> + | undefined; }