diff --git a/package.json b/package.json index 0bc80ae..5b3dc2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "4.10.0", + "version": "4.11.0", "description": "Common library for Eppo JavaScript SDKs (web, react native, and node)", "main": "dist/index.js", "files": [ diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index 440459b..18a1b40 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -12,13 +12,13 @@ import ApiEndpoints from '../api-endpoints'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator'; import { IBanditEvent, IBanditLogger } from '../bandit-logger'; +import ConfigurationRequestor from '../configuration-requestor'; +import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { IConfigurationWire, IPrecomputedConfiguration, IObfuscatedPrecomputedConfigurationResponse, -} from '../configuration'; -import ConfigurationRequestor from '../configuration-requestor'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; +} from '../configuration-wire-types'; import { Evaluator, FlagEvaluation } from '../evaluator'; import { AllocationEvaluationCode, diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 1d15dbf..fad2c13 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -15,14 +15,14 @@ import { } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; +import { IConfigurationStore } from '../configuration-store/configuration-store'; +import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, ObfuscatedPrecomputedConfigurationResponse, -} from '../configuration'; -import { IConfigurationStore } from '../configuration-store/configuration-store'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; +} from '../configuration-wire-types'; +import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; import { decodePrecomputedFlag } from '../decoding'; import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 0dd16c2..4541d6a 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -14,20 +14,19 @@ import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-cache'; import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache'; +import ConfigurationRequestor from '../configuration-requestor'; +import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { ConfigurationWireV1, IConfigurationWire, IPrecomputedConfiguration, PrecomputedConfiguration, -} from '../configuration'; -import ConfigurationRequestor from '../configuration-requestor'; -import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; +} from '../configuration-wire-types'; import { DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_INTERVAL_MS, DEFAULT_REQUEST_TIMEOUT_MS, - OBFUSCATED_FORMATS, } from '../constants'; import { decodeFlag } from '../decoding'; import { EppoValue } from '../eppo_value'; @@ -41,13 +40,12 @@ import { } from '../flag-evaluation-details-builder'; import { FlagEvaluationError } from '../flag-evaluation-error'; import FetchHttpClient from '../http-client'; +import { IConfiguration, StoreBackedConfiguration } from '../i-configuration'; import { BanditModelData, BanditParameters, BanditVariation, - ConfigDetails, Flag, - FormatEnum, IPrecomputedBandit, ObfuscatedFlag, PrecomputedFlag, @@ -135,6 +133,7 @@ export default class EppoClient { private configObfuscatedCache?: boolean; private requestPoller?: IPoller; private readonly evaluator = new Evaluator(); + private configurationRequestor?: ConfigurationRequestor; constructor({ eventDispatcher = new NoOpEventDispatcher(), @@ -164,6 +163,16 @@ export default class EppoClient { this.expectObfuscated = isObfuscated; } + private getConfiguration(): IConfiguration { + return this.configurationRequestor + ? this.configurationRequestor.getConfiguration() + : new StoreBackedConfiguration( + this.flagConfigurationStore, + this.banditVariationConfigurationStore, + this.banditModelConfigurationStore, + ); + } + private maybeWarnAboutObfuscationMismatch(configObfuscated: boolean) { // Don't warn again if we did on the last check. if (configObfuscated !== this.expectObfuscated && !this.obfuscationMismatchWarningIssued) { @@ -177,11 +186,14 @@ export default class EppoClient { } } - private isObfuscated() { + /** + * This method delegates to the configuration to determine whether it is obfuscated, then caches the actual + * obfuscation state and issues a warning if it hasn't already. + * This method can be removed with the next major update when the @deprecated setIsObfuscated is removed + */ + private isObfuscated(config: IConfiguration) { if (this.configObfuscatedCache === undefined) { - this.configObfuscatedCache = OBFUSCATED_FORMATS.includes( - this.flagConfigurationStore.getFormat() ?? FormatEnum.SERVER, - ); + this.configObfuscatedCache = config.isObfuscated(); } this.maybeWarnAboutObfuscationMismatch(this.configObfuscatedCache); return this.configObfuscatedCache; @@ -307,7 +319,7 @@ export default class EppoClient { ); const pollingCallback = async () => { - if (await this.flagConfigurationStore.isExpired()) { + if (await configurationRequestor.isFlagConfigExpired()) { this.configObfuscatedCache = undefined; return configurationRequestor.fetchAndStoreConfigurations(); } @@ -624,13 +636,14 @@ export default class EppoClient { actions: BanditActions, defaultAction: string, ): string { + const config = this.getConfiguration(); let result: string | null = null; - const flagBanditVariations = this.banditVariationConfigurationStore?.get(flagKey); + const flagBanditVariations = config.getFlagBanditVariations(flagKey); const banditKey = flagBanditVariations?.at(0)?.key; if (banditKey) { - const banditParameters = this.getBandit(banditKey); + const banditParameters = config.getBandit(banditKey); if (banditParameters) { const contextualSubjectAttributes = ensureContextualSubjectAttributes(subjectAttributes); const actionsWithContextualAttributes = ensureActionsWithContextualAttributes(actions); @@ -653,11 +666,13 @@ export default class EppoClient { actions: BanditActions, defaultValue: string, ): IAssignmentDetails { + const config = this.getConfiguration(); let variation = defaultValue; let action: string | null = null; // Initialize with a generic evaluation details. This will mutate as the function progresses. let evaluationDetails: IFlagEvaluationDetails = this.newFlagEvaluationDetailsBuilder( + config, flagKey, ).buildForNoneResult( 'ASSIGNMENT_ERROR', @@ -681,7 +696,7 @@ export default class EppoClient { // Check if the assigned variation is an active bandit // Note: the reason for non-bandit assignments include the subject being bucketed into a non-bandit variation or // a rollout having been done. - const bandit = this.findBanditByVariation(flagKey, variation); + const bandit = config.getFlagVariationBandit(flagKey, variation); if (!bandit) { return { variation, action: null, evaluationDetails }; @@ -909,13 +924,14 @@ export default class EppoClient { subjectKey: string, subjectAttributes: Attributes = {}, ): Record { - const configDetails = this.getConfigDetails(); + const config = this.getConfiguration(); + const configDetails = config.getFlagConfigDetails(); const flagKeys = this.getFlagKeys(); const flags: Record = {}; // Evaluate all the enabled flags for the user flagKeys.forEach((flagKey) => { - const flag = this.getFlag(flagKey); + const flag = this.getNormalizedFlag(config, flagKey); if (!flag) { logger.debug(`${loggerPrefix} No assigned variation. Flag does not exist.`); return; @@ -927,7 +943,7 @@ export default class EppoClient { configDetails, subjectKey, subjectAttributes, - this.isObfuscated(), + this.isObfuscated(config), ); // allocationKey is set along with variation when there is a result. this check appeases typescript below @@ -965,13 +981,15 @@ export default class EppoClient { banditActions: Record = {}, salt?: string, ): string { - const configDetails = this.getConfigDetails(); + const config = this.getConfiguration(); + const configDetails = config.getFlagConfigDetails(); const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes); const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); const flags = this.getAllAssignments(subjectKey, subjectFlatAttributes); const bandits = this.computeBanditsForFlags( + config, subjectKey, subjectContextualAttributes, banditActions, @@ -1011,8 +1029,9 @@ export default class EppoClient { ): FlagEvaluation { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); + const config = this.getConfiguration(); - const flagEvaluationDetailsBuilder = this.newFlagEvaluationDetailsBuilder(flagKey); + const flagEvaluationDetailsBuilder = this.newFlagEvaluationDetailsBuilder(config, flagKey); const overrideVariation = this.overrideStore?.get(flagKey); if (overrideVariation) { return overrideResult( @@ -1024,8 +1043,8 @@ export default class EppoClient { ); } - const configDetails = this.getConfigDetails(); - const flag = this.getFlag(flagKey); + const configDetails = config.getFlagConfigDetails(); + const flag = this.getNormalizedFlag(config, flagKey); if (flag === null) { logger.warn(`${loggerPrefix} No assigned variation. Flag not found: ${flagKey}`); @@ -1077,7 +1096,7 @@ export default class EppoClient { ); } - const isObfuscated = this.isObfuscated(); + const isObfuscated = this.isObfuscated(config); const result = this.evaluator.evaluateFlag( flag, configDetails, @@ -1114,9 +1133,12 @@ export default class EppoClient { }); } - private newFlagEvaluationDetailsBuilder(flagKey: string): FlagEvaluationDetailsBuilder { - const flag = this.getFlag(flagKey); - const configDetails = this.getConfigDetails(); + private newFlagEvaluationDetailsBuilder( + config: IConfiguration, + flagKey: string, + ): FlagEvaluationDetailsBuilder { + const flag = this.getNormalizedFlag(config, flagKey); + const configDetails = config.getFlagConfigDetails(); return new FlagEvaluationDetailsBuilder( configDetails.configEnvironment.name, flag?.allocations ?? [], @@ -1125,35 +1147,17 @@ export default class EppoClient { ); } - private getConfigDetails(): ConfigDetails { - return { - configFetchedAt: this.flagConfigurationStore.getConfigFetchedAt() ?? '', - configPublishedAt: this.flagConfigurationStore.getConfigPublishedAt() ?? '', - configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { - name: '', - }, - configFormat: this.flagConfigurationStore.getFormat() ?? '', - }; - } - - private getFlag(flagKey: string): Flag | null { - return this.isObfuscated() - ? this.getObfuscatedFlag(flagKey) - : this.flagConfigurationStore.get(flagKey); + private getNormalizedFlag(config: IConfiguration, flagKey: string): Flag | null { + return this.isObfuscated(config) + ? this.getObfuscatedFlag(config, flagKey) + : config.getFlag(flagKey); } - private getObfuscatedFlag(flagKey: string): Flag | null { - const flag: ObfuscatedFlag | null = this.flagConfigurationStore.get( - getMD5Hash(flagKey), - ) as ObfuscatedFlag; + private getObfuscatedFlag(config: IConfiguration, flagKey: string): Flag | null { + const flag: ObfuscatedFlag | null = config.getFlag(getMD5Hash(flagKey)) as ObfuscatedFlag; return flag ? decodeFlag(flag) : null; } - private getBandit(banditKey: string): BanditParameters | null { - // Upstreams for this SDK do not yet support obfuscating bandits, so no `isObfuscated` check here. - return this.banditModelConfigurationStore?.get(banditKey) ?? null; - } - // noinspection JSUnusedGlobalSymbols getFlagKeys() { /** @@ -1162,16 +1166,11 @@ export default class EppoClient { * * Note that it is generally not a good idea to preload all flag configurations. */ - return this.flagConfigurationStore.getKeys(); + return this.getConfiguration().getFlagKeys(); } isInitialized() { - return ( - this.flagConfigurationStore.isInitialized() && - (!this.banditVariationConfigurationStore || - this.banditVariationConfigurationStore.isInitialized()) && - (!this.banditModelConfigurationStore || this.banditModelConfigurationStore.isInitialized()) - ); + return this.getConfiguration().isInitialized(); } /** @deprecated Use `setAssignmentLogger` */ @@ -1237,7 +1236,7 @@ export default class EppoClient { } getFlagConfigurations(): Record { - return this.flagConfigurationStore.entries(); + return this.getConfiguration().getFlags(); } private flushQueuedEvents(eventQueue: BoundedEventQueue, logFunction?: (event: T) => void) { @@ -1305,13 +1304,14 @@ export default class EppoClient { private buildLoggerMetadata(): Record { return { - obfuscated: this.isObfuscated(), + obfuscated: this.isObfuscated(this.getConfiguration()), sdkLanguage: 'javascript', sdkLibVersion: LIB_VERSION, }; } private computeBanditsForFlags( + config: IConfiguration, subjectKey: string, subjectAttributes: ContextAttributes, banditActions: Record, @@ -1325,6 +1325,7 @@ export default class EppoClient { if (flagVariation) { // Precompute a bandit, if there is one matching this variation. const precomputedResult = this.getPrecomputedBandit( + config, flagKey, flagVariation.variationValue, subjectKey, @@ -1339,27 +1340,15 @@ export default class EppoClient { return banditResults; } - private findBanditByVariation(flagKey: string, variationValue: string): BanditParameters | null { - const banditVariations = this.banditVariationConfigurationStore?.get(flagKey); - const banditKey = banditVariations?.find( - (banditVariation) => banditVariation.variationValue === variationValue, - )?.key; - - if (banditKey) { - // Retrieve the model parameters for the bandit - return this.getBandit(banditKey); - } - return null; - } - private getPrecomputedBandit( + config: IConfiguration, flagKey: string, variationValue: string, subjectKey: string, subjectAttributes: ContextAttributes, banditActions: BanditActions, ): IPrecomputedBandit | null { - const bandit = this.findBanditByVariation(flagKey, variationValue); + const bandit = config.getFlagVariationBandit(flagKey, variationValue); if (!bandit) { return null; } diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts index 79a5be3..606ff17 100644 --- a/src/client/eppo-precomputed-client.spec.ts +++ b/src/client/eppo-precomputed-client.spec.ts @@ -11,9 +11,9 @@ import { ensureContextualSubjectAttributes, ensureNonContextualSubjectAttributes, } from '../attributes'; -import { IPrecomputedConfigurationResponse } from '../configuration'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; +import { IPrecomputedConfigurationResponse } from '../configuration-wire-types'; import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; import FetchHttpClient from '../http-client'; import { diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index 31d7f76..2bfe61d 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -14,6 +14,7 @@ import FetchHttpClient, { IHttpClient, IUniversalFlagConfigResponse, } from './http-client'; +import { StoreBackedConfiguration } from './i-configuration'; import { BanditParameters, BanditVariation, Flag } from './interfaces'; describe('ConfigurationRequestor', () => { @@ -426,4 +427,219 @@ describe('ConfigurationRequestor', () => { }); }); }); + + describe('with mocked response', () => { + const response = { + environment: { + name: 'Test', + }, + createdAt: '2024-01-01', + format: 'SERVER', + flags: { + test_flag: { + key: 'test_flag', + enabled: true, + variationType: 'STRING', + variations: { + bandit: { + key: 'bandit', + value: 'bandit', + }, + }, + }, + }, + banditReferences: { + bandit: { + modelVersion: '123', + flagVariations: [ + { + key: 'bandit', + flagKey: 'test_flag', + allocationKey: 'analysis', + variationKey: 'bandit', + variationValue: 'bandit', + }, + ], + }, + }, + }; + const banditResponse = { + updatedAt: '2023-09-13T04:52:06.462Z', + environment: { + name: 'Test', + }, + bandits: { + bandit: { + banditKey: 'bandit', + modelName: 'falcon', + updatedAt: '2023-09-13T04:52:06.462Z', + modelVersion: '123', + }, + }, + }; + let fetchSpy: jest.Mock; + beforeAll(() => { + fetchSpy = jest.fn((req) => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve(req.includes('flag-config/v1/bandits') ? banditResponse : response), + }); + }) as jest.Mock; + global.fetch = fetchSpy; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('getConfiguration', () => { + it('should return an empty configuration instance before a config has been loaded', async () => { + const requestor = new ConfigurationRequestor( + httpClient, + flagStore, + banditVariationStore, + banditModelStore, + ); + + const config = requestor.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config.getFlagKeys()).toEqual([]); + }); + + it('should return a populated configuration instance', async () => { + const requestor = new ConfigurationRequestor( + httpClient, + flagStore, + banditVariationStore, + banditModelStore, + ); + + await requestor.fetchAndStoreConfigurations(); + + const config = requestor.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config.getFlagKeys()).toEqual(['test_flag']); + }); + }); + + describe('fetchAndStoreConfigurations', () => { + it('should update configuration with flag data', async () => { + const requestor = new ConfigurationRequestor( + httpClient, + flagStore, + banditVariationStore, + banditModelStore, + ); + const config = requestor.getConfiguration(); + + await requestor.fetchAndStoreConfigurations(); + + expect(config.getFlagKeys()).toEqual(['test_flag']); + expect(config.getFlagConfigDetails()).toEqual({ + configEnvironment: { name: 'Test' }, + configFetchedAt: expect.any(String), + configFormat: 'SERVER', + configPublishedAt: '2024-01-01', + }); + }); + + it('should update configuration with bandit data when present', async () => { + const requestor = new ConfigurationRequestor( + httpClient, + flagStore, + banditVariationStore, + banditModelStore, + ); + const config = requestor.getConfiguration(); + + await requestor.fetchAndStoreConfigurations(); + + // Verify flag configuration + expect(config.getFlagKeys()).toEqual(['test_flag']); + + // Verify bandit variation configuration + // expect(banditVariationDetails.entries).toEqual({ + // 'test_flag': [ + // { + // flagKey: 'test_flag', + // variationId: 'variation-1', + // // Add other expected properties based on your mock data + // } + // ] + // }); + // expect(banditVariationDetails.environment).toBe('test-env'); + // expect(banditVariationDetails.configFormat).toBe('SERVER'); + + // Verify bandit model configuration + const banditVariations = config.getFlagBanditVariations('test_flag'); + expect(banditVariations).toEqual([ + { + allocationKey: 'analysis', + flagKey: 'test_flag', + key: 'bandit', + variationKey: 'bandit', + variationValue: 'bandit', + }, + ]); + + const banditKey = banditVariations.at(0)?.key; + + expect(banditKey).toEqual('bandit'); + if (!banditKey) { + fail('bandit Key null, appeasing typescript'); + } + const banditModelDetails = config.getBandit(banditKey); + expect(banditModelDetails).toEqual({ + banditKey: 'bandit', + modelName: 'falcon', + modelVersion: '123', + updatedAt: '2023-09-13T04:52:06.462Z', + // Add other expected properties based on your mock data + }); + }); + + it('should not fetch bandit parameters if model versions are already loaded', async () => { + const requestor = new ConfigurationRequestor( + httpClient, + flagStore, + banditVariationStore, + banditModelStore, + ); + + const ufcResponse = { + flags: { test_flag: { key: 'test_flag', value: true } }, + banditReferences: { + bandit: { + modelVersion: 'v1', + flagVariations: [{ flagKey: 'test_flag', variationId: '1' }], + }, + }, + environment: 'test', + createdAt: '2024-01-01', + format: 'SERVER', + }; + + await requestor.fetchAndStoreConfigurations(); + // const initialFetchCount = fetchSpy.mock.calls.length; + + // Second call with same model version + // fetchSpy.mockImplementationOnce(() => + // Promise.resolve({ + // ok: true, + // status: 200, + // json: () => Promise.resolve(ufcResponse) + // }) + // ); + + await requestor.fetchAndStoreConfigurations(); + + // Should only have one additional fetch (the UFC) and not the bandit parameters + // expect(fetchSpy.mock.calls.length).toBe(initialFetchCount + 1); + }); + }); + }); }); diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 14ead0b..1f48e09 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,11 +1,16 @@ import { IConfigurationStore } from './configuration-store/configuration-store'; -import { hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; import { IHttpClient } from './http-client'; +import { + ConfigStoreHydrationPacket, + IConfiguration, + StoreBackedConfiguration, +} from './i-configuration'; import { BanditVariation, BanditParameters, Flag, BanditReference } from './interfaces'; // Requests AND stores flag configurations export default class ConfigurationRequestor { private banditModelVersions: string[] = []; + private readonly configuration: StoreBackedConfiguration; constructor( private readonly httpClient: IHttpClient, @@ -14,7 +19,21 @@ export default class ConfigurationRequestor { BanditVariation[] > | null, private readonly banditModelConfigurationStore: IConfigurationStore | null, - ) {} + ) { + this.configuration = new StoreBackedConfiguration( + this.flagConfigurationStore, + this.banditVariationConfigurationStore, + this.banditModelConfigurationStore, + ); + } + + public isFlagConfigExpired(): Promise { + return this.flagConfigurationStore.isExpired(); + } + + public getConfiguration(): IConfiguration { + return this.configuration; + } async fetchAndStoreConfigurations(): Promise { const configResponse = await this.httpClient.getUniversalFlagConfiguration(); @@ -22,13 +41,15 @@ export default class ConfigurationRequestor { return; } - await hydrateConfigurationStore(this.flagConfigurationStore, { + const flagResponsePacket: ConfigStoreHydrationPacket = { entries: configResponse.flags, environment: configResponse.environment, createdAt: configResponse.createdAt, format: configResponse.format, - }); + }; + let banditVariationPacket: ConfigStoreHydrationPacket | undefined; + let banditModelPacket: ConfigStoreHydrationPacket | undefined; const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; const banditStoresProvided = Boolean( this.banditVariationConfigurationStore && this.banditModelConfigurationStore, @@ -37,12 +58,12 @@ export default class ConfigurationRequestor { // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) const banditVariations = this.indexBanditVariationsByFlagKey(configResponse.banditReferences); - await hydrateConfigurationStore(this.banditVariationConfigurationStore, { + banditVariationPacket = { entries: banditVariations, environment: configResponse.environment, createdAt: configResponse.createdAt, format: configResponse.format, - }); + }; if ( this.requiresBanditModelConfigurationStoreUpdate( @@ -52,30 +73,31 @@ export default class ConfigurationRequestor { ) { const banditResponse = await this.httpClient.getBanditParameters(); if (banditResponse?.bandits) { - await hydrateConfigurationStore(this.banditModelConfigurationStore, { + banditModelPacket = { entries: banditResponse.bandits, environment: configResponse.environment, createdAt: configResponse.createdAt, format: configResponse.format, - }); + }; - this.banditModelVersions = this.getLoadedBanditModelVersionsFromStore( - this.banditModelConfigurationStore, - ); + this.banditModelVersions = this.getLoadedBanditModelVersions(banditResponse.bandits); } } } - } - private getLoadedBanditModelVersionsFromStore( - banditModelConfigurationStore: IConfigurationStore | null, - ): string[] { - if (banditModelConfigurationStore === null) { - return []; + if ( + await this.configuration.hydrateConfigurationStores( + flagResponsePacket, + banditVariationPacket, + banditModelPacket, + ) + ) { + // TODO: Notify that config updated. } - return Object.values(banditModelConfigurationStore.entries()).map( - (banditParam: BanditParameters) => banditParam.modelVersion, - ); + } + + private getLoadedBanditModelVersions(entries: Record): string[] { + return Object.values(entries).map((banditParam: BanditParameters) => banditParam.modelVersion); } private requiresBanditModelConfigurationStoreUpdate( diff --git a/src/configuration-store/configuration-store-utils.ts b/src/configuration-store/configuration-store-utils.ts index ebd7bd6..6375de2 100644 --- a/src/configuration-store/configuration-store-utils.ts +++ b/src/configuration-store/configuration-store-utils.ts @@ -9,7 +9,7 @@ import { import { IConfigurationStore } from './configuration-store'; -type Entry = +export type Entry = | Flag | BanditVariation[] | BanditParameters @@ -25,7 +25,7 @@ export async function hydrateConfigurationStore( format: string; salt?: string; }, -): Promise { +): Promise { if (configurationStore) { const didUpdate = await configurationStore.setEntries(response.entries); if (didUpdate) { @@ -35,5 +35,7 @@ export async function hydrateConfigurationStore( configurationStore.setFormat(response.format); configurationStore.salt = response.salt; } + return didUpdate; } + return false; } diff --git a/src/configuration.ts b/src/configuration-wire-types.ts similarity index 100% rename from src/configuration.ts rename to src/configuration-wire-types.ts diff --git a/src/http-client.ts b/src/http-client.ts index 76f2f5b..61cace5 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -1,5 +1,5 @@ import ApiEndpoints from './api-endpoints'; -import { IObfuscatedPrecomputedConfigurationResponse } from './configuration'; +import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire-types'; import { BanditParameters, BanditReference, diff --git a/src/i-configuration.spec.ts b/src/i-configuration.spec.ts new file mode 100644 index 0000000..e368579 --- /dev/null +++ b/src/i-configuration.spec.ts @@ -0,0 +1,431 @@ +import { IConfigurationStore } from './configuration-store/configuration-store'; +import { StoreBackedConfiguration } from './i-configuration'; +import { BanditParameters, BanditVariation, Environment, Flag, ObfuscatedFlag } from './interfaces'; +import { BanditKey, FlagKey } from './types'; + +describe('StoreBackedConfiguration', () => { + let mockFlagStore: jest.Mocked>; + let mockBanditVariationStore: jest.Mocked>; + let mockBanditModelStore: jest.Mocked>; + + beforeEach(() => { + mockFlagStore = { + get: jest.fn(), + getKeys: jest.fn(), + entries: jest.fn(), + setEntries: jest.fn(), + setEnvironment: jest.fn(), + setConfigFetchedAt: jest.fn(), + setConfigPublishedAt: jest.fn(), + setFormat: jest.fn(), + getConfigFetchedAt: jest.fn(), + getConfigPublishedAt: jest.fn(), + getEnvironment: jest.fn(), + getFormat: jest.fn(), + salt: undefined, + init: jest.fn(), + isInitialized: jest.fn(), + isExpired: jest.fn(), + }; + + mockBanditVariationStore = { + get: jest.fn(), + getKeys: jest.fn(), + entries: jest.fn(), + setEntries: jest.fn(), + setEnvironment: jest.fn(), + setConfigFetchedAt: jest.fn(), + setConfigPublishedAt: jest.fn(), + setFormat: jest.fn(), + getConfigFetchedAt: jest.fn(), + getConfigPublishedAt: jest.fn(), + getEnvironment: jest.fn(), + getFormat: jest.fn(), + salt: undefined, + init: jest.fn(), + isInitialized: jest.fn(), + isExpired: jest.fn(), + }; + + mockBanditModelStore = { + get: jest.fn(), + getKeys: jest.fn(), + entries: jest.fn(), + setEntries: jest.fn(), + setEnvironment: jest.fn(), + setConfigFetchedAt: jest.fn(), + setConfigPublishedAt: jest.fn(), + setFormat: jest.fn(), + getConfigFetchedAt: jest.fn(), + getConfigPublishedAt: jest.fn(), + getEnvironment: jest.fn(), + getFormat: jest.fn(), + salt: undefined, + init: jest.fn(), + isInitialized: jest.fn(), + isExpired: jest.fn(), + }; + }); + + describe('hydrateConfigurationStores', () => { + it('should hydrate flag store and return true if updates occurred', async () => { + const config = new StoreBackedConfiguration( + mockFlagStore, + mockBanditVariationStore, + mockBanditModelStore, + ); + + mockFlagStore.setEntries.mockResolvedValue(true); + mockBanditVariationStore.setEntries.mockResolvedValue(true); + mockBanditModelStore.setEntries.mockResolvedValue(true); + + const result = await config.hydrateConfigurationStores( + { + entries: { testFlag: { key: 'test' } as Flag }, + environment: { name: 'test' }, + createdAt: '2024-01-01', + format: 'SERVER', + }, + { + entries: { testVar: [] }, + environment: { name: 'test' }, + createdAt: '2024-01-01', + format: 'SERVER', + }, + { + entries: { testBandit: {} as BanditParameters }, + environment: { name: 'test' }, + createdAt: '2024-01-01', + format: 'SERVER', + }, + ); + + expect(result).toBe(true); + expect(mockFlagStore.setEntries).toHaveBeenCalled(); + expect(mockBanditVariationStore.setEntries).toHaveBeenCalled(); + expect(mockBanditModelStore.setEntries).toHaveBeenCalled(); + }); + }); + + describe('getFlag', () => { + it('should return flag when it exists', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + const mockFlag: Flag = { key: 'test-flag' } as Flag; + mockFlagStore.get.mockReturnValue(mockFlag); + + const result = config.getFlag('test-flag'); + expect(result).toEqual(mockFlag); + }); + + it('should return null when flag does not exist', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + mockFlagStore.get.mockReturnValue(null); + + const result = config.getFlag('non-existent'); + expect(result).toBeNull(); + }); + }); + + describe('getFlagVariationBandit', () => { + it('should return bandit parameters when variation exists', () => { + const config = new StoreBackedConfiguration( + mockFlagStore, + mockBanditVariationStore, + mockBanditModelStore, + ); + + const mockVariations: BanditVariation[] = [ + { + key: 'bandit-1', + variationValue: 'var-1', + flagKey: 'test-flag', + variationKey: 'test-variation', + }, + ]; + const mockBanditParams: BanditParameters = {} as BanditParameters; + + mockBanditVariationStore.get.mockReturnValue(mockVariations); + mockBanditModelStore.get.mockReturnValue(mockBanditParams); + + const result = config.getFlagVariationBandit('test-flag', 'var-1'); + expect(result).toEqual(mockBanditParams); + }); + + it('should return null when variation does not exist', () => { + const config = new StoreBackedConfiguration( + mockFlagStore, + mockBanditVariationStore, + mockBanditModelStore, + ); + + mockBanditVariationStore.get.mockReturnValue([]); + + const result = config.getFlagVariationBandit('test-flag', 'non-existent'); + expect(result).toBeNull(); + }); + }); + + describe('getFlagConfigDetails', () => { + it('should return config details with default values when store returns null', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + mockFlagStore.getConfigFetchedAt.mockReturnValue(null); + mockFlagStore.getConfigPublishedAt.mockReturnValue(null); + mockFlagStore.getEnvironment.mockReturnValue(null); + mockFlagStore.getFormat.mockReturnValue(null); + + const result = config.getFlagConfigDetails(); + expect(result).toEqual({ + configFetchedAt: '', + configPublishedAt: '', + configEnvironment: { name: '' }, + configFormat: '', + }); + }); + + it('should return actual config details when available', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + const mockEnvironment: Environment = { name: 'test' }; + + mockFlagStore.getConfigFetchedAt.mockReturnValue('2024-01-01T00:00:00Z'); + mockFlagStore.getConfigPublishedAt.mockReturnValue('2024-01-01T00:00:00Z'); + mockFlagStore.getEnvironment.mockReturnValue(mockEnvironment); + mockFlagStore.getFormat.mockReturnValue('SERVER'); + + const result = config.getFlagConfigDetails(); + expect(result).toEqual({ + configFetchedAt: '2024-01-01T00:00:00Z', + configPublishedAt: '2024-01-01T00:00:00Z', + configEnvironment: mockEnvironment, + configFormat: 'SERVER', + }); + }); + }); + + describe('getBanditVariations', () => { + it('should return variations when they exist', () => { + const config = new StoreBackedConfiguration(mockFlagStore, mockBanditVariationStore); + const mockVariations: BanditVariation[] = [ + { + key: 'bandit-1', + variationValue: 'var-1', + flagKey: 'test-flag', + variationKey: 'test-variation', + }, + ]; + mockBanditVariationStore.get.mockReturnValue(mockVariations); + + const result = config.getFlagBanditVariations('test-flag'); + expect(result).toEqual(mockVariations); + }); + + it('should return empty array when variations do not exist', () => { + const config = new StoreBackedConfiguration(mockFlagStore, mockBanditVariationStore); + mockBanditVariationStore.get.mockReturnValue(null); + + const result = config.getFlagBanditVariations('test-flag'); + expect(result).toEqual([]); + }); + }); + + describe('getFlagKeys', () => { + it('should return flag keys from store', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + const mockKeys = ['flag-1', 'flag-2']; + mockFlagStore.getKeys.mockReturnValue(mockKeys); + + const result = config.getFlagKeys(); + expect(result).toEqual(mockKeys); + }); + }); + + describe('getFlags', () => { + it('should return all flags from store', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + const mockFlags: Record = { + 'flag-1': { key: 'flag-1' } as Flag, + 'flag-2': { key: 'flag-2' } as Flag, + }; + mockFlagStore.entries.mockReturnValue(mockFlags); + + const result = config.getFlags(); + expect(result).toEqual(mockFlags); + }); + }); + + describe('isObfuscated', () => { + it('should return true for CLIENT format', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + mockFlagStore.getFormat.mockReturnValue('CLIENT'); + + expect(config.isObfuscated()).toBe(true); + }); + + it('should return true for PRECOMPUTED format', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + mockFlagStore.getFormat.mockReturnValue('PRECOMPUTED'); + + expect(config.isObfuscated()).toBe(true); + }); + + it('should return false for SERVER format', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + mockFlagStore.getFormat.mockReturnValue('SERVER'); + + expect(config.isObfuscated()).toBe(false); + }); + + it('should return false when format is undefined', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + mockFlagStore.getFormat.mockReturnValue(null); + + expect(config.isObfuscated()).toBe(false); + }); + }); + + describe('isInitialized', () => { + it('should return false when no stores are initialized', () => { + mockFlagStore.isInitialized.mockReturnValue(false); + mockBanditVariationStore.isInitialized.mockReturnValue(false); + mockBanditModelStore.isInitialized.mockReturnValue(false); + + const config = new StoreBackedConfiguration( + mockFlagStore, + mockBanditVariationStore, + mockBanditModelStore, + ); + + expect(config.isInitialized()).toBe(false); + }); + + it('should return true when all stores are initialized', () => { + mockFlagStore.isInitialized.mockReturnValue(true); + mockBanditVariationStore.isInitialized.mockReturnValue(true); + mockBanditModelStore.isInitialized.mockReturnValue(true); + + const config = new StoreBackedConfiguration( + mockFlagStore, + mockBanditVariationStore, + mockBanditModelStore, + ); + + expect(config.isInitialized()).toBe(true); + }); + + it('should return true when flag store is initialized and no bandit stores are provided', () => { + mockFlagStore.isInitialized.mockReturnValue(true); + + const config = new StoreBackedConfiguration(mockFlagStore); + + expect(config.isInitialized()).toBe(true); + }); + + it('should return false if flag store is uninitialized even if bandit stores are initialized', () => { + mockFlagStore.isInitialized.mockReturnValue(false); + mockBanditVariationStore.isInitialized.mockReturnValue(true); + mockBanditModelStore.isInitialized.mockReturnValue(true); + + const config = new StoreBackedConfiguration( + mockFlagStore, + mockBanditVariationStore, + mockBanditModelStore, + ); + + expect(config.isInitialized()).toBe(false); + }); + + it('should return false if any bandit store is uninitialized', () => { + mockFlagStore.isInitialized.mockReturnValue(true); + mockBanditVariationStore.isInitialized.mockReturnValue(true); + mockBanditModelStore.isInitialized.mockReturnValue(false); + + const config = new StoreBackedConfiguration( + mockFlagStore, + mockBanditVariationStore, + mockBanditModelStore, + ); + + expect(config.isInitialized()).toBe(false); + }); + }); + + describe('getBandits', () => { + it('should return empty object when bandit store is null', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + expect(config.getBandits()).toEqual({}); + }); + + it('should return bandits from store', () => { + const mockBandits: Record = { + 'bandit-1': { + banditKey: 'bandit-1', + modelName: 'falcon', + modelVersion: '123', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }, + 'bandit-2': { + banditKey: 'bandit-2', + modelName: 'falcon', + modelVersion: '123', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }, + }; + + mockBanditModelStore.entries.mockReturnValue(mockBandits); + + const config = new StoreBackedConfiguration(mockFlagStore, null, mockBanditModelStore); + + expect(config.getBandits()).toEqual(mockBandits); + }); + }); + + describe('getBanditVariations', () => { + it('should return empty variations when bandit variation store is null', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + expect(config.getBanditVariations()).toEqual({}); + }); + + it('should return flag variations from store', () => { + const mockVariations: Record = { + 'bandit-1': [ + { + key: 'bandit-1', + variationValue: 'true', + flagKey: 'flag_with_bandit', + variationKey: 'bandit-1', + }, + ], + 'bandit-2': [ + { + key: 'bandit-2', + variationValue: 'true', + flagKey: 'flag_with_bandit2', + variationKey: 'bandit-2', + }, + ], + }; + + mockBanditVariationStore.entries.mockReturnValue(mockVariations); + + const config = new StoreBackedConfiguration(mockFlagStore, mockBanditVariationStore); + + expect(config.getBanditVariations()['bandit-1']).toEqual([ + { + key: 'bandit-1', + variationValue: 'true', + flagKey: 'flag_with_bandit', + variationKey: 'bandit-1', + }, + ]); + }); + }); +}); diff --git a/src/i-configuration.ts b/src/i-configuration.ts new file mode 100644 index 0000000..f8d3b0d --- /dev/null +++ b/src/i-configuration.ts @@ -0,0 +1,133 @@ +import { IConfigurationStore } from './configuration-store/configuration-store'; +import { Entry, hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; +import { OBFUSCATED_FORMATS } from './constants'; +import { + BanditParameters, + BanditVariation, + ConfigDetails, + Environment, + Flag, + ObfuscatedFlag, +} from './interfaces'; +import { BanditKey, FlagKey, HashedFlagKey } from './types'; + +export interface IConfiguration { + getFlag(key: FlagKey | HashedFlagKey): Flag | ObfuscatedFlag | null; + getFlags(): Record; + getBandits(): Record; + getBanditVariations(): Record; + getFlagBanditVariations(flagKey: FlagKey | HashedFlagKey): BanditVariation[]; + getFlagVariationBandit( + flagKey: FlagKey | HashedFlagKey, + variationValue: string, + ): BanditParameters | null; + getBandit(key: BanditKey): BanditParameters | null; + getFlagConfigDetails(): ConfigDetails; + getFlagKeys(): FlagKey[] | HashedFlagKey[]; + isObfuscated(): boolean; + isInitialized(): boolean; +} + +export type ConfigStoreHydrationPacket = { + entries: Record; + environment: Environment; + createdAt: string; + format: string; + salt?: string; +}; + +export class StoreBackedConfiguration implements IConfiguration { + constructor( + private readonly flagConfigurationStore: IConfigurationStore, + private readonly banditVariationConfigurationStore?: IConfigurationStore< + BanditVariation[] + > | null, + private readonly banditModelConfigurationStore?: IConfigurationStore | null, + ) {} + + public async hydrateConfigurationStores( + flagConfig: ConfigStoreHydrationPacket, + banditVariationConfig?: ConfigStoreHydrationPacket, + banditModelConfig?: ConfigStoreHydrationPacket, + ) { + const didUpdateFlags = await hydrateConfigurationStore(this.flagConfigurationStore, flagConfig); + const promises: Promise[] = []; + if (this.banditVariationConfigurationStore && banditVariationConfig) { + promises.push( + hydrateConfigurationStore(this.banditVariationConfigurationStore, banditVariationConfig), + ); + } + if (this.banditModelConfigurationStore && banditModelConfig) { + promises.push( + hydrateConfigurationStore(this.banditModelConfigurationStore, banditModelConfig), + ); + } + await Promise.all(promises); + return didUpdateFlags; + } + + getBandit(key: string): BanditParameters | null { + return this.banditModelConfigurationStore?.get(key) ?? null; + } + + getFlagVariationBandit(flagKey: string, variationValue: string): BanditParameters | null { + const banditVariations = this.banditVariationConfigurationStore?.get(flagKey); + const banditKey = banditVariations?.find( + (banditVariation) => banditVariation.variationValue === variationValue, + )?.key; + + if (banditKey) { + // Retrieve the model parameters for the bandit + return this.getBandit(banditKey); + } + return null; + } + + getFlag(key: string): Flag | ObfuscatedFlag | null { + return this.flagConfigurationStore.get(key) ?? null; + } + + getFlagConfigDetails(): ConfigDetails { + return { + configFetchedAt: this.flagConfigurationStore.getConfigFetchedAt() ?? '', + configPublishedAt: this.flagConfigurationStore.getConfigPublishedAt() ?? '', + configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { + name: '', + }, + configFormat: this.flagConfigurationStore.getFormat() ?? '', + }; + } + + getFlagBanditVariations(flagKey: string): BanditVariation[] { + return this.banditVariationConfigurationStore?.get(flagKey) ?? []; + } + + getFlagKeys(): string[] { + return this.flagConfigurationStore.getKeys(); + } + + getFlags(): Record { + return this.flagConfigurationStore.entries(); + } + + isObfuscated(): boolean { + return OBFUSCATED_FORMATS.includes(this.getFlagConfigDetails().configFormat ?? 'SERVER'); + } + + isInitialized() { + return ( + this.flagConfigurationStore.isInitialized() && + (!this.banditVariationConfigurationStore || + this.banditVariationConfigurationStore.isInitialized()) && + (!this.banditModelConfigurationStore || this.banditModelConfigurationStore.isInitialized()) + ); + } + + getBandits(): Record { + return this.banditModelConfigurationStore?.entries() ?? {}; + } + + getBanditVariations(): Record { + return this.banditVariationConfigurationStore?.entries() ?? {}; + } +} diff --git a/src/index.ts b/src/index.ts index 85489a5..df1770b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,11 +25,6 @@ import EppoPrecomputedClient, { PrecomputedFlagsRequestParameters, Subject, } from './client/eppo-precomputed-client'; -import { - IConfigurationWire, - IObfuscatedPrecomputedConfigurationResponse, - IPrecomputedConfigurationResponse, -} from './configuration'; import FlagConfigRequestor from './configuration-requestor'; import { IConfigurationStore, @@ -38,6 +33,11 @@ import { } from './configuration-store/configuration-store'; import { HybridConfigurationStore } from './configuration-store/hybrid.store'; import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; +import { + IConfigurationWire, + IObfuscatedPrecomputedConfigurationResponse, + IPrecomputedConfigurationResponse, +} from './configuration-wire-types'; import * as constants from './constants'; import { decodePrecomputedFlag } from './decoding'; import BatchEventProcessor from './events/batch-event-processor'; diff --git a/src/precomputed-requestor.ts b/src/precomputed-requestor.ts index ec128ad..ef8e0fc 100644 --- a/src/precomputed-requestor.ts +++ b/src/precomputed-requestor.ts @@ -30,7 +30,7 @@ export default class PrecomputedFlagRequestor { return; } - const promises: Promise[] = []; + const promises: Promise[] = []; promises.push( hydrateConfigurationStore(this.precomputedFlagStore, { entries: precomputedResponse.flags, @@ -51,5 +51,6 @@ export default class PrecomputedFlagRequestor { }), ); } + await Promise.all(promises); } } diff --git a/src/types.ts b/src/types.ts index 8390427..abc4e57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,4 +14,5 @@ export type BanditActions = export type Base64String = string; export type MD5String = string; export type FlagKey = string; +export type BanditKey = string; export type HashedFlagKey = FlagKey;