diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 6f2ee9dd..916ece49 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -69,20 +69,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // Refresh #refreshInProgress: boolean = false; - #refreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #onRefreshListeners: Array<() => any> = []; /** * Aka watched settings. */ #sentinels: ConfigurationSettingId[] = []; - #refreshTimer: RefreshTimer; + #watchAll: boolean = false; + #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #kvRefreshTimer: RefreshTimer; // Feature flags - #featureFlagRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; - #featureFlagRefreshTimer: RefreshTimer; + #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #ffRefreshTimer: RefreshTimer; - // Selectors - #featureFlagSelectors: PagedSettingSelector[] = []; + /** + * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors + */ + #kvSelectors: PagedSettingSelector[] = []; + /** + * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors + */ + #ffSelectors: PagedSettingSelector[] = []; // Load balancing #lastSuccessfulEndpoint: string = ""; @@ -94,7 +101,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#options = options; this.#clientManager = clientManager; - // Enable request tracing if not opt-out + // enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); if (this.#requestTracingEnabled) { this.#featureFlagTracing = new FeatureFlagTracingOptions(); @@ -104,40 +111,40 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } + // if no selector is specified, always load key values using the default selector: key="*" and label="\0" + this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); + if (options?.refreshOptions?.enabled) { - const { watchedSettings, refreshIntervalInMs } = options.refreshOptions; - // validate watched settings + const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; if (watchedSettings === undefined || watchedSettings.length === 0) { - throw new Error("Refresh is enabled but no watched settings are specified."); + this.#watchAll = true; // if no watched settings is specified, then watch all + } else { + for (const setting of watchedSettings) { + if (setting.key.includes("*") || setting.key.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in key of watched settings."); + } + if (setting.label?.includes("*") || setting.label?.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in label of watched settings."); + } + this.#sentinels.push(setting); + } } // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { - this.#refreshInterval = refreshIntervalInMs; - } - } - - for (const setting of watchedSettings) { - if (setting.key.includes("*") || setting.key.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in key of watched settings."); - } - if (setting.label?.includes("*") || setting.label?.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in label of watched settings."); + this.#kvRefreshInterval = refreshIntervalInMs; } - this.#sentinels.push(setting); } - - this.#refreshTimer = new RefreshTimer(this.#refreshInterval); + this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); } // feature flag options if (options?.featureFlagOptions?.enabled) { - // validate feature flag selectors - this.#featureFlagSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); + // validate feature flag selectors, only load feature flags when enabled + this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); if (options.featureFlagOptions.refresh?.enabled) { const { refreshIntervalInMs } = options.featureFlagOptions.refresh; @@ -146,11 +153,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); } else { - this.#featureFlagRefreshInterval = refreshIntervalInMs; + this.#ffRefreshInterval = refreshIntervalInMs; } } - this.#featureFlagRefreshTimer = new RefreshTimer(this.#featureFlagRefreshInterval); + this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval); } } @@ -158,40 +165,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#adapters.push(new JsonKeyValueAdapter()); } - // #region ReadonlyMap APIs - get(key: string): T | undefined { - return this.#configMap.get(key); - } - - forEach(callbackfn: (value: any, key: string, map: ReadonlyMap) => void, thisArg?: any): void { - this.#configMap.forEach(callbackfn, thisArg); - } - - has(key: string): boolean { - return this.#configMap.has(key); - } - - get size(): number { - return this.#configMap.size; - } - - entries(): MapIterator<[string, any]> { - return this.#configMap.entries(); - } - - keys(): MapIterator { - return this.#configMap.keys(); - } - - values(): MapIterator { - return this.#configMap.values(); - } - - [Symbol.iterator](): MapIterator<[string, any]> { - return this.#configMap[Symbol.iterator](); - } - // #endregion - get #refreshEnabled(): boolean { return !!this.#options?.refreshOptions?.enabled; } @@ -215,181 +188,42 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { }; } - async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { - let clientWrappers = await this.#clientManager.getClients(); - if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { - let nextClientIndex = 0; - // Iterate through clients to find the index of the client with the last successful endpoint - for (const clientWrapper of clientWrappers) { - nextClientIndex++; - if (clientWrapper.endpoint === this.#lastSuccessfulEndpoint) { - break; - } - } - // If we found the last successful client, rotate the list so that the next client is at the beginning - if (nextClientIndex < clientWrappers.length) { - clientWrappers = [...clientWrappers.slice(nextClientIndex), ...clientWrappers.slice(0, nextClientIndex)]; - } - } - - let successful: boolean; - for (const clientWrapper of clientWrappers) { - successful = false; - try { - const result = await funcToExecute(clientWrapper.client); - this.#isFailoverRequest = false; - this.#lastSuccessfulEndpoint = clientWrapper.endpoint; - successful = true; - clientWrapper.updateBackoffStatus(successful); - return result; - } catch (error) { - if (isFailoverableError(error)) { - clientWrapper.updateBackoffStatus(successful); - this.#isFailoverRequest = true; - continue; - } - - throw error; - } - } - - this.#clientManager.refreshClients(); - throw new Error("All clients failed to get configuration settings."); + // #region ReadonlyMap APIs + get(key: string): T | undefined { + return this.#configMap.get(key); } - async #loadSelectedKeyValues(): Promise { - // validate selectors - const selectors = getValidKeyValueSelectors(this.#options?.selectors); - - const funcToExecute = async (client) => { - const loadedSettings: ConfigurationSetting[] = []; - for (const selector of selectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter - }; - - const settings = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - client, - listOptions - ); - - for await (const setting of settings) { - if (!isFeatureFlag(setting)) { // exclude feature flags - loadedSettings.push(setting); - } - } - } - return loadedSettings; - }; - - return await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; + forEach(callbackfn: (value: any, key: string, map: ReadonlyMap) => void, thisArg?: any): void { + this.#configMap.forEach(callbackfn, thisArg); } - /** - * Update etag of watched settings from loaded data. If a watched setting is not covered by any selector, a request will be sent to retrieve it. - */ - async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { - if (!this.#refreshEnabled) { - return; - } - - for (const sentinel of this.#sentinels) { - const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); - if (matchedSetting) { - sentinel.etag = matchedSetting.etag; - } else { - // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing - const { key, label } = sentinel; - const response = await this.#getConfigurationSetting({ key, label }); - if (response) { - sentinel.etag = response.etag; - } else { - sentinel.etag = undefined; - } - } - } + has(key: string): boolean { + return this.#configMap.has(key); } - async #loadSelectedAndWatchedKeyValues() { - const keyValues: [key: string, value: unknown][] = []; - const loadedSettings = await this.#loadSelectedKeyValues(); - await this.#updateWatchedKeyValuesEtag(loadedSettings); - - // process key-values, watched settings have higher priority - for (const setting of loadedSettings) { - const [key, value] = await this.#processKeyValues(setting); - keyValues.push([key, value]); - } - - this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion - for (const [k, v] of keyValues) { - this.#configMap.set(k, v); - } + get size(): number { + return this.#configMap.size; } - async #clearLoadedKeyValues() { - for (const key of this.#configMap.keys()) { - if (key !== FEATURE_MANAGEMENT_KEY_NAME) { - this.#configMap.delete(key); - } - } + entries(): MapIterator<[string, any]> { + return this.#configMap.entries(); } - async #loadFeatureFlags() { - // Temporary map to store feature flags, key is the key of the setting, value is the raw value of the setting - const funcToExecute = async (client) => { - const featureFlagSettings: ConfigurationSetting[] = []; - // deep copy selectors to avoid modification if current client fails - const selectors = JSON.parse( - JSON.stringify(this.#featureFlagSelectors) - ); - - for (const selector of selectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, - labelFilter: selector.labelFilter - }; - - const pageEtags: string[] = []; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - client, - listOptions - ).byPage(); - for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); - for (const setting of page.items) { - if (isFeatureFlag(setting)) { - featureFlagSettings.push(setting); - } - } - } - selector.pageEtags = pageEtags; - } - - this.#featureFlagSelectors = selectors; - return featureFlagSettings; - }; - - const featureFlagSettings = await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; - - if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { - this.#featureFlagTracing.resetFeatureFlagTracing(); - } + keys(): MapIterator { + return this.#configMap.keys(); + } - // parse feature flags - const featureFlags = await Promise.all( - featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) - ); + values(): MapIterator { + return this.#configMap.values(); + } - // feature_management is a reserved key, and feature_flags is an array of feature flags - this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags }); + [Symbol.iterator](): MapIterator<[string, any]> { + return this.#configMap[Symbol.iterator](); } + // #endregion /** - * Load the configuration store for the first time. + * Loads the configuration store for the first time. */ async load() { await this.#loadSelectedAndWatchedKeyValues(); @@ -401,7 +235,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Construct hierarchical data object from map. + * Constructs hierarchical data object from map. */ constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record { const separator = options?.separator ?? "."; @@ -444,7 +278,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Refresh the configuration store. + * Refreshes the configuration. */ async refresh(): Promise { if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { @@ -462,6 +296,26 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + /** + * Registers a callback function to be called when the configuration is refreshed. + */ + onRefresh(listener: () => any, thisArg?: any): Disposable { + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new Error("Refresh is not enabled for key-values or feature flags."); + } + + const boundedListener = listener.bind(thisArg); + this.#onRefreshListeners.push(boundedListener); + + const remove = () => { + const index = this.#onRefreshListeners.indexOf(boundedListener); + if (index >= 0) { + this.#onRefreshListeners.splice(index, 1); + } + }; + return new Disposable(remove); + } + async #refreshTasks(): Promise { const refreshTasks: Promise[] = []; if (this.#refreshEnabled) { @@ -492,17 +346,141 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Refresh key-values. + * Loads configuration settings from App Configuration, either key-value settings or feature flag settings. + * Additionally, updates the `pageEtags` property of the corresponding @see PagedSettingSelector after loading. + * + * @param loadFeatureFlag - Determines which type of configurationsettings to load: + * If true, loads feature flag using the feature flag selectors; + * If false, loads key-value using the key-value selectors. Defaults to false. + */ + async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise { + const selectors = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors; + const funcToExecute = async (client) => { + const loadedSettings: ConfigurationSetting[] = []; + // deep copy selectors to avoid modification if current client fails + const selectorsToUpdate = JSON.parse( + JSON.stringify(selectors) + ); + + for (const selector of selectorsToUpdate) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: selector.keyFilter, + labelFilter: selector.labelFilter + }; + + const pageEtags: string[] = []; + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + for await (const page of pageIterator) { + pageEtags.push(page.etag ?? ""); + for (const setting of page.items) { + if (loadFeatureFlag === isFeatureFlag(setting)) { + loadedSettings.push(setting); + } + } + } + selector.pageEtags = pageEtags; + } + + if (loadFeatureFlag) { + this.#ffSelectors = selectorsToUpdate; + } else { + this.#kvSelectors = selectorsToUpdate; + } + return loadedSettings; + }; + + return await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; + } + + /** + * Loads selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration. + */ + async #loadSelectedAndWatchedKeyValues() { + const keyValues: [key: string, value: unknown][] = []; + const loadedSettings = await this.#loadConfigurationSettings(); + if (this.#refreshEnabled && !this.#watchAll) { + await this.#updateWatchedKeyValuesEtag(loadedSettings); + } + + // process key-values, watched settings have higher priority + for (const setting of loadedSettings) { + const [key, value] = await this.#processKeyValues(setting); + keyValues.push([key, value]); + } + + this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion + for (const [k, v] of keyValues) { + this.#configMap.set(k, v); // reset the configuration + } + } + + /** + * Updates etag of watched settings from loaded data. If a watched setting is not covered by any selector, a request will be sent to retrieve it. + */ + async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { + for (const sentinel of this.#sentinels) { + const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); + if (matchedSetting) { + sentinel.etag = matchedSetting.etag; + } else { + // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing + const { key, label } = sentinel; + const response = await this.#getConfigurationSetting({ key, label }); + if (response) { + sentinel.etag = response.etag; + } else { + sentinel.etag = undefined; + } + } + } + } + + /** + * Clears all existing key-values in the local configuration except feature flags. + */ + async #clearLoadedKeyValues() { + for (const key of this.#configMap.keys()) { + if (key !== FEATURE_MANAGEMENT_KEY_NAME) { + this.#configMap.delete(key); + } + } + } + + /** + * Loads feature flags from App Configuration to the local configuration. + */ + async #loadFeatureFlags() { + const loadFeatureFlag = true; + const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag); + + // parse feature flags + const featureFlags = await Promise.all( + featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) + ); + + // feature_management is a reserved key, and feature_flags is an array of feature flags + this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags }); + } + + /** + * Refreshes key-values. * @returns true if key-values are refreshed, false otherwise. */ async #refreshKeyValues(): Promise { // if still within refresh interval/backoff, return - if (!this.#refreshTimer.canRefresh()) { + if (!this.#kvRefreshTimer.canRefresh()) { return Promise.resolve(false); } // try refresh if any of watched settings is changed. let needRefresh = false; + if (this.#watchAll) { + needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); + } for (const sentinel of this.#sentinels.values()) { const response = await this.#getConfigurationSetting(sentinel, { onlyIfChanged: true @@ -521,25 +499,39 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { await this.#loadSelectedAndWatchedKeyValues(); } - this.#refreshTimer.reset(); + this.#kvRefreshTimer.reset(); return Promise.resolve(needRefresh); } /** - * Refresh feature flags. + * Refreshes feature flags. * @returns true if feature flags are refreshed, false otherwise. */ async #refreshFeatureFlags(): Promise { // if still within refresh interval/backoff, return - if (!this.#featureFlagRefreshTimer.canRefresh()) { + if (!this.#ffRefreshTimer.canRefresh()) { return Promise.resolve(false); } - // check if any feature flag is changed + const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); + if (needRefresh) { + await this.#loadFeatureFlags(); + } + + this.#ffRefreshTimer.reset(); + return Promise.resolve(needRefresh); + } + + /** + * Checks whether the key-value collection has changed. + * @param selectors - The @see PagedSettingSelector of the kev-value collection. + * @returns true if key-value collection has changed, false otherwise. + */ + async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { const funcToExecute = async (client) => { - for (const selector of this.#featureFlagSelectors) { + for (const selector of selectors) { const listOptions: ListConfigurationSettingsOptions = { - keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, + keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, pageEtags: selector.pageEtags }; @@ -559,30 +551,76 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return false; }; - const needRefresh: boolean = await this.#executeWithFailoverPolicy(funcToExecute); - if (needRefresh) { - await this.#loadFeatureFlags(); - } + const isChanged = await this.#executeWithFailoverPolicy(funcToExecute); + return isChanged; + } - this.#featureFlagRefreshTimer.reset(); - return Promise.resolve(needRefresh); + /** + * Gets a configuration setting by key and label.If the setting is not found, return undefine instead of throwing an error. + */ + async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { + const funcToExecute = async (client) => { + return getConfigurationSettingWithTrace( + this.#requestTraceOptions, + client, + configurationSettingId, + customOptions + ); + }; + + let response: GetConfigurationSettingResponse | undefined; + try { + response = await this.#executeWithFailoverPolicy(funcToExecute); + } catch (error) { + if (isRestError(error) && error.statusCode === 404) { + response = undefined; + } else { + throw error; + } + } + return response; } - onRefresh(listener: () => any, thisArg?: any): Disposable { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); + async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { + let clientWrappers = await this.#clientManager.getClients(); + if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { + let nextClientIndex = 0; + // Iterate through clients to find the index of the client with the last successful endpoint + for (const clientWrapper of clientWrappers) { + nextClientIndex++; + if (clientWrapper.endpoint === this.#lastSuccessfulEndpoint) { + break; + } + } + // If we found the last successful client, rotate the list so that the next client is at the beginning + if (nextClientIndex < clientWrappers.length) { + clientWrappers = [...clientWrappers.slice(nextClientIndex), ...clientWrappers.slice(0, nextClientIndex)]; + } } - const boundedListener = listener.bind(thisArg); - this.#onRefreshListeners.push(boundedListener); + let successful: boolean; + for (const clientWrapper of clientWrappers) { + successful = false; + try { + const result = await funcToExecute(clientWrapper.client); + this.#isFailoverRequest = false; + this.#lastSuccessfulEndpoint = clientWrapper.endpoint; + successful = true; + clientWrapper.updateBackoffStatus(successful); + return result; + } catch (error) { + if (isFailoverableError(error)) { + clientWrapper.updateBackoffStatus(successful); + this.#isFailoverRequest = true; + continue; + } - const remove = () => { - const index = this.#onRefreshListeners.indexOf(boundedListener); - if (index >= 0) { - this.#onRefreshListeners.splice(index, 1); + throw error; } - }; - return new Disposable(remove); + } + + this.#clientManager.refreshClients(); + throw new Error("All clients failed to get configuration settings."); } async #processKeyValues(setting: ConfigurationSetting): Promise<[string, unknown]> { @@ -611,32 +649,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return key; } - /** - * Get a configuration setting by key and label. If the setting is not found, return undefine instead of throwing an error. - */ - async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { - const funcToExecute = async (client) => { - return getConfigurationSettingWithTrace( - this.#requestTraceOptions, - client, - configurationSettingId, - customOptions - ); - }; - - let response: GetConfigurationSettingResponse | undefined; - try { - response = await this.#executeWithFailoverPolicy(funcToExecute); - } catch (error) { - if (isRestError(error) && error.statusCode === 404) { - response = undefined; - } else { - throw error; - } - } - return response; - } - async #parseFeatureFlag(setting: ConfigurationSetting): Promise { const rawFlag = setting.value; if (rawFlag === undefined) { @@ -877,7 +889,7 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { } function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { - if (!selectors || selectors.length === 0) { + if (selectors === undefined || selectors.length === 0) { // Default selector: key: *, label: \0 return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; } @@ -885,10 +897,13 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect } function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] { - if (!selectors || selectors.length === 0) { + if (selectors === undefined || selectors.length === 0) { // selectors must be explicitly provided. throw new Error("Feature flag selectors must be provided."); } else { + selectors.forEach(selector => { + selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; + }); return getValidSelectors(selectors); } } diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts index 37425112..d5e4da5f 100644 --- a/src/RefreshOptions.ts +++ b/src/RefreshOptions.ts @@ -22,6 +22,9 @@ export interface RefreshOptions { /** * One or more configuration settings to be watched for changes on the server. * Any modifications to watched settings will refresh all settings loaded by the configuration provider when refresh() is called. + * + * @remarks + * If no watched setting is specified, all configuration settings will be watched. */ watchedSettings?: WatchedSetting[]; } diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 6c537fb4..6457cb1a 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -58,25 +58,6 @@ describe("dynamic refresh", function () { return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled for key-values or feature flags."); }); - it("should only allow non-empty list of watched settings when refresh is enabled", async () => { - const connectionString = createMockedConnectionString(); - const loadWithEmptyWatchedSettings = load(connectionString, { - refreshOptions: { - enabled: true, - watchedSettings: [] - } - }); - const loadWithUndefinedWatchedSettings = load(connectionString, { - refreshOptions: { - enabled: true - } - }); - return Promise.all([ - expect(loadWithEmptyWatchedSettings).eventually.rejectedWith("Refresh is enabled but no watched settings are specified."), - expect(loadWithUndefinedWatchedSettings).eventually.rejectedWith("Refresh is enabled but no watched settings are specified.") - ]); - }); - it("should not allow refresh interval less than 1 second", async () => { const connectionString = createMockedConnectionString(); const loadWithInvalidRefreshInterval = load(connectionString, { @@ -354,6 +335,73 @@ describe("dynamic refresh", function () { expect(settings.get("app.settings.fontColor")).eq("red"); }); + it("should refresh key value based on page eTag, if no watched setting is specified", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // change setting + updateSetting("app.settings.fontColor", "blue"); + + // after refreshInterval, should really refresh + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(3); // 1 + 2 more requests: one conditional request to detect change and one request to reload all key values + expect(getKvRequestCount).eq(0); + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + + it("should refresh key value based on page Etag, only on change", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + + let refreshSuccessfulCount = 0; + settings.onRefresh(() => { + refreshSuccessfulCount++; + }); + + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(2); // one more conditional request to detect change + expect(getKvRequestCount).eq(0); + expect(refreshSuccessfulCount).eq(0); // no change in key values, because page etags are the same. + + // change key value + restoreMocks(); + const changedKVs = [ + { value: "blue", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" } + ].map(createMockedKeyValue); + mockAppConfigurationClientListConfigurationSettings([changedKVs], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(changedKVs, getKvCallback); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(4); // 2 + 2 more requests: one conditional request to detect change and one request to reload all key values + expect(getKvRequestCount).eq(0); + expect(refreshSuccessfulCount).eq(1); // change in key values, because page etags are different. + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + it("should not refresh any more when there is refresh in progress", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -449,7 +497,7 @@ describe("dynamic refresh feature flags", function () { }); - it("should refresh feature flags only on change, based on page etags", async () => { + it("should refresh feature flags based on page etags, only on change", async () => { // mock multiple pages of feature flags const page1 = [ createMockedFeatureFlag("Alpha_1", { enabled: true }),