From 51a33b2d05e776f4e47abea03d379a5bd87cc17d Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 21 Oct 2025 11:05:45 -0400 Subject: [PATCH 1/4] chore: remove release-as properties (#1264) Cleanup: removing `release-as` properties from release-please config after successfully validating OIDC npm publishing. Signed-off-by: Jonathan Norris --- release-please-config.json | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index 0243edbf0..4b909e3d6 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -9,8 +9,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "extra-files": ["README.md"], - "versioning": "default", - "release-as": "1.19.1" + "versioning": "default" }, "packages/web": { "release-type": "node", @@ -18,8 +17,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "extra-files": ["README.md"], - "versioning": "default", - "release-as": "1.6.3" + "versioning": "default" }, "packages/react": { "release-type": "node", @@ -27,8 +25,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "extra-files": ["README.md"], - "versioning": "default", - "release-as": "1.0.2" + "versioning": "default" }, "packages/angular/projects/angular-sdk": { "release-type": "node", @@ -36,8 +33,7 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "extra-files": ["README.md"], - "versioning": "default", - "release-as": "0.0.19" + "versioning": "default" }, "packages/nest": { "release-type": "node", @@ -45,16 +41,14 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "extra-files": ["README.md"], - "versioning": "default", - "release-as": "0.2.6" + "versioning": "default" }, "packages/shared": { "release-type": "node", "prerelease": false, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, - "versioning": "default", - "release-as": "1.9.2" + "versioning": "default" } }, "changelog-sections": [ From 8686dbfbe2b95b20706718bf23287244eda36f60 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 21 Oct 2025 13:46:09 -0400 Subject: [PATCH 2/4] feat: Migrate MultiProvider from js-sdk-contrib (#1234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR migrates the `MultiProvider` functionality from the [js-sdk-contrib](https://github.com/open-feature/js-sdk-contrib) repository into the main js-sdk, providing built-in support for multi-provider evaluation strategies. Addresses #1217 ### What's Changed - **Added `MultiProvider`** for server SDK with support for multiple evaluation strategies - **Added `WebMultiProvider`** for web SDK with web-specific optimizations - **Included 3 evaluation strategies**: FirstMatch, FirstSuccessful, and Comparison - **Comprehensive test coverage** for both server and web implementations - **Updated documentation** with usage examples and strategy explanations ### Testing - ✅ All existing tests pass - ✅ New comprehensive test suites for both server and web MultiProvider - ✅ ESLint compliance across all new code --------- Signed-off-by: Jonathan Norris --- .eslintrc.json | 9 + packages/server/README.md | 78 +- packages/server/src/provider/index.ts | 1 + .../src/provider/multi-provider/README.md | 185 ++++ .../src/provider/multi-provider/errors.ts | 53 + .../provider/multi-provider/hook-executor.ts | 70 ++ .../src/provider/multi-provider/index.ts | 3 + .../provider/multi-provider/multi-provider.ts | 361 +++++++ .../provider/multi-provider/status-tracker.ts | 67 ++ .../strategies/base-evaluation-strategy.ts | 130 +++ .../strategies/comparison-strategy.ts | 72 ++ .../strategies/first-match-strategy.ts | 36 + .../strategies/first-successful-strategy.ts | 31 + .../multi-provider/strategies/index.ts | 4 + .../src/provider/multi-provider/types.ts | 10 + packages/server/test/multi-provider.spec.ts | 937 ++++++++++++++++++ packages/web/README.md | 57 ++ packages/web/src/provider/index.ts | 1 + .../web/src/provider/multi-provider/README.md | 199 ++++ .../web/src/provider/multi-provider/errors.ts | 53 + .../provider/multi-provider/hook-executor.ts | 62 ++ .../web/src/provider/multi-provider/index.ts | 3 + .../multi-provider/multi-provider-web.ts | 349 +++++++ .../provider/multi-provider/status-tracker.ts | 75 ++ .../strategies/base-evaluation-strategy.ts | 128 +++ .../strategies/comparison-strategy.ts | 70 ++ .../strategies/first-match-strategy.ts | 36 + .../strategies/first-successful-strategy.ts | 31 + .../multi-provider/strategies/index.ts | 4 + .../web/src/provider/multi-provider/types.ts | 10 + packages/web/test/evaluation-context.spec.ts | 6 +- packages/web/test/multi-provider-web.spec.ts | 894 +++++++++++++++++ 32 files changed, 4020 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/provider/multi-provider/README.md create mode 100644 packages/server/src/provider/multi-provider/errors.ts create mode 100644 packages/server/src/provider/multi-provider/hook-executor.ts create mode 100644 packages/server/src/provider/multi-provider/index.ts create mode 100644 packages/server/src/provider/multi-provider/multi-provider.ts create mode 100644 packages/server/src/provider/multi-provider/status-tracker.ts create mode 100644 packages/server/src/provider/multi-provider/strategies/base-evaluation-strategy.ts create mode 100644 packages/server/src/provider/multi-provider/strategies/comparison-strategy.ts create mode 100644 packages/server/src/provider/multi-provider/strategies/first-match-strategy.ts create mode 100644 packages/server/src/provider/multi-provider/strategies/first-successful-strategy.ts create mode 100644 packages/server/src/provider/multi-provider/strategies/index.ts create mode 100644 packages/server/src/provider/multi-provider/types.ts create mode 100644 packages/server/test/multi-provider.spec.ts create mode 100644 packages/web/src/provider/multi-provider/README.md create mode 100644 packages/web/src/provider/multi-provider/errors.ts create mode 100644 packages/web/src/provider/multi-provider/hook-executor.ts create mode 100644 packages/web/src/provider/multi-provider/index.ts create mode 100644 packages/web/src/provider/multi-provider/multi-provider-web.ts create mode 100644 packages/web/src/provider/multi-provider/status-tracker.ts create mode 100644 packages/web/src/provider/multi-provider/strategies/base-evaluation-strategy.ts create mode 100644 packages/web/src/provider/multi-provider/strategies/comparison-strategy.ts create mode 100644 packages/web/src/provider/multi-provider/strategies/first-match-strategy.ts create mode 100644 packages/web/src/provider/multi-provider/strategies/first-successful-strategy.ts create mode 100644 packages/web/src/provider/multi-provider/strategies/index.ts create mode 100644 packages/web/src/provider/multi-provider/types.ts create mode 100644 packages/web/test/multi-provider-web.spec.ts diff --git a/.eslintrc.json b/.eslintrc.json index a08b5bd52..6b72d306d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,6 +12,15 @@ }, "plugins": ["@typescript-eslint", "check-file", "jsdoc"], "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "ignoreRestSiblings": true + } + ], "@typescript-eslint/consistent-type-imports": [ "error", { diff --git a/packages/server/README.md b/packages/server/README.md index d1638c8ce..0966db4ac 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -140,7 +140,83 @@ OpenFeature.setProvider(new MyProvider()); Once the provider has been registered, the status can be tracked using [events](#eventing). In some situations, it may be beneficial to register multiple providers in the same application. -This is possible using [domains](#domains), which is covered in more details below. +This is possible using [domains](#domains), which is covered in more detail below. + +#### Multi-Provider + +The Multi-Provider allows you to use multiple underlying providers as sources of flag data for the OpenFeature server SDK. When a flag is being evaluated, the Multi-Provider will consult each underlying provider it is managing in order to determine the final result. Different evaluation strategies can be defined to control which providers get evaluated and which result is used. + +The Multi-Provider is a powerful tool for performing migrations between flag providers, or combining multiple providers into a single feature flagging interface. For example: + +- **Migration**: Gradually migrate from one provider to another by serving some flags from your old provider and some from your new provider +- **Backup**: Use one provider as a backup for another in case of failures +- **Comparison**: Compare results from multiple providers to validate consistency +- **Hybrid**: Combine multiple providers to leverage different strengths (e.g., one for simple flags, another for complex targeting) + +```ts +import { OpenFeature, MultiProvider, FirstMatchStrategy } from '@openfeature/server-sdk'; + +// Create providers +const primaryProvider = new YourPrimaryProvider(); +const backupProvider = new YourBackupProvider(); + +// Create multi-provider with a strategy +const multiProvider = new MultiProvider( + [primaryProvider, backupProvider], + new FirstMatchStrategy() +); + +// Register the multi-provider +await OpenFeature.setProviderAndWait(multiProvider); + +// Use as normal +const client = OpenFeature.getClient(); +const value = await client.getBooleanValue('my-flag', false); +``` + +**Available Strategies:** + +- `FirstMatchStrategy`: Returns the first successful result from the list of providers +- `ComparisonStrategy`: Compares results from multiple providers and can handle discrepancies + +**Migration Example:** + +```ts +import { OpenFeature, MultiProvider, FirstMatchStrategy } from '@openfeature/server-sdk'; + +// During migration, serve some flags from the new provider and fallback to the old one +const newProvider = new NewFlagProvider(); +const oldProvider = new OldFlagProvider(); + +const multiProvider = new MultiProvider( + [newProvider, oldProvider], // New provider is consulted first + new FirstMatchStrategy() +); + +await OpenFeature.setProviderAndWait(multiProvider); +``` + +**Comparison Example:** + +```ts +import { OpenFeature, MultiProvider, ComparisonStrategy } from '@openfeature/server-sdk'; + +// Compare results from two providers for validation +const providerA = new ProviderA(); +const providerB = new ProviderB(); + +const multiProvider = new MultiProvider( + [ + { provider: providerA }, + { provider: providerB } + ], + new ComparisonStrategy(providerA, (resolutions) => { + console.warn('Mismatch detected', resolutions); + }) +); + +await OpenFeature.setProviderAndWait(multiProvider); +``` ### Targeting diff --git a/packages/server/src/provider/index.ts b/packages/server/src/provider/index.ts index 33e12fea3..ca2771629 100644 --- a/packages/server/src/provider/index.ts +++ b/packages/server/src/provider/index.ts @@ -1,3 +1,4 @@ export * from './provider'; export * from './no-op-provider'; export * from './in-memory-provider'; +export * from './multi-provider'; diff --git a/packages/server/src/provider/multi-provider/README.md b/packages/server/src/provider/multi-provider/README.md new file mode 100644 index 000000000..7543c236b --- /dev/null +++ b/packages/server/src/provider/multi-provider/README.md @@ -0,0 +1,185 @@ +# OpenFeature Multi-Provider + +The Multi-Provider allows you to use multiple underlying providers as sources of flag data for the OpenFeature server SDK. +When a flag is being evaluated, the Multi-Provider will consult each underlying provider it is managing in order to determine +the final result. Different evaluation strategies can be defined to control which providers get evaluated and which result is used. + +The Multi-Provider is a powerful tool for performing migrations between flag providers, or combining multiple providers into a single +feature flagging interface. For example: + +- *Migration*: When migrating between two providers, you can run both in parallel under a unified flagging interface. As flags are added to the +new provider, the Multi-Provider will automatically find and return them, falling back to the old provider if the new provider does not have +- *Multiple Data Sources*: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables, +local files, database values and SaaS hosted feature management systems. + +## Usage + +The Multi-Provider is initialized with an array of providers it should evaluate: + +```typescript +import { MultiProvider } from '@openfeature/server-sdk' +import { OpenFeature } from '@openfeature/server-sdk' + +const multiProvider = new MultiProvider([ + { provider: new ProviderA() }, + { provider: new ProviderB() } +]) + +await OpenFeature.setProviderAndWait(multiProvider) + +const client = OpenFeature.getClient() + +console.log("Evaluating flag") +console.log(await client.getBooleanDetails("my-flag", false)); +``` + +By default, the Multi-Provider will evaluate all underlying providers in order and return the first successful result. If a provider indicates +it does not have a flag (FLAG_NOT_FOUND error code), then it will be skipped and the next provider will be evaluated. If any provider throws +or returns an error result, the operation will fail and the error will be thrown. If no provider returns a successful result, the operation +will fail with a FLAG_NOT_FOUND error code. + +To change this behaviour, a different "strategy" can be provided: + +```typescript +import { MultiProvider, FirstSuccessfulStrategy } from '@openfeature/server-sdk' + +const multiProvider = new MultiProvider( + [ + { provider: new ProviderA() }, + { provider: new ProviderB() } + ], + new FirstSuccessfulStrategy() +) +``` + +## Strategies + +The Multi-Provider comes with three strategies out of the box: + +- `FirstMatchStrategy` (default): Evaluates all providers in order and returns the first successful result. Providers that indicate FLAG_NOT_FOUND error will be skipped and the next provider will be evaluated. Any other error will cause the operation to fail and the set of errors to be thrown. +- `FirstSuccessfulStrategy`: Evaluates all providers in order and returns the first successful result. Any error will cause that provider to be skipped. +If no successful result is returned, the set of errors will be thrown. +- `ComparisonStrategy`: Evaluates all providers in parallel. If every provider returns a successful result with the same value, then that result is returned. +Otherwise, the result returned by the configured "fallback provider" will be used. When values do not agree, an optional callback will be executed to notify +you of the mismatch. This can be useful when migrating between providers that are expected to contain identical configuration. You can easily spot mismatches +in configuration without affecting flag behaviour. + +This strategy accepts several arguments during initialization: + +```typescript +import { MultiProvider, ComparisonStrategy } from '@openfeature/server-sdk' + +const providerA = new ProviderA() +const multiProvider = new MultiProvider( + [ + { provider: providerA }, + { provider: new ProviderB() } + ], + new ComparisonStrategy(providerA, (details) => { + console.log("Mismatch detected", details) + }) +) +``` + +The first argument is the "fallback provider" whose value to use in the event that providers do not agree. It should be the same object reference as one of the providers in the list. The second argument is a callback function that will be executed when a mismatch is detected. The callback will be passed an object containing the details of each provider's resolution, including the flag key, the value returned, and any errors that were thrown. + +## Tracking Support + +The Multi-Provider supports tracking events across multiple providers. When you call the `track` method, it will by default send the tracking event to all underlying providers that implement the `track` method. + +```typescript +import { OpenFeature } from '@openfeature/server-sdk' +import { MultiProvider } from '@openfeature/server-sdk' + +const multiProvider = new MultiProvider([ + { provider: new ProviderA() }, + { provider: new ProviderB() } +]) + +await OpenFeature.setProviderAndWait(multiProvider) +const client = OpenFeature.getClient() + +// Tracked events will be sent to all providers by default +client.track('purchase', { targetingKey: 'user123' }, { value: 99.99, currency: 'USD' }) +``` + +### Tracking Behavior + +- **Default**: All providers receive tracking calls by default +- **Error Handling**: If one provider fails to track, others continue normally and errors are logged +- **Provider Status**: Providers in `NOT_READY` or `FATAL` status are automatically skipped +- **Optional Method**: Providers without a `track` method are gracefully skipped + +### Customizing Tracking with Strategies + +You can customize which providers receive tracking calls by overriding the `shouldTrackWithThisProvider` method in your custom strategy: + +```typescript +import { BaseEvaluationStrategy, StrategyPerProviderContext } from '@openfeature/server-sdk' + +class CustomTrackingStrategy extends BaseEvaluationStrategy { + shouldTrackWithThisProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + trackingEventName: string, + trackingEventDetails: TrackingEventDetails, + ): boolean { + // Only track with the primary provider + if (strategyContext.providerName === 'primary-provider') { + return true; + } + + // Skip tracking for analytics events on backup providers + if (trackingEventName.startsWith('analytics.')) { + return false; + } + + return super.shouldTrackWithThisProvider(strategyContext, context, trackingEventName, trackingEventDetails); + } +} +``` + +## Custom Strategies + +It is also possible to implement your own strategy if the above options do not fit your use case. To do so, create a class which implements the "BaseEvaluationStrategy": + +```typescript +export abstract class BaseEvaluationStrategy { + public runMode: 'parallel' | 'sequential' = 'sequential'; + + abstract shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, evalContext: EvaluationContext): boolean; + + abstract shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean; + + abstract shouldTrackWithThisProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + trackingEventName: string, + trackingEventDetails: TrackingEventDetails, + ): boolean; + + abstract determineFinalResult( + strategyContext: StrategyEvaluationContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult; +} +``` + +The `runMode` property determines whether the list of providers will be evaluated sequentially or in parallel. + +The `shouldEvaluateThisProvider` method is called just before a provider is evaluated by the Multi-Provider. If the function returns `false`, then +the provider will be skipped instead of being evaluated. The function is called with details about the evaluation including the flag key and type. +Check the type definitions for the full list. + +The `shouldEvaluateNextProvider` function is called after a provider is evaluated. If it returns `true`, the next provider in the sequence will be called, +otherwise no more providers will be evaluated. It is called with the same data as `shouldEvaluateThisProvider` as well as the details about the evaluation result. This function is not called when the `runMode` is `parallel`. + +The `shouldTrackWithThisProvider` method is called before sending a tracking event to each provider. Return `false` to skip tracking with that provider. By default, it only tracks with providers that are in a ready state (not `NOT_READY` or `FATAL`). Override this method to implement custom tracking logic based on the tracking event name, details, or provider characteristics. + +The `determineFinalResult` function is called after all providers have been called, or the `shouldEvaluateNextProvider` function returned false. It is called +with a list of results from all the individual providers' evaluations. It returns the final decision for evaluation result, or throws an error if needed. diff --git a/packages/server/src/provider/multi-provider/errors.ts b/packages/server/src/provider/multi-provider/errors.ts new file mode 100644 index 000000000..f1f14438d --- /dev/null +++ b/packages/server/src/provider/multi-provider/errors.ts @@ -0,0 +1,53 @@ +import type { ErrorCode } from '@openfeature/core'; +import { GeneralError, OpenFeatureError } from '@openfeature/core'; +import type { RegisteredProvider } from './types'; + +export class ErrorWithCode extends OpenFeatureError { + constructor( + public code: ErrorCode, + message: string, + ) { + super(message); + } +} + +export class AggregateError extends GeneralError { + constructor( + message: string, + public originalErrors: { source: string; error: unknown }[], + ) { + super(message); + } +} + +export const constructAggregateError = (providerErrors: { error: unknown; providerName: string }[]) => { + const errorsWithSource = providerErrors + .map(({ providerName, error }) => { + return { source: providerName, error }; + }) + .flat(); + + // log first error in the message for convenience, but include all errors in the error object for completeness + return new AggregateError( + `Provider errors occurred: ${errorsWithSource[0].source}: ${errorsWithSource[0].error}`, + errorsWithSource, + ); +}; + +export const throwAggregateErrorFromPromiseResults = ( + result: PromiseSettledResult[], + providerEntries: RegisteredProvider[], +) => { + const errors = result + .map((r, i) => { + if (r.status === 'rejected') { + return { error: r.reason, providerName: providerEntries[i].name }; + } + return null; + }) + .filter((val): val is { error: unknown; providerName: string } => Boolean(val)); + + if (errors.length) { + throw constructAggregateError(errors); + } +}; diff --git a/packages/server/src/provider/multi-provider/hook-executor.ts b/packages/server/src/provider/multi-provider/hook-executor.ts new file mode 100644 index 000000000..55d085974 --- /dev/null +++ b/packages/server/src/provider/multi-provider/hook-executor.ts @@ -0,0 +1,70 @@ +import type { EvaluationDetails, FlagValue, HookContext, HookHints, Logger } from '@openfeature/core'; +import type { Hook } from '../../hooks'; + +/** + * Utility for executing a set of hooks of each type. Implementation is largely copied from the main OpenFeature SDK. + */ +export class HookExecutor { + constructor(private logger: Logger) {} + + async beforeHooks(hooks: Hook[] | undefined, hookContext: HookContext, hints: HookHints) { + for (const hook of hooks ?? []) { + // freeze the hookContext + Object.freeze(hookContext); + + Object.assign(hookContext.context, { + ...(await hook?.before?.(hookContext, Object.freeze(hints))), + }); + } + + // after before hooks, freeze the EvaluationContext. + return Object.freeze(hookContext.context); + } + + async afterHooks( + hooks: Hook[] | undefined, + hookContext: HookContext, + evaluationDetails: EvaluationDetails, + hints: HookHints, + ) { + // run "after" hooks sequentially + for (const hook of hooks ?? []) { + await hook?.after?.(hookContext, evaluationDetails, hints); + } + } + + async errorHooks(hooks: Hook[] | undefined, hookContext: HookContext, err: unknown, hints: HookHints) { + // run "error" hooks sequentially + for (const hook of hooks ?? []) { + try { + await hook?.error?.(hookContext, err, hints); + } catch (err) { + this.logger.error(`Unhandled error during 'error' hook: ${err}`); + if (err instanceof Error) { + this.logger.error(err.stack); + } + this.logger.error((err as Error)?.stack); + } + } + } + + async finallyHooks( + hooks: Hook[] | undefined, + hookContext: HookContext, + evaluationDetails: EvaluationDetails, + hints: HookHints, + ) { + // run "finally" hooks sequentially + for (const hook of hooks ?? []) { + try { + await hook?.finally?.(hookContext, evaluationDetails, hints); + } catch (err) { + this.logger.error(`Unhandled error during 'finally' hook: ${err}`); + if (err instanceof Error) { + this.logger.error(err.stack); + } + this.logger.error((err as Error)?.stack); + } + } + } +} diff --git a/packages/server/src/provider/multi-provider/index.ts b/packages/server/src/provider/multi-provider/index.ts new file mode 100644 index 000000000..e7c2b3828 --- /dev/null +++ b/packages/server/src/provider/multi-provider/index.ts @@ -0,0 +1,3 @@ +export * from './multi-provider'; +export * from './errors'; +export * from './strategies'; diff --git a/packages/server/src/provider/multi-provider/multi-provider.ts b/packages/server/src/provider/multi-provider/multi-provider.ts new file mode 100644 index 000000000..998c9149c --- /dev/null +++ b/packages/server/src/provider/multi-provider/multi-provider.ts @@ -0,0 +1,361 @@ +import type { + BeforeHookContext, + EvaluationContext, + EvaluationDetails, + FlagMetadata, + FlagValue, + FlagValueType, + HookContext, + HookHints, + JsonValue, + Logger, + OpenFeatureError, + ProviderMetadata, + ResolutionDetails, + TrackingEventDetails, +} from '@openfeature/core'; +import { DefaultLogger, ErrorCode, GeneralError, StandardResolutionReasons } from '@openfeature/core'; +import type { Provider } from '../provider'; +import type { Hook } from '../../hooks'; +import { OpenFeatureEventEmitter } from '../../events/open-feature-event-emitter'; +import { constructAggregateError, throwAggregateErrorFromPromiseResults } from './errors'; +import { HookExecutor } from './hook-executor'; +import { StatusTracker } from './status-tracker'; +import type { BaseEvaluationStrategy, ProviderResolutionResult } from './strategies'; +import { FirstMatchStrategy } from './strategies'; +import type { ProviderEntryInput, RegisteredProvider } from './types'; + +export class MultiProvider implements Provider { + readonly runsOn = 'server'; + + public readonly events = new OpenFeatureEventEmitter(); + + private hookContexts: WeakMap = new WeakMap(); + private hookHints: WeakMap = new WeakMap(); + + metadata: ProviderMetadata; + + providerEntries: RegisteredProvider[] = []; + private providerEntriesByName: Record = {}; + + private hookExecutor: HookExecutor; + private statusTracker = new StatusTracker(this.events); + + constructor( + readonly constructorProviders: ProviderEntryInput[], + private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy(), + private readonly logger: Logger = new DefaultLogger(), + ) { + this.hookExecutor = new HookExecutor(this.logger); + + this.registerProviders(constructorProviders); + + const aggregateMetadata = Object.keys(this.providerEntriesByName).reduce((acc, name) => { + return { ...acc, [name]: this.providerEntriesByName[name].provider.metadata }; + }, {}); + + this.metadata = { + ...aggregateMetadata, + name: MultiProvider.name, + }; + } + + private registerProviders(constructorProviders: ProviderEntryInput[]) { + const providersByName: Record = {}; + + for (const constructorProvider of constructorProviders) { + const providerName = constructorProvider.provider.metadata.name; + const candidateName = constructorProvider.name ?? providerName; + + if (constructorProvider.name && providersByName[constructorProvider.name]) { + throw new Error('Provider names must be unique'); + } + + providersByName[candidateName] ??= []; + providersByName[candidateName].push(constructorProvider.provider); + } + + for (const name of Object.keys(providersByName)) { + const useIndexedNames = providersByName[name].length > 1; + for (let i = 0; i < providersByName[name].length; i++) { + const indexedName = useIndexedNames ? `${name}-${i + 1}` : name; + this.providerEntriesByName[indexedName] = { provider: providersByName[name][i], name: indexedName }; + this.providerEntries.push(this.providerEntriesByName[indexedName]); + this.statusTracker.wrapEventHandler(this.providerEntriesByName[indexedName]); + } + } + + // just make sure we don't accidentally modify these later + Object.freeze(this.providerEntries); + Object.freeze(this.providerEntriesByName); + } + + async initialize(context?: EvaluationContext): Promise { + const result = await Promise.allSettled( + this.providerEntries.map((provider) => provider.provider.initialize?.(context)), + ); + throwAggregateErrorFromPromiseResults(result, this.providerEntries); + } + + async onClose(): Promise { + const result = await Promise.allSettled(this.providerEntries.map((provider) => provider.provider.onClose?.())); + throwAggregateErrorFromPromiseResults(result, this.providerEntries); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + ): Promise> { + return this.flagResolutionProxy(flagKey, 'boolean', defaultValue, context); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + ): Promise> { + return this.flagResolutionProxy(flagKey, 'string', defaultValue, context); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + ): Promise> { + return this.flagResolutionProxy(flagKey, 'number', defaultValue, context); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: U, + context: EvaluationContext, + ): Promise> { + return this.flagResolutionProxy(flagKey, 'object', defaultValue, context); + } + + private async flagResolutionProxy( + flagKey: string, + flagType: FlagValueType, + defaultValue: T, + context: EvaluationContext, + ): Promise> { + const hookContext = this.hookContexts.get(context); + const hookHints = this.hookHints.get(context); + + if (!hookContext || !hookHints) { + throw new GeneralError('Hook context not available for evaluation'); + } + + const tasks: Promise<[boolean, ProviderResolutionResult | null]>[] = []; + + for (const providerEntry of this.providerEntries) { + const task = this.evaluateProviderEntry( + flagKey, + flagType, + defaultValue, + providerEntry, + hookContext, + hookHints, + context, + ); + + tasks.push(task); + + if (this.evaluationStrategy.runMode === 'sequential') { + const [shouldEvaluateNext] = await task; + if (!shouldEvaluateNext) { + break; + } + } + } + + const results = await Promise.all(tasks); + const resolutions = results + .map(([, resolution]) => resolution) + .filter((r): r is ProviderResolutionResult => Boolean(r)); + + const finalResult = this.evaluationStrategy.determineFinalResult({ flagKey, flagType }, context, resolutions); + + if (finalResult.errors?.length) { + throw constructAggregateError(finalResult.errors); + } + + if (!finalResult.details) { + throw new GeneralError('No result was returned from any provider'); + } + + return finalResult.details; + } + + private async evaluateProviderEntry( + flagKey: string, + flagType: FlagValueType, + defaultValue: T, + providerEntry: RegisteredProvider, + hookContext: HookContext, + hookHints: HookHints, + context: EvaluationContext, + ): Promise<[boolean, ProviderResolutionResult | null]> { + let evaluationResult: ResolutionDetails | undefined = undefined; + const provider = providerEntry.provider; + const strategyContext = { + flagKey, + flagType, + provider, + providerName: providerEntry.name, + providerStatus: this.statusTracker.providerStatus(providerEntry.name), + }; + + if (!this.evaluationStrategy.shouldEvaluateThisProvider(strategyContext, context)) { + return [true, null]; + } + + let resolution: ProviderResolutionResult; + + try { + evaluationResult = await this.evaluateProviderAndHooks(flagKey, defaultValue, provider, hookContext, hookHints); + resolution = { + details: evaluationResult, + provider: provider, + providerName: providerEntry.name, + }; + } catch (error: unknown) { + resolution = { + thrownError: error, + provider: provider, + providerName: providerEntry.name, + }; + } + + return [ + this.evaluationStrategy.runMode === 'sequential' + ? this.evaluationStrategy.shouldEvaluateNextProvider(strategyContext, context, resolution) + : true, + resolution, + ]; + } + + private async evaluateProviderAndHooks( + flagKey: string, + defaultValue: T, + provider: Provider, + hookContext: HookContext, + hookHints: HookHints, + ) { + let providerContext: EvaluationContext | undefined = undefined; + let evaluationDetails: EvaluationDetails; + + // create a copy of the shared hook context because we're going to mutate the evaluation context + const hookContextCopy = { ...hookContext, context: { ...hookContext.context } }; + + try { + // return the modified provider context and mutate the hook context to contain it + providerContext = await this.hookExecutor.beforeHooks(provider.hooks, hookContextCopy, hookHints); + + const resolutionDetails = (await this.callProviderResolve( + provider, + flagKey, + defaultValue, + providerContext || {}, + )) as ResolutionDetails; + + evaluationDetails = { + ...resolutionDetails, + flagMetadata: Object.freeze(resolutionDetails.flagMetadata ?? {}), + flagKey, + }; + + await this.hookExecutor.afterHooks(provider.hooks, hookContextCopy, evaluationDetails, hookHints); + } catch (error: unknown) { + await this.hookExecutor.errorHooks(provider.hooks, hookContextCopy, error, hookHints); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, error); + } + await this.hookExecutor.finallyHooks(provider.hooks, hookContextCopy, evaluationDetails, hookHints); + return evaluationDetails; + } + + private async callProviderResolve( + provider: Provider, + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ) { + switch (typeof defaultValue) { + case 'string': + return await provider.resolveStringEvaluation(flagKey, defaultValue, context, this.logger); + case 'number': + return await provider.resolveNumberEvaluation(flagKey, defaultValue, context, this.logger); + case 'object': + return await provider.resolveObjectEvaluation(flagKey, defaultValue, context, this.logger); + case 'boolean': + return await provider.resolveBooleanEvaluation(flagKey, defaultValue, context, this.logger); + default: + throw new GeneralError('Invalid flag evaluation type'); + } + } + + public get hooks(): Hook[] { + return [ + { + before: async (hookContext: BeforeHookContext, hints: HookHints): Promise => { + this.hookContexts.set(hookContext.context, hookContext); + this.hookHints.set(hookContext.context, hints ?? {}); + return hookContext.context; + }, + }, + ]; + } + + track(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void { + for (const providerEntry of this.providerEntries) { + if (!providerEntry.provider.track) { + continue; + } + + const strategyContext = { + provider: providerEntry.provider, + providerName: providerEntry.name, + providerStatus: this.statusTracker.providerStatus(providerEntry.name), + }; + + if ( + this.evaluationStrategy.shouldTrackWithThisProvider( + strategyContext, + context, + trackingEventName, + trackingEventDetails, + ) + ) { + try { + providerEntry.provider.track?.(trackingEventName, context, trackingEventDetails); + } catch (error) { + // Log error but don't throw - tracking shouldn't break application flow + this.logger.error( + `Error tracking event "${trackingEventName}" with provider "${providerEntry.name}":`, + error, + ); + } + } + } + } + + private getErrorEvaluationDetails( + flagKey: string, + defaultValue: T, + err: unknown, + flagMetadata: FlagMetadata = {}, + ): EvaluationDetails { + const errorMessage: string = (err as Error)?.message; + const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL; + + return { + errorCode, + errorMessage, + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + flagMetadata: Object.freeze(flagMetadata), + flagKey, + }; + } +} diff --git a/packages/server/src/provider/multi-provider/status-tracker.ts b/packages/server/src/provider/multi-provider/status-tracker.ts new file mode 100644 index 000000000..b06bd054d --- /dev/null +++ b/packages/server/src/provider/multi-provider/status-tracker.ts @@ -0,0 +1,67 @@ +import type { EventDetails } from '@openfeature/core'; +import type { OpenFeatureEventEmitter } from '../../events'; +import { ProviderEvents } from '../../events'; +import { ProviderStatus } from '../provider'; +import type { RegisteredProvider } from './types'; + +/** + * Tracks each individual provider's status by listening to emitted events + * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers + */ +export class StatusTracker { + private readonly providerStatuses: Record = {}; + + constructor(private events: OpenFeatureEventEmitter) {} + + wrapEventHandler(providerEntry: RegisteredProvider) { + const provider = providerEntry.provider; + provider.events?.addHandler(ProviderEvents.Error, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.ERROR, details); + }); + + provider.events?.addHandler(ProviderEvents.Stale, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.STALE, details); + }); + + provider.events?.addHandler(ProviderEvents.ConfigurationChanged, (details?: EventDetails) => { + this.events.emit(ProviderEvents.ConfigurationChanged, details); + }); + + provider.events?.addHandler(ProviderEvents.Ready, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.READY, details); + }); + } + + providerStatus(name: string) { + return this.providerStatuses[name]; + } + + private getStatusFromProviderStatuses() { + const statuses = Object.values(this.providerStatuses); + if (statuses.includes(ProviderStatus.FATAL)) { + return ProviderStatus.FATAL; + } else if (statuses.includes(ProviderStatus.NOT_READY)) { + return ProviderStatus.NOT_READY; + } else if (statuses.includes(ProviderStatus.ERROR)) { + return ProviderStatus.ERROR; + } else if (statuses.includes(ProviderStatus.STALE)) { + return ProviderStatus.STALE; + } + return ProviderStatus.READY; + } + + private changeProviderStatus(name: string, status: ProviderStatus, details?: EventDetails) { + const currentStatus = this.getStatusFromProviderStatuses(); + this.providerStatuses[name] = status; + const newStatus = this.getStatusFromProviderStatuses(); + if (currentStatus !== newStatus) { + if (newStatus === ProviderStatus.FATAL || newStatus === ProviderStatus.ERROR) { + this.events.emit(ProviderEvents.Error, details); + } else if (newStatus === ProviderStatus.STALE) { + this.events.emit(ProviderEvents.Stale, details); + } else if (newStatus === ProviderStatus.READY) { + this.events.emit(ProviderEvents.Ready, details); + } + } + } +} diff --git a/packages/server/src/provider/multi-provider/strategies/base-evaluation-strategy.ts b/packages/server/src/provider/multi-provider/strategies/base-evaluation-strategy.ts new file mode 100644 index 000000000..cb74993cc --- /dev/null +++ b/packages/server/src/provider/multi-provider/strategies/base-evaluation-strategy.ts @@ -0,0 +1,130 @@ +import type { + ErrorCode, + EvaluationContext, + FlagValue, + FlagValueType, + OpenFeatureError, + ResolutionDetails, + TrackingEventDetails, +} from '@openfeature/core'; +import type { Provider } from '../../provider'; +import { ProviderStatus } from '../../provider'; +import { ErrorWithCode } from '../errors'; + +export type StrategyEvaluationContext = { + flagKey: string; + flagType: FlagValueType; +}; +export type StrategyProviderContext = { + provider: Provider; + providerName: string; + providerStatus: ProviderStatus; +}; +export type StrategyPerProviderContext = StrategyEvaluationContext & StrategyProviderContext; + +type ProviderResolutionResultBase = { + provider: Provider; + providerName: string; +}; + +export type ProviderResolutionSuccessResult = ProviderResolutionResultBase & { + details: ResolutionDetails; +}; + +export type ProviderResolutionErrorResult = ProviderResolutionResultBase & { + thrownError: unknown; +}; + +export type ProviderResolutionResult = + | ProviderResolutionSuccessResult + | ProviderResolutionErrorResult; + +export type FinalResult = { + details?: ResolutionDetails; + provider?: Provider; + providerName?: string; + errors?: { + providerName: string; + error: unknown; + }[]; +}; + +/** + * Base strategy to inherit from. Not directly usable, as strategies must implement the "determineResult" method + * Contains default implementations for `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` + */ +export abstract class BaseEvaluationStrategy { + public runMode: 'parallel' | 'sequential' = 'sequential'; + + shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, _evalContext?: EvaluationContext): boolean { + if ( + strategyContext.providerStatus === ProviderStatus.NOT_READY || + strategyContext.providerStatus === ProviderStatus.FATAL + ) { + return false; + } + return true; + } + + shouldEvaluateNextProvider( + _strategyContext?: StrategyPerProviderContext, + _context?: EvaluationContext, + _result?: ProviderResolutionResult, + ): boolean { + return true; + } + + shouldTrackWithThisProvider( + strategyContext: StrategyProviderContext, + _context?: EvaluationContext, + _trackingEventName?: string, + _trackingEventDetails?: TrackingEventDetails, + ): boolean { + if ( + strategyContext.providerStatus === ProviderStatus.NOT_READY || + strategyContext.providerStatus === ProviderStatus.FATAL + ) { + return false; + } + return true; + } + + abstract determineFinalResult( + strategyContext: StrategyEvaluationContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult; + + protected hasError(resolution: ProviderResolutionResult): resolution is + | ProviderResolutionErrorResult + | (ProviderResolutionSuccessResult & { + details: ResolutionDetails & { errorCode: ErrorCode }; + }) { + return 'thrownError' in resolution || !!resolution.details.errorCode; + } + + protected hasErrorWithCode(resolution: ProviderResolutionResult, code: ErrorCode): boolean { + return 'thrownError' in resolution + ? (resolution.thrownError as OpenFeatureError)?.code === code + : resolution.details.errorCode === code; + } + + protected collectProviderErrors(resolutions: ProviderResolutionResult[]): FinalResult { + const errors: FinalResult['errors'] = []; + for (const resolution of resolutions) { + if ('thrownError' in resolution) { + errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); + } else if (resolution.details.errorCode) { + errors.push({ + providerName: resolution.providerName, + error: new ErrorWithCode(resolution.details.errorCode, resolution.details.errorMessage ?? 'unknown error'), + }); + } + } + return { errors }; + } + + protected resolutionToFinalResult(resolution: ProviderResolutionSuccessResult) { + return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; + } +} diff --git a/packages/server/src/provider/multi-provider/strategies/comparison-strategy.ts b/packages/server/src/provider/multi-provider/strategies/comparison-strategy.ts new file mode 100644 index 000000000..491c4cf3e --- /dev/null +++ b/packages/server/src/provider/multi-provider/strategies/comparison-strategy.ts @@ -0,0 +1,72 @@ +import type { + FinalResult, + ProviderResolutionResult, + ProviderResolutionSuccessResult, + StrategyPerProviderContext, +} from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '@openfeature/core'; +import type { Provider } from '../../provider'; +import { GeneralError } from '@openfeature/core'; + +/** + * Evaluate all providers in parallel and compare the results. + * If the values agree, return the value + * If the values disagree, return the value from the configured "fallback provider" and execute the "onMismatch" + * callback if defined + */ +export class ComparisonStrategy extends BaseEvaluationStrategy { + override runMode = 'parallel' as const; + + constructor( + private fallbackProvider: Provider, + private onMismatch?: (resolutions: ProviderResolutionResult[]) => void, + ) { + super(); + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + let value: T | undefined; + let fallbackResolution: ProviderResolutionSuccessResult | undefined; + let finalResolution: ProviderResolutionSuccessResult | undefined; + let mismatch = false; + for (const [i, resolution] of resolutions.entries()) { + if (this.hasError(resolution)) { + return this.collectProviderErrors(resolutions); + } + if (resolution.provider === this.fallbackProvider) { + fallbackResolution = resolution; + } + if (i === 0) { + finalResolution = resolution; + } + if (typeof value !== 'undefined' && value !== resolution.details.value) { + mismatch = true; + } else { + value = resolution.details.value; + } + } + + if (!fallbackResolution) { + throw new GeneralError('Fallback provider not found in resolution results'); + } + + if (!finalResolution) { + throw new GeneralError('Final resolution not found in resolution results'); + } + + if (mismatch) { + this.onMismatch?.(resolutions); + return { + details: fallbackResolution.details, + provider: fallbackResolution.provider, + }; + } + + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/packages/server/src/provider/multi-provider/strategies/first-match-strategy.ts b/packages/server/src/provider/multi-provider/strategies/first-match-strategy.ts new file mode 100644 index 000000000..375378d58 --- /dev/null +++ b/packages/server/src/provider/multi-provider/strategies/first-match-strategy.ts @@ -0,0 +1,36 @@ +import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext } from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '@openfeature/core'; +import { ErrorCode } from '@openfeature/core'; + +/** + * Return the first result that did not indicate "flag not found". + * If any provider in the course of evaluation returns or throws an error, throw that error + */ +export class FirstMatchStrategy extends BaseEvaluationStrategy { + override shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean { + if (this.hasErrorWithCode(result, ErrorCode.FLAG_NOT_FOUND)) { + return true; + } + if (this.hasError(result)) { + return false; + } + return false; + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/packages/server/src/provider/multi-provider/strategies/first-successful-strategy.ts b/packages/server/src/provider/multi-provider/strategies/first-successful-strategy.ts new file mode 100644 index 000000000..779eb69d3 --- /dev/null +++ b/packages/server/src/provider/multi-provider/strategies/first-successful-strategy.ts @@ -0,0 +1,31 @@ +import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext } from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '@openfeature/core'; + +/** + * Return the first result that did NOT result in an error + * If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result + * If there is no successful result, throw all errors + */ +export class FirstSuccessfulStrategy extends BaseEvaluationStrategy { + override shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean { + // evaluate next only if there was an error + return this.hasError(result); + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/packages/server/src/provider/multi-provider/strategies/index.ts b/packages/server/src/provider/multi-provider/strategies/index.ts new file mode 100644 index 000000000..c611ac485 --- /dev/null +++ b/packages/server/src/provider/multi-provider/strategies/index.ts @@ -0,0 +1,4 @@ +export * from './base-evaluation-strategy'; +export * from './first-match-strategy'; +export * from './first-successful-strategy'; +export * from './comparison-strategy'; diff --git a/packages/server/src/provider/multi-provider/types.ts b/packages/server/src/provider/multi-provider/types.ts new file mode 100644 index 000000000..1e2747bb3 --- /dev/null +++ b/packages/server/src/provider/multi-provider/types.ts @@ -0,0 +1,10 @@ +// Represents an entry in the constructor's provider array which may or may not have a name set +import type { Provider } from '../provider'; + +export type ProviderEntryInput = { + provider: Provider; + name?: string; +}; + +// Represents a processed and "registered" provider entry where a name has been chosen +export type RegisteredProvider = Required; diff --git a/packages/server/test/multi-provider.spec.ts b/packages/server/test/multi-provider.spec.ts new file mode 100644 index 000000000..b63d8203b --- /dev/null +++ b/packages/server/test/multi-provider.spec.ts @@ -0,0 +1,937 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { MultiProvider } from '../src/provider/multi-provider/multi-provider'; +import type { + EvaluationContext, + FlagValue, + FlagValueType, + Hook, + Logger, + Provider, + ProviderMetadata, + TrackingEventDetails, +} from '@openfeature/server-sdk'; +import { + DefaultLogger, + MapHookData, + ErrorCode, + FlagNotFoundError, + InMemoryProvider, + OpenFeatureEventEmitter, + ServerProviderEvents, +} from '@openfeature/server-sdk'; +import { FirstMatchStrategy } from '../src/provider/multi-provider/strategies'; +import { FirstSuccessfulStrategy } from '../src/provider/multi-provider/strategies'; +import { ComparisonStrategy } from '../src/provider/multi-provider/strategies'; + +class TestProvider implements Provider { + public metadata: ProviderMetadata = { + name: 'TestProvider', + }; + public events = new OpenFeatureEventEmitter(); + public hooks: Hook[] = []; + public track = jest.fn(); + + constructor( + public resolveBooleanEvaluation = jest.fn().mockResolvedValue({ value: false }), + public resolveStringEvaluation = jest.fn().mockResolvedValue({ value: 'default' }), + public resolveObjectEvaluation = jest.fn().mockResolvedValue({ value: {} }), + public resolveNumberEvaluation = jest.fn().mockResolvedValue({ value: 0 }), + public initialize = jest.fn(), + ) {} + + emitEvent(type: ServerProviderEvents) { + this.events.emit(type, { providerName: this.metadata.name }); + } +} + +const callEvaluation = async (multi: MultiProvider, context: EvaluationContext, logger: Logger) => { + await callBeforeHook(multi, context, 'flag', 'boolean', false, logger); + return multi.resolveBooleanEvaluation('flag', false, context); +}; + +const callBeforeHook = async ( + multi: MultiProvider, + context: EvaluationContext, + flagKey: string, + flagType: FlagValueType, + defaultValue: FlagValue, + logger: Logger = new DefaultLogger(), +) => { + const hookContext = { + context: context, + flagKey, + flagValueType: flagType, + defaultValue, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + hookData: new MapHookData(), + }; + await multi.hooks[0].before?.(hookContext); +}; + +describe('MultiProvider', () => { + const logger = new DefaultLogger(); + + describe('unique names', () => { + it('uses provider names for unique types', () => { + const multiProvider = new MultiProvider([ + { + provider: new InMemoryProvider(), + }, + { + provider: new TestProvider(), + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('in-memory'); + expect(multiProvider.providerEntries[1].name).toEqual('TestProvider'); + expect(multiProvider.providerEntries.length).toBe(2); + }); + it('generates unique names for identical provider types', () => { + const multiProvider = new MultiProvider([ + { + provider: new TestProvider(), + }, + { + provider: new TestProvider(), + }, + { + provider: new TestProvider(), + }, + { + provider: new InMemoryProvider(), + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('TestProvider-1'); + expect(multiProvider.providerEntries[1].name).toEqual('TestProvider-2'); + expect(multiProvider.providerEntries[2].name).toEqual('TestProvider-3'); + expect(multiProvider.providerEntries[3].name).toEqual('in-memory'); + expect(multiProvider.providerEntries.length).toBe(4); + }); + it('uses specified names for identical provider types', () => { + const multiProvider = new MultiProvider([ + { + provider: new TestProvider(), + name: 'provider1', + }, + { + provider: new TestProvider(), + name: 'provider2', + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('provider1'); + expect(multiProvider.providerEntries[1].name).toEqual('provider2'); + expect(multiProvider.providerEntries.length).toBe(2); + }); + it('throws an error if specified names are not unique', () => { + expect( + () => + new MultiProvider([ + { + provider: new TestProvider(), + name: 'provider', + }, + { + provider: new InMemoryProvider(), + name: 'provider', + }, + ]), + ).toThrow(); + }); + }); + + describe('event tracking and statuses', () => { + it('initializes by waiting for all initializations', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let initializations = 0; + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + provider1.initialize.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + initializations++; + }); + provider2.initialize.mockImplementation(() => initializations++); + await multiProvider.initialize(); + expect(initializations).toBe(2); + }); + + it('throws error if a provider errors on initialization', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let initializations = 0; + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + provider1.initialize.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + throw new Error('Failure!'); + }); + provider2.initialize.mockImplementation(() => initializations++); + await expect(() => multiProvider.initialize()).rejects.toThrow('Failure!'); + }); + + it('emits events when aggregate status changes', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + + let readyEmitted = 0; + let errorEmitted = 0; + let staleEmitted = 0; + multiProvider.events.addHandler(ServerProviderEvents.Ready, () => { + readyEmitted++; + }); + + multiProvider.events.addHandler(ServerProviderEvents.Error, () => { + errorEmitted++; + }); + + multiProvider.events.addHandler(ServerProviderEvents.Stale, () => { + staleEmitted++; + }); + + await multiProvider.initialize(); + + provider1.initialize.mockResolvedValue(true); + provider2.initialize.mockResolvedValue(true); + provider1.emitEvent(ServerProviderEvents.Error); + expect(errorEmitted).toBe(1); + provider2.emitEvent(ServerProviderEvents.Error); + // don't emit error again unless aggregate status is changing + expect(errorEmitted).toBe(1); + provider1.emitEvent(ServerProviderEvents.Error); + // don't emit error again unless aggregate status is changing + expect(errorEmitted).toBe(1); + provider2.emitEvent(ServerProviderEvents.Stale); + provider1.emitEvent(ServerProviderEvents.Ready); + // error status provider is ready now but other provider is stale + expect(readyEmitted).toBe(0); + expect(staleEmitted).toBe(1); + provider2.emitEvent(ServerProviderEvents.Ready); + // now both providers are ready + expect(readyEmitted).toBe(1); + }); + }); + + describe('metadata', () => { + it('contains metadata for all providers', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + expect(multiProvider.metadata).toEqual({ + name: 'MultiProvider', + 'TestProvider-1': provider1.metadata, + 'TestProvider-2': provider2.metadata, + }); + }); + }); + + describe('evaluation', () => { + describe('hooks', () => { + it('runs before hooks to modify context for a specific provider and evaluates using that modified context', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let hook1Called = false; + let hook2Called = false; + let after1Called = false; + let after2Called = false; + const context = { + test: true, + }; + const hookContext = { + context: context, + flagKey: 'flag', + flagValueType: 'boolean' as any, + defaultValue: false, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + hookData: new MapHookData(), + }; + + const weakMap = new WeakMap(); + + provider1.hooks = [ + { + before: async (context) => { + hook1Called = true; + expect(context).toEqual(hookContext); + weakMap.set(context, 'test'); + return { ...context.context, hook1: true }; + }, + after: async (context) => { + expect(context.context).toEqual({ + test: true, + hook1: true, + hook2: true, + }); + expect(weakMap.get(context)).toEqual('test'); + after1Called = true; + }, + }, + { + before: async (context) => { + hook2Called = true; + expect(weakMap.get(context)).toEqual('test'); + expect(context.context).toEqual({ + test: true, + hook1: true, + }); + return { ...context.context, hook2: true }; + }, + }, + ]; + + provider2.hooks = [ + { + after: async (context) => { + expect(weakMap.get(context)).toBeFalsy(); + expect(context.context).toEqual({ + test: true, + }); + after2Called = true; + }, + }, + ]; + + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new ComparisonStrategy(provider1), + ); + + await multiProvider.hooks[0].before!(hookContext); + await multiProvider.resolveBooleanEvaluation('flag', false, context); + expect(hook1Called).toBe(true); + expect(hook2Called).toBe(true); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'flag', + false, + { + test: true, + hook1: true, + hook2: true, + }, + expect.any(Object), + ); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'flag', + false, + { test: true }, + expect.any(Object), + ); + expect(after1Called).toBe(true); + expect(after2Called).toBe(true); + }); + + it('runs error hook and finally hook with modified context using same object reference', async () => { + const provider1 = new TestProvider(); + let hook1Called = false; + let error1Called = false; + let finally1Called = false; + + const context = { + test: true, + }; + + const hookContext = { + context: context, + flagKey: 'flag', + flagValueType: 'boolean' as any, + defaultValue: false, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + hookData: new MapHookData(), + }; + + const weakMap = new WeakMap(); + + provider1.hooks = [ + { + before: async (context) => { + hook1Called = true; + weakMap.set(context, 'exists'); + expect(context).toEqual(hookContext); + return { ...context.context, hook1: true }; + }, + error: async (context) => { + expect(context.context).toEqual({ + test: true, + hook1: true, + }); + expect(weakMap.get(context)).toEqual('exists'); + error1Called = true; + throw new Error('error hook error'); + }, + finally: async (context) => { + expect(context.context).toEqual({ + test: true, + hook1: true, + }); + expect(weakMap.get(context)).toEqual('exists'); + finally1Called = true; + }, + }, + ]; + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + + provider1.resolveBooleanEvaluation.mockRejectedValue(new Error('test error')); + + // call the multiprovider before hook to set up the hookcontext + await multiProvider.hooks[0].before!(hookContext); + await expect(() => multiProvider.resolveBooleanEvaluation('flag', false, context)).rejects.toThrow(); + expect(hook1Called).toBe(true); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'flag', + false, + { + test: true, + hook1: true, + }, + expect.any(Object), + ); + expect(error1Called).toBe(true); + expect(finally1Called).toBe(true); + }); + }); + + describe('resolution logic and strategies', () => { + describe('evaluation data types', () => { + it('evaluates a string variable', async () => { + const provider1 = new TestProvider(); + provider1.resolveStringEvaluation.mockResolvedValue({ value: 'value' }); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + await callBeforeHook(multiProvider, context, 'flag', 'string', 'default'); + expect(await multiProvider.resolveStringEvaluation('flag', 'default', context)).toEqual({ + value: 'value', + flagKey: 'flag', + flagMetadata: {}, + }); + }); + + it('evaluates a number variable', async () => { + const provider1 = new TestProvider(); + provider1.resolveNumberEvaluation.mockResolvedValue({ value: 1 }); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + + await callBeforeHook(multiProvider, context, 'flag', 'number', 0); + + expect(await multiProvider.resolveNumberEvaluation('flag', 0, context)).toEqual({ + value: 1, + flagKey: 'flag', + flagMetadata: {}, + }); + }); + + it('evaluates a boolean variable', async () => { + const provider1 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockResolvedValue({ value: true }); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + await callBeforeHook(multiProvider, context, 'flag', 'boolean', false); + expect(await multiProvider.resolveBooleanEvaluation('flag', false, context)).toEqual({ + value: true, + flagKey: 'flag', + flagMetadata: {}, + }); + }); + + it('evaluates an object variable', async () => { + const provider1 = new TestProvider(); + provider1.resolveObjectEvaluation.mockResolvedValue({ value: { test: true } }); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + await callBeforeHook(multiProvider, context, 'flag', 'object', {}); + expect(await multiProvider.resolveObjectEvaluation('flag', {}, context)).toEqual({ + flagKey: 'flag', + flagMetadata: {}, + value: { test: true }, + }); + }); + }); + describe('first match strategy', () => { + it('throws an error if any provider throws an error during evaluation', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockRejectedValue(new Error('test error')); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new FirstMatchStrategy(), + ); + + await expect(() => callEvaluation(multiProvider, {}, logger)).rejects.toThrow('test error'); + expect(provider2.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('throws an error if any provider returns an error result during evaluation', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockResolvedValue({ + errorCode: 'test-error', + errorMessage: 'test error', + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new FirstMatchStrategy(), + ); + + await expect(() => callEvaluation(multiProvider, {}, logger)).rejects.toThrow('test error'); + expect(provider2.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('skips providers that return flag not found until it gets a result, skipping any provider after', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockResolvedValue({ + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: 'flag not found', + }); + provider2.resolveBooleanEvaluation.mockResolvedValue({ + value: true, + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstMatchStrategy(), + ); + const result = await callEvaluation(multiProvider, {}, logger); + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('skips providers that throw flag not found until it gets a result, skipping any provider after', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockRejectedValue(new FlagNotFoundError('flag not found')); + provider2.resolveBooleanEvaluation.mockResolvedValue({ + value: true, + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstMatchStrategy(), + ); + const result = await callEvaluation(multiProvider, {}, logger); + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + }); + + describe('first successful strategy', () => { + it('ignores errors from earlier providers and returns successful result from later provider', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockResolvedValue({ + errorCode: 'some error', + errorMessage: 'flag not found', + }); + provider2.resolveBooleanEvaluation.mockResolvedValue({ + value: true, + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstSuccessfulStrategy(), + ); + const result = await callEvaluation(multiProvider, {}, logger); + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + }); + + describe('comparison strategy', () => { + it('calls every provider in parallel and returns a result if they all agree', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue( + new Promise((resolve) => { + setTimeout(() => resolve({ value: true }), 2); + }), + ); + + provider2.resolveBooleanEvaluation.mockResolvedValue({ + value: true, + }); + provider3.resolveBooleanEvaluation.mockResolvedValue({ + value: true, + }); + + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1), + ); + const resultPromise = callEvaluation(multiProvider, {}, logger); + await new Promise((resolve) => process.nextTick(resolve)); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + + expect(await resultPromise).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + }); + + it('calls every provider and returns the fallback value if any disagree, and calls onMismatch', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockResolvedValue({ + value: true, + }); + provider2.resolveBooleanEvaluation.mockResolvedValue({ + value: false, + }); + provider3.resolveBooleanEvaluation.mockResolvedValue({ + value: false, + }); + + const onMismatch = jest.fn(); + + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1, onMismatch), + ); + const result = await callEvaluation(multiProvider, {}, logger); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(onMismatch).toHaveBeenCalledWith([ + { + provider: provider1, + providerName: 'TestProvider-1', + details: { value: true, flagKey: 'flag', flagMetadata: {} }, + }, + { + provider: provider2, + providerName: 'TestProvider-2', + details: { value: false, flagKey: 'flag', flagMetadata: {} }, + }, + { + provider: provider3, + providerName: 'TestProvider-3', + details: { value: false, flagKey: 'flag', flagMetadata: {} }, + }, + ]); + + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + }); + + it('returns an error if any provider returns an error', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockRejectedValue(new Error('test error')); + provider2.resolveBooleanEvaluation.mockResolvedValue({ + value: false, + }); + provider3.resolveBooleanEvaluation.mockResolvedValue({ + value: false, + }); + + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1), + ); + await expect(callEvaluation(multiProvider, {}, logger)).rejects.toThrow('test error'); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('tracking', () => { + const context: EvaluationContext = { targetingKey: 'user123' }; + const trackingEventDetails: TrackingEventDetails = { value: 100, currency: 'USD' }; + + it('calls track on all providers by default', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + + const multiProvider = new MultiProvider([ + { provider: provider1 }, + { provider: provider2 }, + { provider: provider3 }, + ]); + + multiProvider.track('purchase', context, trackingEventDetails); + + expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + }); + + it('skips providers without track method', () => { + const provider1 = new TestProvider(); + const provider2 = new InMemoryProvider(); // Doesn't have track method + const provider3 = new TestProvider(); + + const multiProvider = new MultiProvider([ + { provider: provider1 }, + { provider: provider2 }, + { provider: provider3 }, + ]); + + expect(() => multiProvider.track('purchase', context, trackingEventDetails)).not.toThrow(); + expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + }); + + it('continues tracking with other providers when one throws an error', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + + provider2.track.mockImplementation(() => { + throw new Error('Tracking failed'); + }); + + const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }; + const multiProvider = new MultiProvider( + [{ provider: provider1 }, { provider: provider2 }, { provider: provider3 }], + undefined, + mockLogger, + ); + + expect(() => multiProvider.track('purchase', context, trackingEventDetails)).not.toThrow(); + + expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Error tracking event "purchase" with provider "TestProvider-2":', + expect.any(Error), + ); + }); + + it('respects strategy shouldTrackWithThisProvider decision', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + + const mockStrategy = new FirstMatchStrategy(); + mockStrategy.shouldTrackWithThisProvider = jest + .fn() + .mockReturnValueOnce(true) // provider1: should track + .mockReturnValueOnce(false) // provider2: should not track + .mockReturnValueOnce(true); // provider3: should track + + const multiProvider = new MultiProvider( + [{ provider: provider1 }, { provider: provider2 }, { provider: provider3 }], + mockStrategy, + ); + + multiProvider.track('purchase', context, trackingEventDetails); + + expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider2.track).not.toHaveBeenCalled(); + expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + + expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledTimes(3); + expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider: provider1, + providerName: 'TestProvider-1', + }), + context, + 'purchase', + trackingEventDetails, + ); + }); + + it('does not track with providers in NOT_READY or FATAL status by default', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + + const multiProvider = new MultiProvider([ + { provider: provider1 }, + { provider: provider2 }, + { provider: provider3 }, + ]); + + // Simulate providers with different statuses + const mockStatusTracker = { + providerStatus: jest + .fn() + .mockReturnValueOnce('READY') // provider1: ready + .mockReturnValueOnce('NOT_READY') // provider2: not ready + .mockReturnValueOnce('FATAL'), // provider3: fatal + }; + (multiProvider as any).statusTracker = mockStatusTracker; + + multiProvider.track('purchase', context, trackingEventDetails); + + expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider2.track).not.toHaveBeenCalled(); + expect(provider3.track).not.toHaveBeenCalled(); + }); + + it('passes correct strategy context to shouldTrackWithThisProvider', () => { + const provider1 = new TestProvider(); + + const mockStrategy = new FirstMatchStrategy(); + mockStrategy.shouldTrackWithThisProvider = jest.fn().mockReturnValue(true); + + const multiProvider = new MultiProvider([{ provider: provider1, name: 'custom-name' }], mockStrategy); + + // Mock the status tracker to return a proper status + const mockStatusTracker = { + providerStatus: jest.fn().mockReturnValue('READY'), + }; + (multiProvider as any).statusTracker = mockStatusTracker; + + multiProvider.track('purchase', context, trackingEventDetails); + + expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider: provider1, + providerName: 'custom-name', + providerStatus: 'READY', + }), + context, + 'purchase', + trackingEventDetails, + ); + }); + }); +}); diff --git a/packages/web/README.md b/packages/web/README.md index a00097b3f..cd219252f 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -145,6 +145,63 @@ Once the provider has been registered, the status can be tracked using [events]( In some situations, it may be beneficial to register multiple providers in the same application. This is possible using [domains](#domains), which is covered in more detail below. +#### Multi-Provider + +The Multi-Provider allows you to use multiple underlying providers as sources of flag data for the OpenFeature web SDK. When a flag is being evaluated, the Multi-Provider will consult each underlying provider it is managing in order to determine the final result. Different evaluation strategies can be defined to control which providers get evaluated and which result is used. + +The Multi-Provider is a powerful tool for performing migrations between flag providers, or combining multiple providers into a single feature flagging interface. For example: + +- **Migration**: When migrating between two providers, you can run both in parallel under a unified flagging interface. As flags are added to the new provider, the Multi-Provider will automatically find and return them, falling back to the old provider if the new provider does not have the flag. +- **Multiple Data Sources**: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables, local files, database values and SaaS hosted feature management systems. + +```ts +import { WebMultiProvider } from '@openfeature/web-sdk'; + +const multiProvider = new WebMultiProvider([ + { provider: new ProviderA() }, + { provider: new ProviderB() } +]); + +await OpenFeature.setProviderAndWait(multiProvider); + +const client = OpenFeature.getClient(); +console.log(client.getBooleanDetails("my-flag", false)); +``` + +By default, the Multi-Provider will evaluate all underlying providers in order and return the first successful result. If a provider indicates it does not have a flag (FLAG_NOT_FOUND error code), then it will be skipped and the next provider will be evaluated. + +##### Evaluation Strategies + +The Multi-Provider comes with three strategies out of the box: + +- **FirstMatchStrategy** (default): Evaluates all providers in order and returns the first successful result. Providers that indicate FLAG_NOT_FOUND error will be skipped and the next provider will be evaluated. +- **FirstSuccessfulStrategy**: Evaluates all providers in order and returns the first successful result. Any error will cause that provider to be skipped. +- **ComparisonStrategy**: Evaluates all providers sequentially. If every provider returns a successful result with the same value, then that result is returned. Otherwise, the result returned by the configured "fallback provider" will be used. + +```ts +import { WebMultiProvider, FirstSuccessfulStrategy } from '@openfeature/web-sdk'; + +const multiProvider = new WebMultiProvider( + [ + { provider: new ProviderA() }, + { provider: new ProviderB() } + ], + new FirstSuccessfulStrategy() +); +``` + +##### Tracking Support + +The Multi-Provider supports tracking events across multiple providers, allowing you to send analytics events to all configured providers simultaneously: + +```ts +// Tracked events will be sent to all providers by default +client.track('user-conversion', { + value: 99.99, + currency: 'USD' +}); +``` + ### Flag evaluation flow When a new provider is added to OpenFeature client the following process happens: diff --git a/packages/web/src/provider/index.ts b/packages/web/src/provider/index.ts index 33e12fea3..ca2771629 100644 --- a/packages/web/src/provider/index.ts +++ b/packages/web/src/provider/index.ts @@ -1,3 +1,4 @@ export * from './provider'; export * from './no-op-provider'; export * from './in-memory-provider'; +export * from './multi-provider'; diff --git a/packages/web/src/provider/multi-provider/README.md b/packages/web/src/provider/multi-provider/README.md new file mode 100644 index 000000000..fdbb56914 --- /dev/null +++ b/packages/web/src/provider/multi-provider/README.md @@ -0,0 +1,199 @@ +# OpenFeature Multi-Provider + +The Multi-Provider allows you to use multiple underlying providers as sources of flag data for the OpenFeature web SDK. +When a flag is being evaluated, the Multi-Provider will consult each underlying provider it is managing in order to determine +the final result. Different evaluation strategies can be defined to control which providers get evaluated and which result is used. + +The Multi-Provider is a powerful tool for performing migrations between flag providers, or combining multiple providers into a single +feature flagging interface. For example: + +- *Migration*: When migrating between two providers, you can run both in parallel under a unified flagging interface. As flags are added to the +new provider, the Multi-Provider will automatically find and return them, falling back to the old provider if the new provider does not have +- *Multiple Data Sources*: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables, +local files, database values and SaaS hosted feature management systems. + +## Usage + +The Multi-Provider is initialized with an array of providers it should evaluate: + +```typescript +import { WebMultiProvider } from '@openfeature/web-sdk' +import { OpenFeature } from '@openfeature/web-sdk' + +const multiProvider = new WebMultiProvider([ + { provider: new ProviderA() }, + { provider: new ProviderB() } +]) + +await OpenFeature.setProviderAndWait(multiProvider) + +const client = OpenFeature.getClient() + +console.log("Evaluating flag") +console.log(client.getBooleanDetails("my-flag", false)); +``` + +By default, the Multi-Provider will evaluate all underlying providers in order and return the first successful result. If a provider indicates +it does not have a flag (FLAG_NOT_FOUND error code), then it will be skipped and the next provider will be evaluated. If any provider throws +or returns an error result, the operation will fail and the error will be thrown. If no provider returns a successful result, the operation +will fail with a FLAG_NOT_FOUND error code. + +To change this behaviour, a different "strategy" can be provided: + +```typescript +import { WebMultiProvider, FirstSuccessfulStrategy } from '@openfeature/web-sdk' + +const multiProvider = new WebMultiProvider( + [ + { provider: new ProviderA() }, + { provider: new ProviderB() } + ], + new FirstSuccessfulStrategy() +) +``` + +## Strategies + +The Multi-Provider comes with three strategies out of the box: + +- `FirstMatchStrategy` (default): Evaluates all providers in order and returns the first successful result. Providers that indicate FLAG_NOT_FOUND error will be skipped and the next provider will be evaluated. Any other error will cause the operation to fail and the set of errors to be thrown. +- `FirstSuccessfulStrategy`: Evaluates all providers in order and returns the first successful result. Any error will cause that provider to be skipped. +If no successful result is returned, the set of errors will be thrown. +- `ComparisonStrategy`: Evaluates all providers sequentially. If every provider returns a successful result with the same value, then that result is returned. +Otherwise, the result returned by the configured "fallback provider" will be used. When values do not agree, an optional callback will be executed to notify +you of the mismatch. This can be useful when migrating between providers that are expected to contain identical configuration. You can easily spot mismatches +in configuration without affecting flag behaviour. + +This strategy accepts several arguments during initialization: + +```typescript +import { WebMultiProvider, ComparisonStrategy } from '@openfeature/web-sdk' + +const providerA = new ProviderA() +const multiProvider = new WebMultiProvider( + [ + { provider: providerA }, + { provider: new ProviderB() } + ], + new ComparisonStrategy(providerA, (details) => { + console.log("Mismatch detected", details) + }) +) +``` + +The first argument is the "fallback provider" whose value to use in the event that providers do not agree. It should be the same object reference as one of the providers in the list. The second argument is a callback function that will be executed when a mismatch is detected. The callback will be passed an object containing the details of each provider's resolution, including the flag key, the value returned, and any errors that were thrown. + +## Custom Strategies + +It is also possible to implement your own strategy if the above options do not fit your use case. To do so, create a class which implements the "BaseEvaluationStrategy": + +```typescript +export abstract class BaseEvaluationStrategy { + public runMode: 'parallel' | 'sequential' = 'sequential'; + + abstract shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, evalContext: EvaluationContext): boolean; + + abstract shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean; + + abstract shouldTrackWithThisProvider( + strategyContext: StrategyProviderContext, + context: EvaluationContext, + trackingEventName: string, + trackingEventDetails: TrackingEventDetails, + ): boolean; + + abstract determineFinalResult( + strategyContext: StrategyEvaluationContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult; +} +``` + +The `runMode` property determines whether the list of providers will be evaluated sequentially or in parallel. + +The `shouldEvaluateThisProvider` method is called just before a provider is evaluated by the Multi-Provider. If the function returns `false`, then +the provider will be skipped instead of being evaluated. The function is called with details about the evaluation including the flag key and type. +Check the type definitions for the full list. + +The `shouldEvaluateNextProvider` function is called after a provider is evaluated. If it returns `true`, the next provider in the sequence will be called, +otherwise no more providers will be evaluated. It is called with the same data as `shouldEvaluateThisProvider` as well as the details about the evaluation result. This function is not called when the `runMode` is `parallel`. + +The `shouldTrackWithThisProvider` method is called before tracking an event with each provider. If the function returns `false`, then +the provider will be skipped for that tracking event. The method includes the tracking event name and details, +allowing for fine-grained control over which providers receive which events. By default, providers in `NOT_READY` or `FATAL` status are skipped. + +The `determineFinalResult` function is called after all providers have been called, or the `shouldEvaluateNextProvider` function returned false. It is called +with a list of results from all the individual providers' evaluations. It returns the final decision for evaluation result, or throws an error if needed. + +## Tracking Support + +The Multi-Provider supports tracking events across multiple providers, allowing you to send analytics events to all configured providers simultaneously. + +### Basic Tracking Usage + +```typescript +import { WebMultiProvider } from '@openfeature/web-sdk' +import { OpenFeature } from '@openfeature/web-sdk' + +const multiProvider = new WebMultiProvider([ + { provider: new ProviderA() }, + { provider: new ProviderB() } +]) + +await OpenFeature.setProviderAndWait(multiProvider) +const client = OpenFeature.getClient() + +// Tracked events will be sent to all providers by default +client.track('user-conversion', { + value: 99.99, + currency: 'USD', + conversionType: 'purchase' +}) + +client.track('page-view', { + page: '/checkout', + source: 'direct' +}) +``` + +### Tracking Behavior + +- **Default**: All providers receive tracking calls by default +- **Error Handling**: If one provider fails to track, others continue normally and errors are logged +- **Provider Status**: Providers in `NOT_READY` or `FATAL` status are automatically skipped +- **Optional Method**: Providers without a `track` method are gracefully skipped + +### Customizing Tracking with Strategies + +You can customize which providers receive tracking calls by overriding the `shouldTrackWithThisProvider` method in your custom strategy: + +```typescript +import { BaseEvaluationStrategy, StrategyProviderContext } from '@openfeature/web-sdk' + +class CustomTrackingStrategy extends BaseEvaluationStrategy { + // Override tracking behavior + shouldTrackWithThisProvider( + strategyContext: StrategyProviderContext, + context: EvaluationContext, + trackingEventName: string, + trackingEventDetails: TrackingEventDetails, + ): boolean { + // Only track with the primary provider + if (strategyContext.providerName === 'primary-provider') { + return true; + } + + // Skip tracking for analytics events on backup providers + if (trackingEventName.startsWith('analytics.')) { + return false; + } + + return super.shouldTrackWithThisProvider(strategyContext, context, trackingEventName, trackingEventDetails); + } +} +``` diff --git a/packages/web/src/provider/multi-provider/errors.ts b/packages/web/src/provider/multi-provider/errors.ts new file mode 100644 index 000000000..f1f14438d --- /dev/null +++ b/packages/web/src/provider/multi-provider/errors.ts @@ -0,0 +1,53 @@ +import type { ErrorCode } from '@openfeature/core'; +import { GeneralError, OpenFeatureError } from '@openfeature/core'; +import type { RegisteredProvider } from './types'; + +export class ErrorWithCode extends OpenFeatureError { + constructor( + public code: ErrorCode, + message: string, + ) { + super(message); + } +} + +export class AggregateError extends GeneralError { + constructor( + message: string, + public originalErrors: { source: string; error: unknown }[], + ) { + super(message); + } +} + +export const constructAggregateError = (providerErrors: { error: unknown; providerName: string }[]) => { + const errorsWithSource = providerErrors + .map(({ providerName, error }) => { + return { source: providerName, error }; + }) + .flat(); + + // log first error in the message for convenience, but include all errors in the error object for completeness + return new AggregateError( + `Provider errors occurred: ${errorsWithSource[0].source}: ${errorsWithSource[0].error}`, + errorsWithSource, + ); +}; + +export const throwAggregateErrorFromPromiseResults = ( + result: PromiseSettledResult[], + providerEntries: RegisteredProvider[], +) => { + const errors = result + .map((r, i) => { + if (r.status === 'rejected') { + return { error: r.reason, providerName: providerEntries[i].name }; + } + return null; + }) + .filter((val): val is { error: unknown; providerName: string } => Boolean(val)); + + if (errors.length) { + throw constructAggregateError(errors); + } +}; diff --git a/packages/web/src/provider/multi-provider/hook-executor.ts b/packages/web/src/provider/multi-provider/hook-executor.ts new file mode 100644 index 000000000..89749287e --- /dev/null +++ b/packages/web/src/provider/multi-provider/hook-executor.ts @@ -0,0 +1,62 @@ +import type { EvaluationDetails, FlagValue, HookContext, HookHints, Logger } from '@openfeature/core'; +import type { Hook } from '../../hooks'; + +/** + * Utility for executing a set of hooks of each type. Implementation is largely copied from the main OpenFeature SDK. + */ +export class HookExecutor { + constructor(private logger: Logger) {} + + beforeHooks(hooks: Hook[] | undefined, hookContext: HookContext, hints: HookHints) { + for (const hook of hooks ?? []) { + hook?.before?.(hookContext, Object.freeze(hints)); + } + } + + afterHooks( + hooks: Hook[] | undefined, + hookContext: HookContext, + evaluationDetails: EvaluationDetails, + hints: HookHints, + ) { + // run "after" hooks sequentially + for (const hook of hooks ?? []) { + hook?.after?.(hookContext, evaluationDetails, hints); + } + } + + errorHooks(hooks: Hook[] | undefined, hookContext: HookContext, err: unknown, hints: HookHints) { + // run "error" hooks sequentially + for (const hook of hooks ?? []) { + try { + hook?.error?.(hookContext, err, hints); + } catch (err) { + this.logger.error(`Unhandled error during 'error' hook: ${err}`); + if (err instanceof Error) { + this.logger.error(err.stack); + } + this.logger.error((err as Error)?.stack); + } + } + } + + finallyHooks( + hooks: Hook[] | undefined, + hookContext: HookContext, + evaluationDetails: EvaluationDetails, + hints: HookHints, + ) { + // run "finally" hooks sequentially + for (const hook of hooks ?? []) { + try { + hook?.finally?.(hookContext, evaluationDetails, hints); + } catch (err) { + this.logger.error(`Unhandled error during 'finally' hook: ${err}`); + if (err instanceof Error) { + this.logger.error(err.stack); + } + this.logger.error((err as Error)?.stack); + } + } + } +} diff --git a/packages/web/src/provider/multi-provider/index.ts b/packages/web/src/provider/multi-provider/index.ts new file mode 100644 index 000000000..8cae17a14 --- /dev/null +++ b/packages/web/src/provider/multi-provider/index.ts @@ -0,0 +1,3 @@ +export * from './multi-provider-web'; +export * from './errors'; +export * from './strategies'; diff --git a/packages/web/src/provider/multi-provider/multi-provider-web.ts b/packages/web/src/provider/multi-provider/multi-provider-web.ts new file mode 100644 index 000000000..8dbcf075d --- /dev/null +++ b/packages/web/src/provider/multi-provider/multi-provider-web.ts @@ -0,0 +1,349 @@ +import type { + EvaluationContext, + FlagValueType, + HookContext, + HookHints, + JsonValue, + Logger, + ProviderMetadata, + BeforeHookContext, + ResolutionDetails, + FlagMetadata, + EvaluationDetails, + FlagValue, + OpenFeatureError, + TrackingEventDetails, +} from '@openfeature/core'; +import type { Provider } from '../../provider'; +import type { Hook } from '../../hooks'; +import { OpenFeatureEventEmitter } from '../../events'; +import { DefaultLogger, GeneralError, ErrorCode, StandardResolutionReasons } from '@openfeature/core'; +import { HookExecutor } from './hook-executor'; +import { constructAggregateError, throwAggregateErrorFromPromiseResults } from './errors'; +import type { BaseEvaluationStrategy, ProviderResolutionResult } from './strategies'; +import { FirstMatchStrategy } from './strategies'; +import { StatusTracker } from './status-tracker'; +import type { ProviderEntryInput, RegisteredProvider } from './types'; + +export class MultiProvider implements Provider { + readonly runsOn = 'client'; + + public readonly events = new OpenFeatureEventEmitter(); + + private hookContexts: WeakMap = new WeakMap(); + private hookHints: WeakMap = new WeakMap(); + + metadata: ProviderMetadata; + + providerEntries: RegisteredProvider[] = []; + private providerEntriesByName: Record = {}; + + private hookExecutor: HookExecutor; + private statusTracker = new StatusTracker(this.events); + + constructor( + readonly constructorProviders: ProviderEntryInput[], + private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy(), + private readonly logger: Logger = new DefaultLogger(), + ) { + this.hookExecutor = new HookExecutor(this.logger); + + this.registerProviders(constructorProviders); + + const aggregateMetadata = Object.keys(this.providerEntriesByName).reduce((acc, name) => { + return { ...acc, [name]: this.providerEntriesByName[name].provider.metadata }; + }, {}); + + this.metadata = { + ...aggregateMetadata, + name: MultiProvider.name, + }; + } + + private registerProviders(constructorProviders: ProviderEntryInput[]) { + const providersByName: Record = {}; + + for (const constructorProvider of constructorProviders) { + const providerName = constructorProvider.provider.metadata.name; + const candidateName = constructorProvider.name ?? providerName; + + if (constructorProvider.name && providersByName[constructorProvider.name]) { + throw new Error('Provider names must be unique'); + } + + providersByName[candidateName] ??= []; + providersByName[candidateName].push(constructorProvider.provider); + } + + for (const name of Object.keys(providersByName)) { + const useIndexedNames = providersByName[name].length > 1; + for (let i = 0; i < providersByName[name].length; i++) { + const indexedName = useIndexedNames ? `${name}-${i + 1}` : name; + this.providerEntriesByName[indexedName] = { provider: providersByName[name][i], name: indexedName }; + this.providerEntries.push(this.providerEntriesByName[indexedName]); + this.statusTracker.wrapEventHandler(this.providerEntriesByName[indexedName]); + } + } + + // just make sure we don't accidentally modify these later + Object.freeze(this.providerEntries); + Object.freeze(this.providerEntriesByName); + } + + async initialize(context?: EvaluationContext): Promise { + const result = await Promise.allSettled( + this.providerEntries.map((provider) => provider.provider.initialize?.(context)), + ); + throwAggregateErrorFromPromiseResults(result, this.providerEntries); + } + + async onClose() { + const result = await Promise.allSettled(this.providerEntries.map((provider) => provider.provider.onClose?.())); + throwAggregateErrorFromPromiseResults(result, this.providerEntries); + } + + async onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext) { + for (const providerEntry of this.providerEntries) { + await providerEntry.provider.onContextChange?.(oldContext, newContext); + } + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + ): ResolutionDetails { + return this.flagResolutionProxy(flagKey, 'boolean', defaultValue, context); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + ): ResolutionDetails { + return this.flagResolutionProxy(flagKey, 'string', defaultValue, context); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + ): ResolutionDetails { + return this.flagResolutionProxy(flagKey, 'number', defaultValue, context); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ): ResolutionDetails { + return this.flagResolutionProxy(flagKey, 'object', defaultValue, context); + } + + track(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void { + for (const providerEntry of this.providerEntries) { + if (!providerEntry.provider.track) { + continue; + } + + const strategyContext = { + provider: providerEntry.provider, + providerName: providerEntry.name, + providerStatus: this.statusTracker.providerStatus(providerEntry.name), + }; + + if ( + this.evaluationStrategy.shouldTrackWithThisProvider( + strategyContext, + context, + trackingEventName, + trackingEventDetails, + ) + ) { + try { + providerEntry.provider.track?.(trackingEventName, context, trackingEventDetails); + } catch (error) { + this.logger.error( + `Error tracking event "${trackingEventName}" with provider "${providerEntry.name}":`, + error, + ); + } + } + } + } + + private flagResolutionProxy( + flagKey: string, + flagType: FlagValueType, + defaultValue: T, + context: EvaluationContext, + ): ResolutionDetails { + const hookContext = this.hookContexts.get(context); + const hookHints = this.hookHints.get(context); + + if (!hookContext || !hookHints) { + throw new GeneralError('Hook context not available for evaluation'); + } + + const results = [] as (ProviderResolutionResult | null)[]; + + for (const providerEntry of this.providerEntries) { + const [shouldEvaluateNext, result] = this.evaluateProviderEntry( + flagKey, + flagType, + defaultValue, + providerEntry, + hookContext, + hookHints, + context, + ); + + results.push(result); + + if (!shouldEvaluateNext) { + break; + } + } + + const resolutions = results.filter((r): r is ProviderResolutionResult => Boolean(r)); + const finalResult = this.evaluationStrategy.determineFinalResult({ flagKey, flagType }, context, resolutions); + + if (finalResult.errors?.length) { + throw constructAggregateError(finalResult.errors); + } + + if (!finalResult.details) { + throw new GeneralError('No result was returned from any provider'); + } + + return finalResult.details; + } + + private evaluateProviderEntry( + flagKey: string, + flagType: FlagValueType, + defaultValue: T, + providerEntry: RegisteredProvider, + hookContext: HookContext, + hookHints: HookHints, + context: EvaluationContext, + ): [boolean, ProviderResolutionResult | null] { + let evaluationResult: ResolutionDetails | undefined = undefined; + const provider = providerEntry.provider; + const strategyContext = { + flagKey, + flagType, + provider, + providerName: providerEntry.name, + providerStatus: this.statusTracker.providerStatus(providerEntry.name), + }; + + if (!this.evaluationStrategy.shouldEvaluateThisProvider(strategyContext, context)) { + return [true, null]; + } + + let resolution: ProviderResolutionResult; + + try { + evaluationResult = this.evaluateProviderAndHooks(flagKey, defaultValue, provider, hookContext, hookHints); + resolution = { + details: evaluationResult, + provider: provider, + providerName: providerEntry.name, + }; + } catch (error: unknown) { + resolution = { + thrownError: error, + provider: provider, + providerName: providerEntry.name, + }; + } + + return [this.evaluationStrategy.shouldEvaluateNextProvider(strategyContext, context, resolution), resolution]; + } + + private evaluateProviderAndHooks( + flagKey: string, + defaultValue: T, + provider: Provider, + hookContext: HookContext, + hookHints: HookHints, + ) { + let evaluationDetails: EvaluationDetails; + + try { + this.hookExecutor.beforeHooks(provider.hooks, hookContext, hookHints); + + const resolutionDetails = this.callProviderResolve( + provider, + flagKey, + defaultValue, + hookContext.context, + ) as ResolutionDetails; + + evaluationDetails = { + ...resolutionDetails, + flagMetadata: Object.freeze(resolutionDetails.flagMetadata ?? {}), + flagKey, + }; + + this.hookExecutor.afterHooks(provider.hooks, hookContext, evaluationDetails, hookHints); + } catch (error: unknown) { + this.hookExecutor.errorHooks(provider.hooks, hookContext, error, hookHints); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, error); + } + this.hookExecutor.finallyHooks(provider.hooks, hookContext, evaluationDetails, hookHints); + return evaluationDetails; + } + + private callProviderResolve( + provider: Provider, + flagKey: string, + defaultValue: T, + context: EvaluationContext, + ) { + switch (typeof defaultValue) { + case 'string': + return provider.resolveStringEvaluation(flagKey, defaultValue, context, this.logger); + case 'number': + return provider.resolveNumberEvaluation(flagKey, defaultValue, context, this.logger); + case 'object': + return provider.resolveObjectEvaluation(flagKey, defaultValue, context, this.logger); + case 'boolean': + return provider.resolveBooleanEvaluation(flagKey, defaultValue, context, this.logger); + default: + throw new GeneralError('Invalid flag evaluation type'); + } + } + + public get hooks(): Hook[] { + return [ + { + before: (hookContext: BeforeHookContext, hints: HookHints): EvaluationContext => { + this.hookContexts.set(hookContext.context, hookContext); + this.hookHints.set(hookContext.context, hints ?? {}); + return hookContext.context; + }, + }, + ]; + } + + private getErrorEvaluationDetails( + flagKey: string, + defaultValue: T, + err: unknown, + flagMetadata: FlagMetadata = {}, + ): EvaluationDetails { + const errorMessage: string = (err as Error)?.message; + const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL; + + return { + errorCode, + errorMessage, + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + flagMetadata: Object.freeze(flagMetadata), + flagKey, + }; + } +} diff --git a/packages/web/src/provider/multi-provider/status-tracker.ts b/packages/web/src/provider/multi-provider/status-tracker.ts new file mode 100644 index 000000000..8fdef11c0 --- /dev/null +++ b/packages/web/src/provider/multi-provider/status-tracker.ts @@ -0,0 +1,75 @@ +import type { EventDetails } from '@openfeature/core'; +import type { OpenFeatureEventEmitter } from '../../events'; +import { ProviderEvents } from '../../events'; +import { ProviderStatus } from '../provider'; +import type { RegisteredProvider } from './types'; + +/** + * Tracks each individual provider's status by listening to emitted events + * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers + */ +export class StatusTracker { + private readonly providerStatuses: Record = {}; + + constructor(private events: OpenFeatureEventEmitter) {} + + wrapEventHandler(providerEntry: RegisteredProvider) { + const provider = providerEntry.provider; + provider.events?.addHandler(ProviderEvents.Error, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.ERROR, details); + }); + + provider.events?.addHandler(ProviderEvents.Stale, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.STALE, details); + }); + + provider.events?.addHandler(ProviderEvents.ConfigurationChanged, (details?: EventDetails) => { + this.events.emit(ProviderEvents.ConfigurationChanged, details); + }); + + provider.events?.addHandler(ProviderEvents.Ready, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.READY, details); + }); + + provider.events?.addHandler(ProviderEvents.Reconciling, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, ProviderStatus.RECONCILING, details); + }); + } + + providerStatus(name: string) { + return this.providerStatuses[name]; + } + + private getStatusFromProviderStatuses() { + const statuses = Object.values(this.providerStatuses); + if (statuses.includes(ProviderStatus.FATAL)) { + return ProviderStatus.FATAL; + } else if (statuses.includes(ProviderStatus.NOT_READY)) { + return ProviderStatus.NOT_READY; + } else if (statuses.includes(ProviderStatus.ERROR)) { + return ProviderStatus.ERROR; + } else if (statuses.includes(ProviderStatus.STALE)) { + return ProviderStatus.STALE; + } else if (statuses.includes(ProviderStatus.RECONCILING)) { + return ProviderStatus.RECONCILING; + } + return ProviderStatus.READY; + } + + private changeProviderStatus(name: string, status: ProviderStatus, details?: EventDetails) { + const currentStatus = this.getStatusFromProviderStatuses(); + this.providerStatuses[name] = status; + const newStatus = this.getStatusFromProviderStatuses(); + if (currentStatus !== newStatus) { + if (newStatus === ProviderStatus.FATAL || newStatus === ProviderStatus.ERROR) { + this.events.emit(ProviderEvents.Error, details); + } else if (newStatus === ProviderStatus.STALE) { + this.events.emit(ProviderEvents.Stale, details); + } else if (newStatus === ProviderStatus.READY) { + this.events.emit(ProviderEvents.Ready, details); + } else if (newStatus === ProviderStatus.RECONCILING) { + this.events.emit(ProviderEvents.Reconciling, details); + } + } + } +} diff --git a/packages/web/src/provider/multi-provider/strategies/base-evaluation-strategy.ts b/packages/web/src/provider/multi-provider/strategies/base-evaluation-strategy.ts new file mode 100644 index 000000000..3105b1403 --- /dev/null +++ b/packages/web/src/provider/multi-provider/strategies/base-evaluation-strategy.ts @@ -0,0 +1,128 @@ +import type { + ErrorCode, + EvaluationContext, + FlagValue, + FlagValueType, + OpenFeatureError, + ResolutionDetails, + TrackingEventDetails, +} from '@openfeature/core'; +import type { Provider } from '../../provider'; +import { ProviderStatus } from '../../provider'; +import { ErrorWithCode } from '../errors'; + +export type StrategyEvaluationContext = { + flagKey: string; + flagType: FlagValueType; +}; +export type StrategyProviderContext = { + provider: Provider; + providerName: string; + providerStatus: ProviderStatus; +}; +export type StrategyPerProviderContext = StrategyEvaluationContext & StrategyProviderContext; + +type ProviderResolutionResultBase = { + provider: Provider; + providerName: string; +}; + +export type ProviderResolutionSuccessResult = ProviderResolutionResultBase & { + details: ResolutionDetails; +}; + +export type ProviderResolutionErrorResult = ProviderResolutionResultBase & { + thrownError: unknown; +}; + +export type ProviderResolutionResult = + | ProviderResolutionSuccessResult + | ProviderResolutionErrorResult; + +export type FinalResult = { + details?: ResolutionDetails; + provider?: Provider; + providerName?: string; + errors?: { + providerName: string; + error: unknown; + }[]; +}; + +/** + * Base strategy to inherit from. Not directly usable, as strategies must implement the "determineResult" method + * Contains default implementations for `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` + */ +export abstract class BaseEvaluationStrategy { + shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, _evalContext: EvaluationContext): boolean { + if ( + strategyContext.providerStatus === ProviderStatus.NOT_READY || + strategyContext.providerStatus === ProviderStatus.FATAL + ) { + return false; + } + return true; + } + + shouldEvaluateNextProvider( + _strategyContext: StrategyPerProviderContext, + _context: EvaluationContext, + _result: ProviderResolutionResult, + ): boolean { + return true; + } + + shouldTrackWithThisProvider( + strategyContext: StrategyProviderContext, + _context: EvaluationContext, + _trackingEventName: string, + _trackingEventDetails: TrackingEventDetails, + ): boolean { + if ( + strategyContext.providerStatus === ProviderStatus.NOT_READY || + strategyContext.providerStatus === ProviderStatus.FATAL + ) { + return false; + } + return true; + } + + abstract determineFinalResult( + strategyContext: StrategyEvaluationContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult; + + protected hasError(resolution: ProviderResolutionResult): resolution is + | ProviderResolutionErrorResult + | (ProviderResolutionSuccessResult & { + details: ResolutionDetails & { errorCode: ErrorCode }; + }) { + return 'thrownError' in resolution || !!resolution.details.errorCode; + } + + protected hasErrorWithCode(resolution: ProviderResolutionResult, code: ErrorCode): boolean { + return 'thrownError' in resolution + ? (resolution.thrownError as OpenFeatureError)?.code === code + : resolution.details.errorCode === code; + } + + protected collectProviderErrors(resolutions: ProviderResolutionResult[]): FinalResult { + const errors: FinalResult['errors'] = []; + for (const resolution of resolutions) { + if ('thrownError' in resolution) { + errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); + } else if (resolution.details.errorCode) { + errors.push({ + providerName: resolution.providerName, + error: new ErrorWithCode(resolution.details.errorCode, resolution.details.errorMessage ?? 'unknown error'), + }); + } + } + return { errors }; + } + + protected resolutionToFinalResult(resolution: ProviderResolutionSuccessResult) { + return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; + } +} diff --git a/packages/web/src/provider/multi-provider/strategies/comparison-strategy.ts b/packages/web/src/provider/multi-provider/strategies/comparison-strategy.ts new file mode 100644 index 000000000..64855c4da --- /dev/null +++ b/packages/web/src/provider/multi-provider/strategies/comparison-strategy.ts @@ -0,0 +1,70 @@ +import type { + FinalResult, + ProviderResolutionResult, + ProviderResolutionSuccessResult, + StrategyPerProviderContext, +} from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '@openfeature/core'; +import type { Provider } from '../../provider'; +import { GeneralError } from '@openfeature/core'; + +/** + * Evaluate all providers and compare the results. + * If the values agree, return the value + * If the values disagree, return the value from the configured "fallback provider" and execute the "onMismatch" + * callback if defined + */ +export class ComparisonStrategy extends BaseEvaluationStrategy { + constructor( + private fallbackProvider: Provider, + private onMismatch?: (resolutions: ProviderResolutionResult[]) => void, + ) { + super(); + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + let value: T | undefined; + let fallbackResolution: ProviderResolutionSuccessResult | undefined; + let finalResolution: ProviderResolutionSuccessResult | undefined; + let mismatch = false; + for (const [i, resolution] of resolutions.entries()) { + if (this.hasError(resolution)) { + return this.collectProviderErrors(resolutions); + } + if (resolution.provider === this.fallbackProvider) { + fallbackResolution = resolution; + } + if (i === 0) { + finalResolution = resolution; + } + if (typeof value !== 'undefined' && value !== resolution.details.value) { + mismatch = true; + } else { + value = resolution.details.value; + } + } + + if (!fallbackResolution) { + throw new GeneralError('Fallback provider not found in resolution results'); + } + + if (!finalResolution) { + throw new GeneralError('Final resolution not found in resolution results'); + } + + if (mismatch) { + this.onMismatch?.(resolutions); + return { + details: fallbackResolution.details, + provider: fallbackResolution.provider, + }; + } + + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/packages/web/src/provider/multi-provider/strategies/first-match-strategy.ts b/packages/web/src/provider/multi-provider/strategies/first-match-strategy.ts new file mode 100644 index 000000000..375378d58 --- /dev/null +++ b/packages/web/src/provider/multi-provider/strategies/first-match-strategy.ts @@ -0,0 +1,36 @@ +import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext } from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '@openfeature/core'; +import { ErrorCode } from '@openfeature/core'; + +/** + * Return the first result that did not indicate "flag not found". + * If any provider in the course of evaluation returns or throws an error, throw that error + */ +export class FirstMatchStrategy extends BaseEvaluationStrategy { + override shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean { + if (this.hasErrorWithCode(result, ErrorCode.FLAG_NOT_FOUND)) { + return true; + } + if (this.hasError(result)) { + return false; + } + return false; + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/packages/web/src/provider/multi-provider/strategies/first-successful-strategy.ts b/packages/web/src/provider/multi-provider/strategies/first-successful-strategy.ts new file mode 100644 index 000000000..779eb69d3 --- /dev/null +++ b/packages/web/src/provider/multi-provider/strategies/first-successful-strategy.ts @@ -0,0 +1,31 @@ +import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext } from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '@openfeature/core'; + +/** + * Return the first result that did NOT result in an error + * If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result + * If there is no successful result, throw all errors + */ +export class FirstSuccessfulStrategy extends BaseEvaluationStrategy { + override shouldEvaluateNextProvider( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + result: ProviderResolutionResult, + ): boolean { + // evaluate next only if there was an error + return this.hasError(result); + } + + override determineFinalResult( + strategyContext: StrategyPerProviderContext, + context: EvaluationContext, + resolutions: ProviderResolutionResult[], + ): FinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/packages/web/src/provider/multi-provider/strategies/index.ts b/packages/web/src/provider/multi-provider/strategies/index.ts new file mode 100644 index 000000000..c611ac485 --- /dev/null +++ b/packages/web/src/provider/multi-provider/strategies/index.ts @@ -0,0 +1,4 @@ +export * from './base-evaluation-strategy'; +export * from './first-match-strategy'; +export * from './first-successful-strategy'; +export * from './comparison-strategy'; diff --git a/packages/web/src/provider/multi-provider/types.ts b/packages/web/src/provider/multi-provider/types.ts new file mode 100644 index 000000000..1e2747bb3 --- /dev/null +++ b/packages/web/src/provider/multi-provider/types.ts @@ -0,0 +1,10 @@ +// Represents an entry in the constructor's provider array which may or may not have a name set +import type { Provider } from '../provider'; + +export type ProviderEntryInput = { + provider: Provider; + name?: string; +}; + +// Represents a processed and "registered" provider entry where a name has been chosen +export type RegisteredProvider = Required; diff --git a/packages/web/test/evaluation-context.spec.ts b/packages/web/test/evaluation-context.spec.ts index 7de3b8edc..288301c88 100644 --- a/packages/web/test/evaluation-context.spec.ts +++ b/packages/web/test/evaluation-context.spec.ts @@ -12,13 +12,11 @@ class MockProvider implements Provider { initialize = initializeMock; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise { + onContextChange(_oldContext: EvaluationContext, _newContext: EvaluationContext): Promise { return Promise.resolve(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - resolveBooleanEvaluation = jest.fn((flagKey: string, defaultValue: boolean, context: EvaluationContext) => { + resolveBooleanEvaluation = jest.fn((_flagKey: string, _defaultValue: boolean, _context: EvaluationContext) => { return { value: true, }; diff --git a/packages/web/test/multi-provider-web.spec.ts b/packages/web/test/multi-provider-web.spec.ts new file mode 100644 index 000000000..d6e256010 --- /dev/null +++ b/packages/web/test/multi-provider-web.spec.ts @@ -0,0 +1,894 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { MultiProvider } from '../src/provider/multi-provider/multi-provider-web'; +import type { + EvaluationContext, + FlagValue, + FlagValueType, + Hook, + Logger, + Provider, + ProviderEmittableEvents, + ProviderMetadata, + TrackingEventDetails, +} from '@openfeature/web-sdk'; +import { + DefaultLogger, + MapHookData, + ErrorCode, + FlagNotFoundError, + InMemoryProvider, + OpenFeatureEventEmitter, + ClientProviderEvents, +} from '@openfeature/web-sdk'; +import { FirstMatchStrategy } from '../src/provider/multi-provider/strategies'; +import { FirstSuccessfulStrategy } from '../src/provider/multi-provider/strategies'; +import { ComparisonStrategy } from '../src/provider/multi-provider/strategies'; + +class TestProvider implements Provider { + public metadata: ProviderMetadata = { + name: 'TestProvider', + }; + public events = new OpenFeatureEventEmitter(); + public hooks: Hook[] = []; + public track = jest.fn(); + constructor( + public resolveBooleanEvaluation = jest.fn().mockReturnValue({ value: false }), + public resolveStringEvaluation = jest.fn().mockReturnValue({ value: 'default' }), + public resolveObjectEvaluation = jest.fn().mockReturnValue({ value: {} }), + public resolveNumberEvaluation = jest.fn().mockReturnValue({ value: 0 }), + public initialize = jest.fn(), + ) {} + + emitEvent(type: ProviderEmittableEvents) { + this.events.emit(type, { providerName: this.metadata.name }); + } +} + +const callEvaluation = (multi: MultiProvider, context: EvaluationContext) => { + callBeforeHook(multi, context, 'flag', 'boolean', false); + return multi.resolveBooleanEvaluation('flag', false, context); +}; + +const callBeforeHook = ( + multi: MultiProvider, + context: EvaluationContext, + flagKey: string, + flagType: FlagValueType, + defaultValue: FlagValue, + logger: Logger = new DefaultLogger(), +) => { + const hookContext = { + context: context, + flagKey, + flagValueType: flagType, + defaultValue, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + hookData: new MapHookData(), + }; + multi.hooks[0].before?.(hookContext); +}; + +describe('MultiProvider', () => { + const logger = new DefaultLogger(); + + describe('unique names', () => { + it('uses provider names for unique types', () => { + const multiProvider = new MultiProvider([ + { + provider: new InMemoryProvider(), + }, + { + provider: new TestProvider(), + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('in-memory'); + expect(multiProvider.providerEntries[1].name).toEqual('TestProvider'); + expect(multiProvider.providerEntries.length).toBe(2); + }); + it('generates unique names for identical provider types', () => { + const multiProvider = new MultiProvider([ + { + provider: new TestProvider(), + }, + { + provider: new TestProvider(), + }, + { + provider: new TestProvider(), + }, + { + provider: new InMemoryProvider(), + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('TestProvider-1'); + expect(multiProvider.providerEntries[1].name).toEqual('TestProvider-2'); + expect(multiProvider.providerEntries[2].name).toEqual('TestProvider-3'); + expect(multiProvider.providerEntries[3].name).toEqual('in-memory'); + expect(multiProvider.providerEntries.length).toBe(4); + }); + it('uses specified names for identical provider types', () => { + const multiProvider = new MultiProvider([ + { + provider: new TestProvider(), + name: 'provider1', + }, + { + provider: new TestProvider(), + name: 'provider2', + }, + ]); + expect(multiProvider.providerEntries[0].name).toEqual('provider1'); + expect(multiProvider.providerEntries[1].name).toEqual('provider2'); + expect(multiProvider.providerEntries.length).toBe(2); + }); + it('throws an error if specified names are not unique', () => { + expect( + () => + new MultiProvider([ + { + provider: new TestProvider(), + name: 'provider', + }, + { + provider: new InMemoryProvider(), + name: 'provider', + }, + ]), + ).toThrow(); + }); + }); + + describe('event tracking and statuses', () => { + it('initializes by waiting for all initializations', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let initializations = 0; + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + provider1.initialize.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + initializations++; + }); + provider2.initialize.mockImplementation(() => initializations++); + await multiProvider.initialize(); + expect(initializations).toBe(2); + }); + + it('throws error if a provider errors on initialization', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let initializations = 0; + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + provider1.initialize.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + throw new Error('Failure!'); + }); + provider2.initialize.mockImplementation(async () => initializations++); + await expect(() => multiProvider.initialize()).rejects.toThrow('Failure!'); + }); + + it('emits events when aggregate status changes', async () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + + let readyEmitted = 0; + let errorEmitted = 0; + let staleEmitted = 0; + multiProvider.events.addHandler(ClientProviderEvents.Ready, () => { + readyEmitted++; + }); + + multiProvider.events.addHandler(ClientProviderEvents.Error, () => { + errorEmitted++; + }); + + multiProvider.events.addHandler(ClientProviderEvents.Stale, () => { + staleEmitted++; + }); + + await multiProvider.initialize(); + + provider1.initialize.mockResolvedValue(true); + provider2.initialize.mockResolvedValue(true); + provider1.emitEvent(ClientProviderEvents.Error); + expect(errorEmitted).toBe(1); + provider2.emitEvent(ClientProviderEvents.Error); + // don't emit error again unless aggregate status is changing + expect(errorEmitted).toBe(1); + provider1.emitEvent(ClientProviderEvents.Error); + // don't emit error again unless aggregate status is changing + expect(errorEmitted).toBe(1); + provider2.emitEvent(ClientProviderEvents.Stale); + provider1.emitEvent(ClientProviderEvents.Ready); + // error status provider is ready now but other provider is stale + expect(readyEmitted).toBe(0); + expect(staleEmitted).toBe(1); + provider2.emitEvent(ClientProviderEvents.Ready); + // now both providers are ready + expect(readyEmitted).toBe(1); + }); + }); + + describe('metadata', () => { + it('contains metadata for all providers', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + { + provider: provider2, + }, + ]); + expect(multiProvider.metadata).toEqual({ + name: 'MultiProvider', + 'TestProvider-1': provider1.metadata, + 'TestProvider-2': provider2.metadata, + }); + }); + }); + + describe('evaluation', () => { + describe('hooks', () => { + it("runs all providers' before hooks before evaluation, using same hook context", () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + let hook1Called = false; + let hook2Called = false; + let after1Called = false; + let after2Called = false; + const context = { + test: true, + }; + const hookContext = { + context: context, + flagKey: 'flag', + flagValueType: 'boolean' as any, + defaultValue: false, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + hookData: new MapHookData(), + }; + + provider1.hooks = [ + { + before: (context) => { + hook1Called = true; + expect(context).toEqual(hookContext); + }, + after: (context) => { + expect(context).toEqual(hookContext); + after1Called = true; + }, + }, + { + before: (context) => { + expect(context).toEqual(hookContext); + hook2Called = true; + }, + }, + ]; + + provider2.hooks = [ + { + after: (context) => { + expect(context).toEqual(hookContext); + after2Called = true; + }, + }, + ]; + + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new ComparisonStrategy(provider1), + ); + + multiProvider.hooks[0].before?.(hookContext); + multiProvider.resolveBooleanEvaluation('flag', false, context); + expect(hook1Called).toBe(true); + expect(hook2Called).toBe(true); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'flag', + false, + { test: true }, + expect.any(Object), + ); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'flag', + false, + { test: true }, + expect.any(Object), + ); + expect(after1Called).toBe(true); + expect(after2Called).toBe(true); + }); + + it('runs error hook and finally hook', () => { + const provider1 = new TestProvider(); + let error1Called = false; + let finally1Called = false; + + const context = { + test: true, + }; + + const hookContext = { + context: context, + flagKey: 'flag', + flagValueType: 'boolean' as any, + defaultValue: false, + clientMetadata: {} as any, + providerMetadata: {} as any, + logger: logger, + hookData: new MapHookData(), + }; + + provider1.hooks = [ + { + error: async (context) => { + expect(context).toEqual(hookContext); + error1Called = true; + }, + finally: async (context) => { + expect(context).toEqual(hookContext); + finally1Called = true; + }, + }, + ]; + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + + provider1.resolveBooleanEvaluation.mockImplementation(() => { + throw new Error('test error'); + }); + + multiProvider.hooks[0].before?.(hookContext); + expect(() => multiProvider.resolveBooleanEvaluation('flag', false, context)).toThrow(); + expect(error1Called).toBe(true); + expect(finally1Called).toBe(true); + }); + }); + + describe('resolution logic and strategies', () => { + describe('evaluation data types', () => { + it('evaluates a string variable', () => { + const provider1 = new TestProvider(); + provider1.resolveStringEvaluation.mockReturnValue({ value: 'value' }); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + callBeforeHook(multiProvider, context, 'flag', 'string', 'default'); + expect(multiProvider.resolveStringEvaluation('flag', 'default', context)).toEqual({ + value: 'value', + flagKey: 'flag', + flagMetadata: {}, + }); + }); + + it('evaluates a number variable', () => { + const provider1 = new TestProvider(); + provider1.resolveNumberEvaluation.mockReturnValue({ value: 1 }); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + + callBeforeHook(multiProvider, context, 'flag', 'number', 0); + + expect(multiProvider.resolveNumberEvaluation('flag', 0, context)).toEqual({ + value: 1, + flagKey: 'flag', + flagMetadata: {}, + }); + }); + + it('evaluates a boolean variable', () => { + const provider1 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ value: true }); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + callBeforeHook(multiProvider, context, 'flag', 'boolean', false); + expect(multiProvider.resolveBooleanEvaluation('flag', false, context)).toEqual({ + value: true, + flagKey: 'flag', + flagMetadata: {}, + }); + }); + + it('evaluates an object variable', () => { + const provider1 = new TestProvider(); + provider1.resolveObjectEvaluation.mockReturnValue({ value: { test: true } }); + + const multiProvider = new MultiProvider([ + { + provider: provider1, + }, + ]); + const context = {}; + callBeforeHook(multiProvider, context, 'flag', 'object', {}); + expect(multiProvider.resolveObjectEvaluation('flag', {}, context)).toEqual({ + value: { test: true }, + flagKey: 'flag', + flagMetadata: {}, + }); + }); + }); + describe('first match strategy', () => { + it('throws an error if any provider throws an error during evaluation', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockImplementation(() => { + throw new Error('test error'); + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new FirstMatchStrategy(), + ); + + expect(() => callEvaluation(multiProvider, {})).toThrow('test error'); + expect(provider2.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('throws an error if any provider returns an error result during evaluation', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ + errorCode: 'test-error', + errorMessage: 'test error', + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + ], + new FirstMatchStrategy(), + ); + + expect(() => callEvaluation(multiProvider, {})).toThrow('test error'); + expect(provider2.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('skips providers that return flag not found until it gets a result, skipping any provider after', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ + errorCode: ErrorCode.FLAG_NOT_FOUND, + errorMessage: 'flag not found', + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: true, + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstMatchStrategy(), + ); + const result = callEvaluation(multiProvider, {}); + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + + it('skips providers that throw flag not found until it gets a result, skipping any provider after', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockImplementation(() => { + throw new FlagNotFoundError('flag not found'); + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: true, + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstMatchStrategy(), + ); + const result = callEvaluation(multiProvider, {}); + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + }); + + describe('first successful strategy', () => { + it('ignores errors from earlier providers and returns successful result from later provider', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ + errorCode: 'some error', + errorMessage: 'flag not found', + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: true, + }); + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new FirstSuccessfulStrategy(), + ); + const result = callEvaluation(multiProvider, {}); + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).not.toHaveBeenCalled(); + }); + }); + + describe('comparison strategy', () => { + it('calls every provider and returns a result if they all agree', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ value: true }); + + provider2.resolveBooleanEvaluation.mockReturnValue({ value: true }); + provider3.resolveBooleanEvaluation.mockReturnValue({ value: true }); + + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1), + ); + const result = callEvaluation(multiProvider, {}); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + }); + + it('calls every provider and returns the fallback value if any disagree, and calls onMismatch', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockReturnValue({ + value: true, + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: false, + }); + provider3.resolveBooleanEvaluation.mockReturnValue({ + value: false, + }); + + const onMismatch = jest.fn(); + + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1, onMismatch), + ); + const result = callEvaluation(multiProvider, {}); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(onMismatch).toHaveBeenCalledWith([ + { + provider: provider1, + providerName: 'TestProvider-1', + details: { value: true, flagKey: 'flag', flagMetadata: {} }, + }, + { + provider: provider2, + providerName: 'TestProvider-2', + details: { value: false, flagKey: 'flag', flagMetadata: {} }, + }, + { + provider: provider3, + providerName: 'TestProvider-3', + details: { value: false, flagKey: 'flag', flagMetadata: {} }, + }, + ]); + + expect(result).toEqual({ value: true, flagKey: 'flag', flagMetadata: {} }); + }); + + it('returns an error if any provider returns an error', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + provider1.resolveBooleanEvaluation.mockImplementation(() => { + throw new Error('test error'); + }); + provider2.resolveBooleanEvaluation.mockReturnValue({ + value: false, + }); + provider3.resolveBooleanEvaluation.mockReturnValue({ + value: false, + }); + + const multiProvider = new MultiProvider( + [ + { + provider: provider1, + }, + { + provider: provider2, + }, + { + provider: provider3, + }, + ], + new ComparisonStrategy(provider1), + ); + expect(() => callEvaluation(multiProvider, {})).toThrow('test error'); + expect(provider1.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider2.resolveBooleanEvaluation).toHaveBeenCalled(); + expect(provider3.resolveBooleanEvaluation).toHaveBeenCalled(); + }); + }); + }); + + describe('tracking', () => { + const context: EvaluationContext = { targetingKey: 'user123' }; + const trackingEventDetails: TrackingEventDetails = { value: 100, currency: 'USD' }; + + it('calls track on all providers by default', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + + const multiProvider = new MultiProvider([ + { provider: provider1 }, + { provider: provider2 }, + { provider: provider3 }, + ]); + + multiProvider.track('purchase', context, trackingEventDetails); + + expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + }); + + it('skips providers without track method', () => { + const provider1 = new TestProvider(); + const provider2 = new InMemoryProvider(); // Doesn't have track method + const provider3 = new TestProvider(); + + const multiProvider = new MultiProvider([ + { provider: provider1 }, + { provider: provider2 }, + { provider: provider3 }, + ]); + + expect(() => multiProvider.track('purchase', context, trackingEventDetails)).not.toThrow(); + expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + }); + + it('continues tracking with other providers when one throws an error', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + + provider2.track.mockImplementation(() => { + throw new Error('Tracking failed'); + }); + + const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }; + const multiProvider = new MultiProvider( + [{ provider: provider1 }, { provider: provider2 }, { provider: provider3 }], + undefined, + mockLogger, + ); + + expect(() => multiProvider.track('purchase', context, trackingEventDetails)).not.toThrow(); + + expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Error tracking event "purchase" with provider "TestProvider-2":', + expect.any(Error), + ); + }); + + it('respects strategy shouldTrackWithThisProvider decision', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + + // Create a custom strategy that only allows the second provider to track + class MockStrategy extends FirstMatchStrategy { + override shouldTrackWithThisProvider = jest.fn().mockImplementation((strategyContext) => { + return strategyContext.providerName === 'TestProvider-2'; + }); + } + + const mockStrategy = new MockStrategy(); + + const multiProvider = new MultiProvider( + [{ provider: provider1 }, { provider: provider2 }, { provider: provider3 }], + mockStrategy, + ); + + multiProvider.track('purchase', context, trackingEventDetails); + + expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledTimes(3); + expect(provider1.track).not.toHaveBeenCalled(); + expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider3.track).not.toHaveBeenCalled(); + }); + + it('does not track with providers in NOT_READY or FATAL status by default', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + const provider3 = new TestProvider(); + + const multiProvider = new MultiProvider([ + { provider: provider1 }, + { provider: provider2 }, + { provider: provider3 }, + ]); + + // Mock the status tracker to return different statuses + const mockStatusTracker = { + providerStatus: jest.fn().mockImplementation((name) => { + if (name === 'TestProvider-1') return 'NOT_READY'; + if (name === 'TestProvider-2') return 'READY'; + if (name === 'TestProvider-3') return 'FATAL'; + return 'READY'; // Default fallback + }), + }; + (multiProvider as any).statusTracker = mockStatusTracker; + + multiProvider.track('purchase', context, trackingEventDetails); + + expect(provider1.track).not.toHaveBeenCalled(); + expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails); + expect(provider3.track).not.toHaveBeenCalled(); + }); + + it('passes correct strategy context to shouldTrackWithThisProvider', () => { + const provider1 = new TestProvider(); + const provider2 = new TestProvider(); + + class MockStrategy extends FirstMatchStrategy { + override shouldTrackWithThisProvider = jest.fn().mockReturnValue(true); + } + + const mockStrategy = new MockStrategy(); + + const multiProvider = new MultiProvider([{ provider: provider1 }, { provider: provider2 }], mockStrategy); + + // Mock the status tracker to return READY status + const mockStatusTracker = { + providerStatus: jest.fn().mockReturnValue('READY'), + }; + (multiProvider as any).statusTracker = mockStatusTracker; + + multiProvider.track('purchase', context, trackingEventDetails); + + expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledWith( + { + provider: provider1, + providerName: 'TestProvider-1', + providerStatus: 'READY', + }, + context, + 'purchase', + trackingEventDetails, + ); + + expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledWith( + { + provider: provider2, + providerName: 'TestProvider-2', + providerStatus: 'READY', + }, + context, + 'purchase', + trackingEventDetails, + ); + }); + }); + }); +}); From ff941977921bbfeb88da1069b79f1ef7b64156a3 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 29 Sep 2025 15:42:42 -0400 Subject: [PATCH 3/4] feat: moving duplicate Multi-Provider resources to shared pacakge, WIP # Conflicts: # packages/web/src/provider/multi-provider/status-tracker.ts --- .../src/provider/multi-provider/index.ts | 2 - .../provider/multi-provider/multi-provider.ts | 20 ++- .../provider/multi-provider/status-tracker.ts | 2 +- .../strategies/comparison-strategy.ts | 72 ---------- .../strategies/first-match-strategy.ts | 36 ----- .../src/provider/multi-provider/types.ts | 2 - packages/shared/src/provider/index.ts | 4 + .../src/provider/multi-provider/errors.ts | 4 +- .../provider/multi-provider/status-tracker.ts | 66 +++++++++ .../strategies/base-evaluation-strategy.ts | 38 ++---- .../strategies/comparison-strategy.ts | 16 +-- .../strategies/first-match-strategy.ts | 8 +- .../strategies/first-successful-strategy.ts | 8 +- .../multi-provider/strategies/index.ts | 0 .../src/provider/multi-provider/types.ts | 12 ++ packages/shared/src/provider/provider.ts | 9 +- .../web/src/provider/multi-provider/errors.ts | 53 -------- .../web/src/provider/multi-provider/index.ts | 2 - .../multi-provider/multi-provider-web.ts | 24 +++- .../provider/multi-provider/status-tracker.ts | 75 ---------- .../strategies/base-evaluation-strategy.ts | 128 ------------------ .../strategies/first-successful-strategy.ts | 31 ----- .../multi-provider/strategies/index.ts | 4 - .../web/src/provider/multi-provider/types.ts | 10 -- 24 files changed, 148 insertions(+), 478 deletions(-) delete mode 100644 packages/server/src/provider/multi-provider/strategies/comparison-strategy.ts delete mode 100644 packages/server/src/provider/multi-provider/strategies/first-match-strategy.ts rename packages/{server => shared}/src/provider/multi-provider/errors.ts (92%) create mode 100644 packages/shared/src/provider/multi-provider/status-tracker.ts rename packages/{server => shared}/src/provider/multi-provider/strategies/base-evaluation-strategy.ts (79%) rename packages/{web => shared}/src/provider/multi-provider/strategies/comparison-strategy.ts (80%) rename packages/{web => shared}/src/provider/multi-provider/strategies/first-match-strategy.ts (78%) rename packages/{server => shared}/src/provider/multi-provider/strategies/first-successful-strategy.ts (71%) rename packages/{server => shared}/src/provider/multi-provider/strategies/index.ts (100%) create mode 100644 packages/shared/src/provider/multi-provider/types.ts delete mode 100644 packages/web/src/provider/multi-provider/errors.ts delete mode 100644 packages/web/src/provider/multi-provider/status-tracker.ts delete mode 100644 packages/web/src/provider/multi-provider/strategies/base-evaluation-strategy.ts delete mode 100644 packages/web/src/provider/multi-provider/strategies/first-successful-strategy.ts delete mode 100644 packages/web/src/provider/multi-provider/strategies/index.ts delete mode 100644 packages/web/src/provider/multi-provider/types.ts diff --git a/packages/server/src/provider/multi-provider/index.ts b/packages/server/src/provider/multi-provider/index.ts index e7c2b3828..bcf3023c7 100644 --- a/packages/server/src/provider/multi-provider/index.ts +++ b/packages/server/src/provider/multi-provider/index.ts @@ -1,3 +1 @@ export * from './multi-provider'; -export * from './errors'; -export * from './strategies'; diff --git a/packages/server/src/provider/multi-provider/multi-provider.ts b/packages/server/src/provider/multi-provider/multi-provider.ts index 998c9149c..ea14e1aef 100644 --- a/packages/server/src/provider/multi-provider/multi-provider.ts +++ b/packages/server/src/provider/multi-provider/multi-provider.ts @@ -13,17 +13,25 @@ import type { ProviderMetadata, ResolutionDetails, TrackingEventDetails, + BaseEvaluationStrategy, + ProviderResolutionResult, + ProviderEntryInput, + RegisteredProvider, +} from '@openfeature/core'; +import { + DefaultLogger, + ErrorCode, + GeneralError, + StandardResolutionReasons, + constructAggregateError, + FirstMatchStrategy, + throwAggregateErrorFromPromiseResults, + StatusTracker, } from '@openfeature/core'; -import { DefaultLogger, ErrorCode, GeneralError, StandardResolutionReasons } from '@openfeature/core'; import type { Provider } from '../provider'; import type { Hook } from '../../hooks'; import { OpenFeatureEventEmitter } from '../../events/open-feature-event-emitter'; -import { constructAggregateError, throwAggregateErrorFromPromiseResults } from './errors'; import { HookExecutor } from './hook-executor'; -import { StatusTracker } from './status-tracker'; -import type { BaseEvaluationStrategy, ProviderResolutionResult } from './strategies'; -import { FirstMatchStrategy } from './strategies'; -import type { ProviderEntryInput, RegisteredProvider } from './types'; export class MultiProvider implements Provider { readonly runsOn = 'server'; diff --git a/packages/server/src/provider/multi-provider/status-tracker.ts b/packages/server/src/provider/multi-provider/status-tracker.ts index b06bd054d..42cdde5ea 100644 --- a/packages/server/src/provider/multi-provider/status-tracker.ts +++ b/packages/server/src/provider/multi-provider/status-tracker.ts @@ -1,6 +1,6 @@ import type { EventDetails } from '@openfeature/core'; -import type { OpenFeatureEventEmitter } from '../../events'; import { ProviderEvents } from '../../events'; +import type { OpenFeatureEventEmitter } from '../../events/open-feature-event-emitter'; import { ProviderStatus } from '../provider'; import type { RegisteredProvider } from './types'; diff --git a/packages/server/src/provider/multi-provider/strategies/comparison-strategy.ts b/packages/server/src/provider/multi-provider/strategies/comparison-strategy.ts deleted file mode 100644 index 491c4cf3e..000000000 --- a/packages/server/src/provider/multi-provider/strategies/comparison-strategy.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - FinalResult, - ProviderResolutionResult, - ProviderResolutionSuccessResult, - StrategyPerProviderContext, -} from './base-evaluation-strategy'; -import { BaseEvaluationStrategy } from './base-evaluation-strategy'; -import type { EvaluationContext, FlagValue } from '@openfeature/core'; -import type { Provider } from '../../provider'; -import { GeneralError } from '@openfeature/core'; - -/** - * Evaluate all providers in parallel and compare the results. - * If the values agree, return the value - * If the values disagree, return the value from the configured "fallback provider" and execute the "onMismatch" - * callback if defined - */ -export class ComparisonStrategy extends BaseEvaluationStrategy { - override runMode = 'parallel' as const; - - constructor( - private fallbackProvider: Provider, - private onMismatch?: (resolutions: ProviderResolutionResult[]) => void, - ) { - super(); - } - - override determineFinalResult( - strategyContext: StrategyPerProviderContext, - context: EvaluationContext, - resolutions: ProviderResolutionResult[], - ): FinalResult { - let value: T | undefined; - let fallbackResolution: ProviderResolutionSuccessResult | undefined; - let finalResolution: ProviderResolutionSuccessResult | undefined; - let mismatch = false; - for (const [i, resolution] of resolutions.entries()) { - if (this.hasError(resolution)) { - return this.collectProviderErrors(resolutions); - } - if (resolution.provider === this.fallbackProvider) { - fallbackResolution = resolution; - } - if (i === 0) { - finalResolution = resolution; - } - if (typeof value !== 'undefined' && value !== resolution.details.value) { - mismatch = true; - } else { - value = resolution.details.value; - } - } - - if (!fallbackResolution) { - throw new GeneralError('Fallback provider not found in resolution results'); - } - - if (!finalResolution) { - throw new GeneralError('Final resolution not found in resolution results'); - } - - if (mismatch) { - this.onMismatch?.(resolutions); - return { - details: fallbackResolution.details, - provider: fallbackResolution.provider, - }; - } - - return this.resolutionToFinalResult(finalResolution); - } -} diff --git a/packages/server/src/provider/multi-provider/strategies/first-match-strategy.ts b/packages/server/src/provider/multi-provider/strategies/first-match-strategy.ts deleted file mode 100644 index 375378d58..000000000 --- a/packages/server/src/provider/multi-provider/strategies/first-match-strategy.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext } from './base-evaluation-strategy'; -import { BaseEvaluationStrategy } from './base-evaluation-strategy'; -import type { EvaluationContext, FlagValue } from '@openfeature/core'; -import { ErrorCode } from '@openfeature/core'; - -/** - * Return the first result that did not indicate "flag not found". - * If any provider in the course of evaluation returns or throws an error, throw that error - */ -export class FirstMatchStrategy extends BaseEvaluationStrategy { - override shouldEvaluateNextProvider( - strategyContext: StrategyPerProviderContext, - context: EvaluationContext, - result: ProviderResolutionResult, - ): boolean { - if (this.hasErrorWithCode(result, ErrorCode.FLAG_NOT_FOUND)) { - return true; - } - if (this.hasError(result)) { - return false; - } - return false; - } - - override determineFinalResult( - strategyContext: StrategyPerProviderContext, - context: EvaluationContext, - resolutions: ProviderResolutionResult[], - ): FinalResult { - const finalResolution = resolutions[resolutions.length - 1]; - if (this.hasError(finalResolution)) { - return this.collectProviderErrors(resolutions); - } - return this.resolutionToFinalResult(finalResolution); - } -} diff --git a/packages/server/src/provider/multi-provider/types.ts b/packages/server/src/provider/multi-provider/types.ts index 1e2747bb3..fbbaed697 100644 --- a/packages/server/src/provider/multi-provider/types.ts +++ b/packages/server/src/provider/multi-provider/types.ts @@ -1,4 +1,3 @@ -// Represents an entry in the constructor's provider array which may or may not have a name set import type { Provider } from '../provider'; export type ProviderEntryInput = { @@ -6,5 +5,4 @@ export type ProviderEntryInput = { name?: string; }; -// Represents a processed and "registered" provider entry where a name has been chosen export type RegisteredProvider = Required; diff --git a/packages/shared/src/provider/index.ts b/packages/shared/src/provider/index.ts index 03be03e58..952bd5e77 100644 --- a/packages/shared/src/provider/index.ts +++ b/packages/shared/src/provider/index.ts @@ -1 +1,5 @@ export * from './provider'; +export * from './multi-provider/errors'; +export * from './multi-provider/status-tracker'; +export * from './multi-provider/types'; +export * from './multi-provider/strategies'; diff --git a/packages/server/src/provider/multi-provider/errors.ts b/packages/shared/src/provider/multi-provider/errors.ts similarity index 92% rename from packages/server/src/provider/multi-provider/errors.ts rename to packages/shared/src/provider/multi-provider/errors.ts index f1f14438d..de726212c 100644 --- a/packages/server/src/provider/multi-provider/errors.ts +++ b/packages/shared/src/provider/multi-provider/errors.ts @@ -1,5 +1,5 @@ -import type { ErrorCode } from '@openfeature/core'; -import { GeneralError, OpenFeatureError } from '@openfeature/core'; +import type { ErrorCode } from '../../evaluation'; +import { GeneralError, OpenFeatureError } from '../../errors'; import type { RegisteredProvider } from './types'; export class ErrorWithCode extends OpenFeatureError { diff --git a/packages/shared/src/provider/multi-provider/status-tracker.ts b/packages/shared/src/provider/multi-provider/status-tracker.ts new file mode 100644 index 000000000..1d31d2925 --- /dev/null +++ b/packages/shared/src/provider/multi-provider/status-tracker.ts @@ -0,0 +1,66 @@ +import type { EventDetails, ProviderEventEmitter } from '../../events'; +import { AllProviderEvents } from '../../events'; +import { AllProviderStatus } from '../../provider'; +import type { RegisteredProvider } from './types'; + +/** + * Tracks each individual provider's status by listening to emitted events + * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers + */ +export class StatusTracker { + private readonly providerStatuses: Record = {}; + + constructor(private events: ProviderEventEmitter) {} + + wrapEventHandler(providerEntry: RegisteredProvider) { + const provider = providerEntry.provider; + provider.events?.addHandler(AllProviderEvents.Error, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, AllProviderStatus.ERROR, details); + }); + + provider.events?.addHandler(AllProviderEvents.Stale, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, AllProviderStatus.STALE, details); + }); + + provider.events?.addHandler(AllProviderEvents.ConfigurationChanged, (details?: EventDetails) => { + this.events.emit(AllProviderEvents.ConfigurationChanged, details); + }); + + provider.events?.addHandler(AllProviderEvents.Ready, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, AllProviderStatus.READY, details); + }); + } + + providerStatus(name: string) { + return this.providerStatuses[name]; + } + + private getStatusFromProviderStatuses() { + const statuses = Object.values(this.providerStatuses); + if (statuses.includes(AllProviderStatus.FATAL)) { + return AllProviderStatus.FATAL; + } else if (statuses.includes(AllProviderStatus.NOT_READY)) { + return AllProviderStatus.NOT_READY; + } else if (statuses.includes(AllProviderStatus.ERROR)) { + return AllProviderStatus.ERROR; + } else if (statuses.includes(AllProviderStatus.STALE)) { + return AllProviderStatus.STALE; + } + return AllProviderStatus.READY; + } + + private changeProviderStatus(name: string, status: AllProviderStatus, details?: EventDetails) { + const currentStatus = this.getStatusFromProviderStatuses(); + this.providerStatuses[name] = status; + const newStatus = this.getStatusFromProviderStatuses(); + if (currentStatus !== newStatus) { + if (newStatus === AllProviderStatus.FATAL || newStatus === AllProviderStatus.ERROR) { + this.events.emit(AllProviderEvents.Error, details); + } else if (newStatus === AllProviderStatus.STALE) { + this.events.emit(AllProviderEvents.Stale, details); + } else if (newStatus === AllProviderStatus.READY) { + this.events.emit(AllProviderEvents.Ready, details); + } + } + } +} diff --git a/packages/server/src/provider/multi-provider/strategies/base-evaluation-strategy.ts b/packages/shared/src/provider/multi-provider/strategies/base-evaluation-strategy.ts similarity index 79% rename from packages/server/src/provider/multi-provider/strategies/base-evaluation-strategy.ts rename to packages/shared/src/provider/multi-provider/strategies/base-evaluation-strategy.ts index cb74993cc..057cb1504 100644 --- a/packages/server/src/provider/multi-provider/strategies/base-evaluation-strategy.ts +++ b/packages/shared/src/provider/multi-provider/strategies/base-evaluation-strategy.ts @@ -1,29 +1,25 @@ -import type { - ErrorCode, - EvaluationContext, - FlagValue, - FlagValueType, - OpenFeatureError, - ResolutionDetails, - TrackingEventDetails, -} from '@openfeature/core'; -import type { Provider } from '../../provider'; -import { ProviderStatus } from '../../provider'; +import type { ErrorCode, EvaluationContext, FlagValue, FlagValueType, ResolutionDetails } from '../../../evaluation'; +import type { OpenFeatureError } from '../../../errors'; +import type { CommonProvider } from '../../../provider'; +import { AllProviderStatus } from '../../../provider'; import { ErrorWithCode } from '../errors'; +import type { TrackingEventDetails } from '../../../tracking'; export type StrategyEvaluationContext = { flagKey: string; flagType: FlagValueType; }; + export type StrategyProviderContext = { - provider: Provider; + provider: CommonProvider; providerName: string; - providerStatus: ProviderStatus; + providerStatus: AllProviderStatus; }; + export type StrategyPerProviderContext = StrategyEvaluationContext & StrategyProviderContext; type ProviderResolutionResultBase = { - provider: Provider; + provider: CommonProvider; providerName: string; }; @@ -41,7 +37,7 @@ export type ProviderResolutionResult = export type FinalResult = { details?: ResolutionDetails; - provider?: Provider; + provider?: CommonProvider; providerName?: string; errors?: { providerName: string; @@ -49,17 +45,13 @@ export type FinalResult = { }[]; }; -/** - * Base strategy to inherit from. Not directly usable, as strategies must implement the "determineResult" method - * Contains default implementations for `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` - */ export abstract class BaseEvaluationStrategy { public runMode: 'parallel' | 'sequential' = 'sequential'; shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, _evalContext?: EvaluationContext): boolean { if ( - strategyContext.providerStatus === ProviderStatus.NOT_READY || - strategyContext.providerStatus === ProviderStatus.FATAL + strategyContext.providerStatus === AllProviderStatus.NOT_READY || + strategyContext.providerStatus === AllProviderStatus.FATAL ) { return false; } @@ -81,8 +73,8 @@ export abstract class BaseEvaluationStrategy { _trackingEventDetails?: TrackingEventDetails, ): boolean { if ( - strategyContext.providerStatus === ProviderStatus.NOT_READY || - strategyContext.providerStatus === ProviderStatus.FATAL + strategyContext.providerStatus === AllProviderStatus.NOT_READY || + strategyContext.providerStatus === AllProviderStatus.FATAL ) { return false; } diff --git a/packages/web/src/provider/multi-provider/strategies/comparison-strategy.ts b/packages/shared/src/provider/multi-provider/strategies/comparison-strategy.ts similarity index 80% rename from packages/web/src/provider/multi-provider/strategies/comparison-strategy.ts rename to packages/shared/src/provider/multi-provider/strategies/comparison-strategy.ts index 64855c4da..1c5af3684 100644 --- a/packages/web/src/provider/multi-provider/strategies/comparison-strategy.ts +++ b/packages/shared/src/provider/multi-provider/strategies/comparison-strategy.ts @@ -5,19 +5,15 @@ import type { StrategyPerProviderContext, } from './base-evaluation-strategy'; import { BaseEvaluationStrategy } from './base-evaluation-strategy'; -import type { EvaluationContext, FlagValue } from '@openfeature/core'; -import type { Provider } from '../../provider'; -import { GeneralError } from '@openfeature/core'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; +import type { CommonProvider, AllProviderStatus } from '../../../provider'; +import { GeneralError } from '../../../errors'; -/** - * Evaluate all providers and compare the results. - * If the values agree, return the value - * If the values disagree, return the value from the configured "fallback provider" and execute the "onMismatch" - * callback if defined - */ export class ComparisonStrategy extends BaseEvaluationStrategy { + override runMode = 'parallel' as const; + constructor( - private fallbackProvider: Provider, + private fallbackProvider: CommonProvider, private onMismatch?: (resolutions: ProviderResolutionResult[]) => void, ) { super(); diff --git a/packages/web/src/provider/multi-provider/strategies/first-match-strategy.ts b/packages/shared/src/provider/multi-provider/strategies/first-match-strategy.ts similarity index 78% rename from packages/web/src/provider/multi-provider/strategies/first-match-strategy.ts rename to packages/shared/src/provider/multi-provider/strategies/first-match-strategy.ts index 375378d58..b5a00a3c8 100644 --- a/packages/web/src/provider/multi-provider/strategies/first-match-strategy.ts +++ b/packages/shared/src/provider/multi-provider/strategies/first-match-strategy.ts @@ -1,12 +1,8 @@ import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext } from './base-evaluation-strategy'; import { BaseEvaluationStrategy } from './base-evaluation-strategy'; -import type { EvaluationContext, FlagValue } from '@openfeature/core'; -import { ErrorCode } from '@openfeature/core'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; +import { ErrorCode } from '../../../evaluation'; -/** - * Return the first result that did not indicate "flag not found". - * If any provider in the course of evaluation returns or throws an error, throw that error - */ export class FirstMatchStrategy extends BaseEvaluationStrategy { override shouldEvaluateNextProvider( strategyContext: StrategyPerProviderContext, diff --git a/packages/server/src/provider/multi-provider/strategies/first-successful-strategy.ts b/packages/shared/src/provider/multi-provider/strategies/first-successful-strategy.ts similarity index 71% rename from packages/server/src/provider/multi-provider/strategies/first-successful-strategy.ts rename to packages/shared/src/provider/multi-provider/strategies/first-successful-strategy.ts index 779eb69d3..9355e7335 100644 --- a/packages/server/src/provider/multi-provider/strategies/first-successful-strategy.ts +++ b/packages/shared/src/provider/multi-provider/strategies/first-successful-strategy.ts @@ -1,19 +1,13 @@ import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext } from './base-evaluation-strategy'; import { BaseEvaluationStrategy } from './base-evaluation-strategy'; -import type { EvaluationContext, FlagValue } from '@openfeature/core'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; -/** - * Return the first result that did NOT result in an error - * If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result - * If there is no successful result, throw all errors - */ export class FirstSuccessfulStrategy extends BaseEvaluationStrategy { override shouldEvaluateNextProvider( strategyContext: StrategyPerProviderContext, context: EvaluationContext, result: ProviderResolutionResult, ): boolean { - // evaluate next only if there was an error return this.hasError(result); } diff --git a/packages/server/src/provider/multi-provider/strategies/index.ts b/packages/shared/src/provider/multi-provider/strategies/index.ts similarity index 100% rename from packages/server/src/provider/multi-provider/strategies/index.ts rename to packages/shared/src/provider/multi-provider/strategies/index.ts diff --git a/packages/shared/src/provider/multi-provider/types.ts b/packages/shared/src/provider/multi-provider/types.ts new file mode 100644 index 000000000..2a0622817 --- /dev/null +++ b/packages/shared/src/provider/multi-provider/types.ts @@ -0,0 +1,12 @@ +import type { AllProviderStatus, CommonProvider } from '../../provider'; + +export type ProviderEntryInput< + TProvider extends CommonProvider = CommonProvider, +> = { + provider: TProvider; + name?: string; +}; + +export type RegisteredProvider< + TProvider extends CommonProvider = CommonProvider, +> = Required>; diff --git a/packages/shared/src/provider/provider.ts b/packages/shared/src/provider/provider.ts index 74ae8606f..c3d72f6d8 100644 --- a/packages/shared/src/provider/provider.ts +++ b/packages/shared/src/provider/provider.ts @@ -78,7 +78,14 @@ export enum ClientProviderStatus { * A type representing any possible ProviderStatus (server or client side). * In most cases, you probably want to import `ProviderStatus` from the respective SDK. */ -export { ClientProviderStatus as AllProviderStatus }; +export enum AllProviderStatus { + NOT_READY = 'NOT_READY', + READY = 'READY', + ERROR = 'ERROR', + STALE = 'STALE', + FATAL = 'FATAL', + RECONCILING = 'RECONCILING', +} /** * Static data about the provider. diff --git a/packages/web/src/provider/multi-provider/errors.ts b/packages/web/src/provider/multi-provider/errors.ts deleted file mode 100644 index f1f14438d..000000000 --- a/packages/web/src/provider/multi-provider/errors.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ErrorCode } from '@openfeature/core'; -import { GeneralError, OpenFeatureError } from '@openfeature/core'; -import type { RegisteredProvider } from './types'; - -export class ErrorWithCode extends OpenFeatureError { - constructor( - public code: ErrorCode, - message: string, - ) { - super(message); - } -} - -export class AggregateError extends GeneralError { - constructor( - message: string, - public originalErrors: { source: string; error: unknown }[], - ) { - super(message); - } -} - -export const constructAggregateError = (providerErrors: { error: unknown; providerName: string }[]) => { - const errorsWithSource = providerErrors - .map(({ providerName, error }) => { - return { source: providerName, error }; - }) - .flat(); - - // log first error in the message for convenience, but include all errors in the error object for completeness - return new AggregateError( - `Provider errors occurred: ${errorsWithSource[0].source}: ${errorsWithSource[0].error}`, - errorsWithSource, - ); -}; - -export const throwAggregateErrorFromPromiseResults = ( - result: PromiseSettledResult[], - providerEntries: RegisteredProvider[], -) => { - const errors = result - .map((r, i) => { - if (r.status === 'rejected') { - return { error: r.reason, providerName: providerEntries[i].name }; - } - return null; - }) - .filter((val): val is { error: unknown; providerName: string } => Boolean(val)); - - if (errors.length) { - throw constructAggregateError(errors); - } -}; diff --git a/packages/web/src/provider/multi-provider/index.ts b/packages/web/src/provider/multi-provider/index.ts index 8cae17a14..1a483967e 100644 --- a/packages/web/src/provider/multi-provider/index.ts +++ b/packages/web/src/provider/multi-provider/index.ts @@ -1,3 +1 @@ export * from './multi-provider-web'; -export * from './errors'; -export * from './strategies'; diff --git a/packages/web/src/provider/multi-provider/multi-provider-web.ts b/packages/web/src/provider/multi-provider/multi-provider-web.ts index 8dbcf075d..a8af8d8b5 100644 --- a/packages/web/src/provider/multi-provider/multi-provider-web.ts +++ b/packages/web/src/provider/multi-provider/multi-provider-web.ts @@ -18,12 +18,22 @@ import type { Provider } from '../../provider'; import type { Hook } from '../../hooks'; import { OpenFeatureEventEmitter } from '../../events'; import { DefaultLogger, GeneralError, ErrorCode, StandardResolutionReasons } from '@openfeature/core'; +import type { + BaseEvaluationStrategy, + ProviderEntryInput as CoreProviderEntryInput, + ProviderResolutionResult, + RegisteredProvider as CoreRegisteredProvider, +} from '@openfeature/core'; +import { + constructAggregateError, + FirstMatchStrategy, + StatusTracker, + throwAggregateErrorFromPromiseResults, +} from '@openfeature/core'; import { HookExecutor } from './hook-executor'; -import { constructAggregateError, throwAggregateErrorFromPromiseResults } from './errors'; -import type { BaseEvaluationStrategy, ProviderResolutionResult } from './strategies'; -import { FirstMatchStrategy } from './strategies'; -import { StatusTracker } from './status-tracker'; -import type { ProviderEntryInput, RegisteredProvider } from './types'; + +type ProviderEntry = CoreProviderEntryInput; +type RegisteredProvider = CoreRegisteredProvider; export class MultiProvider implements Provider { readonly runsOn = 'client'; @@ -42,7 +52,7 @@ export class MultiProvider implements Provider { private statusTracker = new StatusTracker(this.events); constructor( - readonly constructorProviders: ProviderEntryInput[], + readonly constructorProviders: ProviderEntry[], private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy(), private readonly logger: Logger = new DefaultLogger(), ) { @@ -60,7 +70,7 @@ export class MultiProvider implements Provider { }; } - private registerProviders(constructorProviders: ProviderEntryInput[]) { + private registerProviders(constructorProviders: ProviderEntry[]) { const providersByName: Record = {}; for (const constructorProvider of constructorProviders) { diff --git a/packages/web/src/provider/multi-provider/status-tracker.ts b/packages/web/src/provider/multi-provider/status-tracker.ts deleted file mode 100644 index 8fdef11c0..000000000 --- a/packages/web/src/provider/multi-provider/status-tracker.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { EventDetails } from '@openfeature/core'; -import type { OpenFeatureEventEmitter } from '../../events'; -import { ProviderEvents } from '../../events'; -import { ProviderStatus } from '../provider'; -import type { RegisteredProvider } from './types'; - -/** - * Tracks each individual provider's status by listening to emitted events - * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers - */ -export class StatusTracker { - private readonly providerStatuses: Record = {}; - - constructor(private events: OpenFeatureEventEmitter) {} - - wrapEventHandler(providerEntry: RegisteredProvider) { - const provider = providerEntry.provider; - provider.events?.addHandler(ProviderEvents.Error, (details?: EventDetails) => { - this.changeProviderStatus(providerEntry.name, ProviderStatus.ERROR, details); - }); - - provider.events?.addHandler(ProviderEvents.Stale, (details?: EventDetails) => { - this.changeProviderStatus(providerEntry.name, ProviderStatus.STALE, details); - }); - - provider.events?.addHandler(ProviderEvents.ConfigurationChanged, (details?: EventDetails) => { - this.events.emit(ProviderEvents.ConfigurationChanged, details); - }); - - provider.events?.addHandler(ProviderEvents.Ready, (details?: EventDetails) => { - this.changeProviderStatus(providerEntry.name, ProviderStatus.READY, details); - }); - - provider.events?.addHandler(ProviderEvents.Reconciling, (details?: EventDetails) => { - this.changeProviderStatus(providerEntry.name, ProviderStatus.RECONCILING, details); - }); - } - - providerStatus(name: string) { - return this.providerStatuses[name]; - } - - private getStatusFromProviderStatuses() { - const statuses = Object.values(this.providerStatuses); - if (statuses.includes(ProviderStatus.FATAL)) { - return ProviderStatus.FATAL; - } else if (statuses.includes(ProviderStatus.NOT_READY)) { - return ProviderStatus.NOT_READY; - } else if (statuses.includes(ProviderStatus.ERROR)) { - return ProviderStatus.ERROR; - } else if (statuses.includes(ProviderStatus.STALE)) { - return ProviderStatus.STALE; - } else if (statuses.includes(ProviderStatus.RECONCILING)) { - return ProviderStatus.RECONCILING; - } - return ProviderStatus.READY; - } - - private changeProviderStatus(name: string, status: ProviderStatus, details?: EventDetails) { - const currentStatus = this.getStatusFromProviderStatuses(); - this.providerStatuses[name] = status; - const newStatus = this.getStatusFromProviderStatuses(); - if (currentStatus !== newStatus) { - if (newStatus === ProviderStatus.FATAL || newStatus === ProviderStatus.ERROR) { - this.events.emit(ProviderEvents.Error, details); - } else if (newStatus === ProviderStatus.STALE) { - this.events.emit(ProviderEvents.Stale, details); - } else if (newStatus === ProviderStatus.READY) { - this.events.emit(ProviderEvents.Ready, details); - } else if (newStatus === ProviderStatus.RECONCILING) { - this.events.emit(ProviderEvents.Reconciling, details); - } - } - } -} diff --git a/packages/web/src/provider/multi-provider/strategies/base-evaluation-strategy.ts b/packages/web/src/provider/multi-provider/strategies/base-evaluation-strategy.ts deleted file mode 100644 index 3105b1403..000000000 --- a/packages/web/src/provider/multi-provider/strategies/base-evaluation-strategy.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { - ErrorCode, - EvaluationContext, - FlagValue, - FlagValueType, - OpenFeatureError, - ResolutionDetails, - TrackingEventDetails, -} from '@openfeature/core'; -import type { Provider } from '../../provider'; -import { ProviderStatus } from '../../provider'; -import { ErrorWithCode } from '../errors'; - -export type StrategyEvaluationContext = { - flagKey: string; - flagType: FlagValueType; -}; -export type StrategyProviderContext = { - provider: Provider; - providerName: string; - providerStatus: ProviderStatus; -}; -export type StrategyPerProviderContext = StrategyEvaluationContext & StrategyProviderContext; - -type ProviderResolutionResultBase = { - provider: Provider; - providerName: string; -}; - -export type ProviderResolutionSuccessResult = ProviderResolutionResultBase & { - details: ResolutionDetails; -}; - -export type ProviderResolutionErrorResult = ProviderResolutionResultBase & { - thrownError: unknown; -}; - -export type ProviderResolutionResult = - | ProviderResolutionSuccessResult - | ProviderResolutionErrorResult; - -export type FinalResult = { - details?: ResolutionDetails; - provider?: Provider; - providerName?: string; - errors?: { - providerName: string; - error: unknown; - }[]; -}; - -/** - * Base strategy to inherit from. Not directly usable, as strategies must implement the "determineResult" method - * Contains default implementations for `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` - */ -export abstract class BaseEvaluationStrategy { - shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, _evalContext: EvaluationContext): boolean { - if ( - strategyContext.providerStatus === ProviderStatus.NOT_READY || - strategyContext.providerStatus === ProviderStatus.FATAL - ) { - return false; - } - return true; - } - - shouldEvaluateNextProvider( - _strategyContext: StrategyPerProviderContext, - _context: EvaluationContext, - _result: ProviderResolutionResult, - ): boolean { - return true; - } - - shouldTrackWithThisProvider( - strategyContext: StrategyProviderContext, - _context: EvaluationContext, - _trackingEventName: string, - _trackingEventDetails: TrackingEventDetails, - ): boolean { - if ( - strategyContext.providerStatus === ProviderStatus.NOT_READY || - strategyContext.providerStatus === ProviderStatus.FATAL - ) { - return false; - } - return true; - } - - abstract determineFinalResult( - strategyContext: StrategyEvaluationContext, - context: EvaluationContext, - resolutions: ProviderResolutionResult[], - ): FinalResult; - - protected hasError(resolution: ProviderResolutionResult): resolution is - | ProviderResolutionErrorResult - | (ProviderResolutionSuccessResult & { - details: ResolutionDetails & { errorCode: ErrorCode }; - }) { - return 'thrownError' in resolution || !!resolution.details.errorCode; - } - - protected hasErrorWithCode(resolution: ProviderResolutionResult, code: ErrorCode): boolean { - return 'thrownError' in resolution - ? (resolution.thrownError as OpenFeatureError)?.code === code - : resolution.details.errorCode === code; - } - - protected collectProviderErrors(resolutions: ProviderResolutionResult[]): FinalResult { - const errors: FinalResult['errors'] = []; - for (const resolution of resolutions) { - if ('thrownError' in resolution) { - errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); - } else if (resolution.details.errorCode) { - errors.push({ - providerName: resolution.providerName, - error: new ErrorWithCode(resolution.details.errorCode, resolution.details.errorMessage ?? 'unknown error'), - }); - } - } - return { errors }; - } - - protected resolutionToFinalResult(resolution: ProviderResolutionSuccessResult) { - return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; - } -} diff --git a/packages/web/src/provider/multi-provider/strategies/first-successful-strategy.ts b/packages/web/src/provider/multi-provider/strategies/first-successful-strategy.ts deleted file mode 100644 index 779eb69d3..000000000 --- a/packages/web/src/provider/multi-provider/strategies/first-successful-strategy.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext } from './base-evaluation-strategy'; -import { BaseEvaluationStrategy } from './base-evaluation-strategy'; -import type { EvaluationContext, FlagValue } from '@openfeature/core'; - -/** - * Return the first result that did NOT result in an error - * If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result - * If there is no successful result, throw all errors - */ -export class FirstSuccessfulStrategy extends BaseEvaluationStrategy { - override shouldEvaluateNextProvider( - strategyContext: StrategyPerProviderContext, - context: EvaluationContext, - result: ProviderResolutionResult, - ): boolean { - // evaluate next only if there was an error - return this.hasError(result); - } - - override determineFinalResult( - strategyContext: StrategyPerProviderContext, - context: EvaluationContext, - resolutions: ProviderResolutionResult[], - ): FinalResult { - const finalResolution = resolutions[resolutions.length - 1]; - if (this.hasError(finalResolution)) { - return this.collectProviderErrors(resolutions); - } - return this.resolutionToFinalResult(finalResolution); - } -} diff --git a/packages/web/src/provider/multi-provider/strategies/index.ts b/packages/web/src/provider/multi-provider/strategies/index.ts deleted file mode 100644 index c611ac485..000000000 --- a/packages/web/src/provider/multi-provider/strategies/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './base-evaluation-strategy'; -export * from './first-match-strategy'; -export * from './first-successful-strategy'; -export * from './comparison-strategy'; diff --git a/packages/web/src/provider/multi-provider/types.ts b/packages/web/src/provider/multi-provider/types.ts deleted file mode 100644 index 1e2747bb3..000000000 --- a/packages/web/src/provider/multi-provider/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Represents an entry in the constructor's provider array which may or may not have a name set -import type { Provider } from '../provider'; - -export type ProviderEntryInput = { - provider: Provider; - name?: string; -}; - -// Represents a processed and "registered" provider entry where a name has been chosen -export type RegisteredProvider = Required; From 847278f7e88ef66d661701c73a89c7d8374c1258 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 30 Sep 2025 10:09:10 -0400 Subject: [PATCH 4/4] fix: resolve TypeScript errors in multi-provider with proper generic types Signed-off-by: Jonathan Norris --- .../provider/multi-provider/multi-provider.ts | 40 +++++---- .../src/provider/multi-provider/errors.ts | 4 +- .../provider/multi-provider/status-tracker.ts | 68 +++++++------- .../strategies/base-evaluation-strategy.ts | 90 +++++++++++-------- .../strategies/comparison-strategy.ts | 20 ++--- .../strategies/first-match-strategy.ts | 12 +-- .../strategies/first-successful-strategy.ts | 15 ++-- .../src/provider/multi-provider/types.ts | 10 +-- .../web/src/provider/multi-provider/README.md | 2 +- .../multi-provider/multi-provider-web.ts | 57 ++++++------ 10 files changed, 177 insertions(+), 141 deletions(-) diff --git a/packages/server/src/provider/multi-provider/multi-provider.ts b/packages/server/src/provider/multi-provider/multi-provider.ts index ea14e1aef..eafc96020 100644 --- a/packages/server/src/provider/multi-provider/multi-provider.ts +++ b/packages/server/src/provider/multi-provider/multi-provider.ts @@ -13,10 +13,10 @@ import type { ProviderMetadata, ResolutionDetails, TrackingEventDetails, - BaseEvaluationStrategy, ProviderResolutionResult, ProviderEntryInput, RegisteredProvider, + BaseEvaluationStrategy, } from '@openfeature/core'; import { DefaultLogger, @@ -29,8 +29,10 @@ import { StatusTracker, } from '@openfeature/core'; import type { Provider } from '../provider'; +import { ProviderStatus } from '../provider'; import type { Hook } from '../../hooks'; import { OpenFeatureEventEmitter } from '../../events/open-feature-event-emitter'; +import { ProviderEvents } from '../../events'; import { HookExecutor } from './hook-executor'; export class MultiProvider implements Provider { @@ -43,15 +45,21 @@ export class MultiProvider implements Provider { metadata: ProviderMetadata; - providerEntries: RegisteredProvider[] = []; - private providerEntriesByName: Record = {}; + providerEntries: RegisteredProvider[] = []; + private providerEntriesByName: Record> = {}; private hookExecutor: HookExecutor; - private statusTracker = new StatusTracker(this.events); + private statusTracker = new StatusTracker< + (typeof ProviderEvents)[keyof typeof ProviderEvents], + ProviderStatus, + Provider + >(this.events, ProviderStatus, ProviderEvents); constructor( - readonly constructorProviders: ProviderEntryInput[], - private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy(), + readonly constructorProviders: ProviderEntryInput[], + private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy( + ProviderStatus, + ), private readonly logger: Logger = new DefaultLogger(), ) { this.hookExecutor = new HookExecutor(this.logger); @@ -68,7 +76,7 @@ export class MultiProvider implements Provider { }; } - private registerProviders(constructorProviders: ProviderEntryInput[]) { + private registerProviders(constructorProviders: ProviderEntryInput[]) { const providersByName: Record = {}; for (const constructorProvider of constructorProviders) { @@ -155,7 +163,7 @@ export class MultiProvider implements Provider { throw new GeneralError('Hook context not available for evaluation'); } - const tasks: Promise<[boolean, ProviderResolutionResult | null]>[] = []; + const tasks: Promise<[boolean, ProviderResolutionResult | null]>[] = []; for (const providerEntry of this.providerEntries) { const task = this.evaluateProviderEntry( @@ -181,7 +189,7 @@ export class MultiProvider implements Provider { const results = await Promise.all(tasks); const resolutions = results .map(([, resolution]) => resolution) - .filter((r): r is ProviderResolutionResult => Boolean(r)); + .filter((r): r is ProviderResolutionResult => Boolean(r)); const finalResult = this.evaluationStrategy.determineFinalResult({ flagKey, flagType }, context, resolutions); @@ -200,17 +208,17 @@ export class MultiProvider implements Provider { flagKey: string, flagType: FlagValueType, defaultValue: T, - providerEntry: RegisteredProvider, + providerEntry: RegisteredProvider, hookContext: HookContext, hookHints: HookHints, context: EvaluationContext, - ): Promise<[boolean, ProviderResolutionResult | null]> { + ): Promise<[boolean, ProviderResolutionResult | null]> { let evaluationResult: ResolutionDetails | undefined = undefined; const provider = providerEntry.provider; const strategyContext = { flagKey, flagType, - provider, + provider: provider as Provider, providerName: providerEntry.name, providerStatus: this.statusTracker.providerStatus(providerEntry.name), }; @@ -219,19 +227,19 @@ export class MultiProvider implements Provider { return [true, null]; } - let resolution: ProviderResolutionResult; + let resolution: ProviderResolutionResult; try { evaluationResult = await this.evaluateProviderAndHooks(flagKey, defaultValue, provider, hookContext, hookHints); resolution = { details: evaluationResult, - provider: provider, + provider: provider as Provider, providerName: providerEntry.name, }; } catch (error: unknown) { resolution = { thrownError: error, - provider: provider, + provider: provider as Provider, providerName: providerEntry.name, }; } @@ -322,7 +330,7 @@ export class MultiProvider implements Provider { } const strategyContext = { - provider: providerEntry.provider, + provider: providerEntry.provider as Provider, providerName: providerEntry.name, providerStatus: this.statusTracker.providerStatus(providerEntry.name), }; diff --git a/packages/shared/src/provider/multi-provider/errors.ts b/packages/shared/src/provider/multi-provider/errors.ts index de726212c..6637c445f 100644 --- a/packages/shared/src/provider/multi-provider/errors.ts +++ b/packages/shared/src/provider/multi-provider/errors.ts @@ -34,9 +34,9 @@ export const constructAggregateError = (providerErrors: { error: unknown; provid ); }; -export const throwAggregateErrorFromPromiseResults = ( +export const throwAggregateErrorFromPromiseResults = ( result: PromiseSettledResult[], - providerEntries: RegisteredProvider[], + providerEntries: RegisteredProvider[], ) => { const errors = result .map((r, i) => { diff --git a/packages/shared/src/provider/multi-provider/status-tracker.ts b/packages/shared/src/provider/multi-provider/status-tracker.ts index 1d31d2925..d3c3c545e 100644 --- a/packages/shared/src/provider/multi-provider/status-tracker.ts +++ b/packages/shared/src/provider/multi-provider/status-tracker.ts @@ -1,33 +1,39 @@ -import type { EventDetails, ProviderEventEmitter } from '../../events'; -import { AllProviderEvents } from '../../events'; -import { AllProviderStatus } from '../../provider'; +import type { EventDetails, ProviderEventEmitter, AnyProviderEvent } from '../../events'; import type { RegisteredProvider } from './types'; /** * Tracks each individual provider's status by listening to emitted events * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers */ -export class StatusTracker { - private readonly providerStatuses: Record = {}; +export class StatusTracker< + TProviderEvents extends AnyProviderEvent, + TProviderStatus, + TProvider extends { events?: ProviderEventEmitter }, +> { + private readonly providerStatuses: Record = {}; - constructor(private events: ProviderEventEmitter) {} + constructor( + private events: ProviderEventEmitter, + private statusEnum: Record, + private eventEnum: Record, + ) {} - wrapEventHandler(providerEntry: RegisteredProvider) { + wrapEventHandler(providerEntry: RegisteredProvider) { const provider = providerEntry.provider; - provider.events?.addHandler(AllProviderEvents.Error, (details?: EventDetails) => { - this.changeProviderStatus(providerEntry.name, AllProviderStatus.ERROR, details); + provider.events?.addHandler(this.eventEnum.Error as TProviderEvents, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.ERROR, details); }); - provider.events?.addHandler(AllProviderEvents.Stale, (details?: EventDetails) => { - this.changeProviderStatus(providerEntry.name, AllProviderStatus.STALE, details); + provider.events?.addHandler(this.eventEnum.Stale as TProviderEvents, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.STALE, details); }); - provider.events?.addHandler(AllProviderEvents.ConfigurationChanged, (details?: EventDetails) => { - this.events.emit(AllProviderEvents.ConfigurationChanged, details); + provider.events?.addHandler(this.eventEnum.ConfigurationChanged as TProviderEvents, (details?: EventDetails) => { + this.events.emit(this.eventEnum.ConfigurationChanged as TProviderEvents, details); }); - provider.events?.addHandler(AllProviderEvents.Ready, (details?: EventDetails) => { - this.changeProviderStatus(providerEntry.name, AllProviderStatus.READY, details); + provider.events?.addHandler(this.eventEnum.Ready as TProviderEvents, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.READY, details); }); } @@ -37,29 +43,29 @@ export class StatusTracker { private getStatusFromProviderStatuses() { const statuses = Object.values(this.providerStatuses); - if (statuses.includes(AllProviderStatus.FATAL)) { - return AllProviderStatus.FATAL; - } else if (statuses.includes(AllProviderStatus.NOT_READY)) { - return AllProviderStatus.NOT_READY; - } else if (statuses.includes(AllProviderStatus.ERROR)) { - return AllProviderStatus.ERROR; - } else if (statuses.includes(AllProviderStatus.STALE)) { - return AllProviderStatus.STALE; + if (statuses.includes(this.statusEnum.FATAL)) { + return this.statusEnum.FATAL; + } else if (statuses.includes(this.statusEnum.NOT_READY)) { + return this.statusEnum.NOT_READY; + } else if (statuses.includes(this.statusEnum.ERROR)) { + return this.statusEnum.ERROR; + } else if (statuses.includes(this.statusEnum.STALE)) { + return this.statusEnum.STALE; } - return AllProviderStatus.READY; + return this.statusEnum.READY; } - private changeProviderStatus(name: string, status: AllProviderStatus, details?: EventDetails) { + private changeProviderStatus(name: string, status: TProviderStatus, details?: EventDetails) { const currentStatus = this.getStatusFromProviderStatuses(); this.providerStatuses[name] = status; const newStatus = this.getStatusFromProviderStatuses(); if (currentStatus !== newStatus) { - if (newStatus === AllProviderStatus.FATAL || newStatus === AllProviderStatus.ERROR) { - this.events.emit(AllProviderEvents.Error, details); - } else if (newStatus === AllProviderStatus.STALE) { - this.events.emit(AllProviderEvents.Stale, details); - } else if (newStatus === AllProviderStatus.READY) { - this.events.emit(AllProviderEvents.Ready, details); + if (newStatus === this.statusEnum.FATAL || newStatus === this.statusEnum.ERROR) { + this.events.emit(this.eventEnum.Error as TProviderEvents, details); + } else if (newStatus === this.statusEnum.STALE) { + this.events.emit(this.eventEnum.Stale as TProviderEvents, details); + } else if (newStatus === this.statusEnum.READY) { + this.events.emit(this.eventEnum.Ready as TProviderEvents, details); } } } diff --git a/packages/shared/src/provider/multi-provider/strategies/base-evaluation-strategy.ts b/packages/shared/src/provider/multi-provider/strategies/base-evaluation-strategy.ts index 057cb1504..beb2b2ffe 100644 --- a/packages/shared/src/provider/multi-provider/strategies/base-evaluation-strategy.ts +++ b/packages/shared/src/provider/multi-provider/strategies/base-evaluation-strategy.ts @@ -1,7 +1,5 @@ import type { ErrorCode, EvaluationContext, FlagValue, FlagValueType, ResolutionDetails } from '../../../evaluation'; import type { OpenFeatureError } from '../../../errors'; -import type { CommonProvider } from '../../../provider'; -import { AllProviderStatus } from '../../../provider'; import { ErrorWithCode } from '../errors'; import type { TrackingEventDetails } from '../../../tracking'; @@ -10,34 +8,42 @@ export type StrategyEvaluationContext = { flagType: FlagValueType; }; -export type StrategyProviderContext = { - provider: CommonProvider; +export type StrategyProviderContext = { + provider: TProvider; providerName: string; - providerStatus: AllProviderStatus; + providerStatus: TProviderStatus; }; -export type StrategyPerProviderContext = StrategyEvaluationContext & StrategyProviderContext; +export type StrategyPerProviderContext = StrategyEvaluationContext & + StrategyProviderContext; -type ProviderResolutionResultBase = { - provider: CommonProvider; +type ProviderResolutionResultBase = { + provider: TProvider; providerName: string; }; -export type ProviderResolutionSuccessResult = ProviderResolutionResultBase & { +export type ProviderResolutionSuccessResult< + T extends FlagValue, + TProviderStatus, + TProvider, +> = ProviderResolutionResultBase & { details: ResolutionDetails; }; -export type ProviderResolutionErrorResult = ProviderResolutionResultBase & { +export type ProviderResolutionErrorResult = ProviderResolutionResultBase< + TProviderStatus, + TProvider +> & { thrownError: unknown; }; -export type ProviderResolutionResult = - | ProviderResolutionSuccessResult - | ProviderResolutionErrorResult; +export type ProviderResolutionResult = + | ProviderResolutionSuccessResult + | ProviderResolutionErrorResult; -export type FinalResult = { +export type FinalResult = { details?: ResolutionDetails; - provider?: CommonProvider; + provider?: TProvider; providerName?: string; errors?: { providerName: string; @@ -45,13 +51,18 @@ export type FinalResult = { }[]; }; -export abstract class BaseEvaluationStrategy { +export abstract class BaseEvaluationStrategy { public runMode: 'parallel' | 'sequential' = 'sequential'; - shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, _evalContext?: EvaluationContext): boolean { + constructor(protected statusEnum: Record) {} + + shouldEvaluateThisProvider( + strategyContext: StrategyPerProviderContext, + _evalContext?: EvaluationContext, + ): boolean { if ( - strategyContext.providerStatus === AllProviderStatus.NOT_READY || - strategyContext.providerStatus === AllProviderStatus.FATAL + strategyContext.providerStatus === this.statusEnum.NOT_READY || + strategyContext.providerStatus === this.statusEnum.FATAL ) { return false; } @@ -59,22 +70,22 @@ export abstract class BaseEvaluationStrategy { } shouldEvaluateNextProvider( - _strategyContext?: StrategyPerProviderContext, + _strategyContext?: StrategyPerProviderContext, _context?: EvaluationContext, - _result?: ProviderResolutionResult, + _result?: ProviderResolutionResult, ): boolean { return true; } shouldTrackWithThisProvider( - strategyContext: StrategyProviderContext, + strategyContext: StrategyProviderContext, _context?: EvaluationContext, _trackingEventName?: string, _trackingEventDetails?: TrackingEventDetails, ): boolean { if ( - strategyContext.providerStatus === AllProviderStatus.NOT_READY || - strategyContext.providerStatus === AllProviderStatus.FATAL + strategyContext.providerStatus === this.statusEnum.NOT_READY || + strategyContext.providerStatus === this.statusEnum.FATAL ) { return false; } @@ -84,25 +95,32 @@ export abstract class BaseEvaluationStrategy { abstract determineFinalResult( strategyContext: StrategyEvaluationContext, context: EvaluationContext, - resolutions: ProviderResolutionResult[], - ): FinalResult; - - protected hasError(resolution: ProviderResolutionResult): resolution is - | ProviderResolutionErrorResult - | (ProviderResolutionSuccessResult & { - details: ResolutionDetails & { errorCode: ErrorCode }; + resolutions: ProviderResolutionResult[], + ): FinalResult; + + protected hasError( + resolution: ProviderResolutionResult, + ): resolution is + | ProviderResolutionErrorResult + | (ProviderResolutionSuccessResult & { + details: ResolutionDetails & { errorCode: ErrorCode }; }) { return 'thrownError' in resolution || !!resolution.details.errorCode; } - protected hasErrorWithCode(resolution: ProviderResolutionResult, code: ErrorCode): boolean { + protected hasErrorWithCode( + resolution: ProviderResolutionResult, + code: ErrorCode, + ): boolean { return 'thrownError' in resolution ? (resolution.thrownError as OpenFeatureError)?.code === code : resolution.details.errorCode === code; } - protected collectProviderErrors(resolutions: ProviderResolutionResult[]): FinalResult { - const errors: FinalResult['errors'] = []; + protected collectProviderErrors( + resolutions: ProviderResolutionResult[], + ): FinalResult { + const errors: FinalResult['errors'] = []; for (const resolution of resolutions) { if ('thrownError' in resolution) { errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); @@ -116,7 +134,9 @@ export abstract class BaseEvaluationStrategy { return { errors }; } - protected resolutionToFinalResult(resolution: ProviderResolutionSuccessResult) { + protected resolutionToFinalResult( + resolution: ProviderResolutionSuccessResult, + ) { return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; } } diff --git a/packages/shared/src/provider/multi-provider/strategies/comparison-strategy.ts b/packages/shared/src/provider/multi-provider/strategies/comparison-strategy.ts index 1c5af3684..791f07009 100644 --- a/packages/shared/src/provider/multi-provider/strategies/comparison-strategy.ts +++ b/packages/shared/src/provider/multi-provider/strategies/comparison-strategy.ts @@ -6,27 +6,27 @@ import type { } from './base-evaluation-strategy'; import { BaseEvaluationStrategy } from './base-evaluation-strategy'; import type { EvaluationContext, FlagValue } from '../../../evaluation'; -import type { CommonProvider, AllProviderStatus } from '../../../provider'; import { GeneralError } from '../../../errors'; -export class ComparisonStrategy extends BaseEvaluationStrategy { +export class ComparisonStrategy extends BaseEvaluationStrategy { override runMode = 'parallel' as const; constructor( - private fallbackProvider: CommonProvider, - private onMismatch?: (resolutions: ProviderResolutionResult[]) => void, + statusEnum: Record, + private fallbackProvider: TProvider, + private onMismatch?: (resolutions: ProviderResolutionResult[]) => void, ) { - super(); + super(statusEnum); } override determineFinalResult( - strategyContext: StrategyPerProviderContext, + strategyContext: StrategyPerProviderContext, context: EvaluationContext, - resolutions: ProviderResolutionResult[], - ): FinalResult { + resolutions: ProviderResolutionResult[], + ): FinalResult { let value: T | undefined; - let fallbackResolution: ProviderResolutionSuccessResult | undefined; - let finalResolution: ProviderResolutionSuccessResult | undefined; + let fallbackResolution: ProviderResolutionSuccessResult | undefined; + let finalResolution: ProviderResolutionSuccessResult | undefined; let mismatch = false; for (const [i, resolution] of resolutions.entries()) { if (this.hasError(resolution)) { diff --git a/packages/shared/src/provider/multi-provider/strategies/first-match-strategy.ts b/packages/shared/src/provider/multi-provider/strategies/first-match-strategy.ts index b5a00a3c8..0cf8e31bb 100644 --- a/packages/shared/src/provider/multi-provider/strategies/first-match-strategy.ts +++ b/packages/shared/src/provider/multi-provider/strategies/first-match-strategy.ts @@ -3,11 +3,11 @@ import { BaseEvaluationStrategy } from './base-evaluation-strategy'; import type { EvaluationContext, FlagValue } from '../../../evaluation'; import { ErrorCode } from '../../../evaluation'; -export class FirstMatchStrategy extends BaseEvaluationStrategy { +export class FirstMatchStrategy extends BaseEvaluationStrategy { override shouldEvaluateNextProvider( - strategyContext: StrategyPerProviderContext, + strategyContext: StrategyPerProviderContext, context: EvaluationContext, - result: ProviderResolutionResult, + result: ProviderResolutionResult, ): boolean { if (this.hasErrorWithCode(result, ErrorCode.FLAG_NOT_FOUND)) { return true; @@ -19,10 +19,10 @@ export class FirstMatchStrategy extends BaseEvaluationStrategy { } override determineFinalResult( - strategyContext: StrategyPerProviderContext, + strategyContext: StrategyPerProviderContext, context: EvaluationContext, - resolutions: ProviderResolutionResult[], - ): FinalResult { + resolutions: ProviderResolutionResult[], + ): FinalResult { const finalResolution = resolutions[resolutions.length - 1]; if (this.hasError(finalResolution)) { return this.collectProviderErrors(resolutions); diff --git a/packages/shared/src/provider/multi-provider/strategies/first-successful-strategy.ts b/packages/shared/src/provider/multi-provider/strategies/first-successful-strategy.ts index 9355e7335..e9df8b2da 100644 --- a/packages/shared/src/provider/multi-provider/strategies/first-successful-strategy.ts +++ b/packages/shared/src/provider/multi-provider/strategies/first-successful-strategy.ts @@ -2,20 +2,23 @@ import type { FinalResult, ProviderResolutionResult, StrategyPerProviderContext import { BaseEvaluationStrategy } from './base-evaluation-strategy'; import type { EvaluationContext, FlagValue } from '../../../evaluation'; -export class FirstSuccessfulStrategy extends BaseEvaluationStrategy { +export class FirstSuccessfulStrategy extends BaseEvaluationStrategy< + TProviderStatus, + TProvider +> { override shouldEvaluateNextProvider( - strategyContext: StrategyPerProviderContext, + strategyContext: StrategyPerProviderContext, context: EvaluationContext, - result: ProviderResolutionResult, + result: ProviderResolutionResult, ): boolean { return this.hasError(result); } override determineFinalResult( - strategyContext: StrategyPerProviderContext, + strategyContext: StrategyPerProviderContext, context: EvaluationContext, - resolutions: ProviderResolutionResult[], - ): FinalResult { + resolutions: ProviderResolutionResult[], + ): FinalResult { const finalResolution = resolutions[resolutions.length - 1]; if (this.hasError(finalResolution)) { return this.collectProviderErrors(resolutions); diff --git a/packages/shared/src/provider/multi-provider/types.ts b/packages/shared/src/provider/multi-provider/types.ts index 2a0622817..96137f3e5 100644 --- a/packages/shared/src/provider/multi-provider/types.ts +++ b/packages/shared/src/provider/multi-provider/types.ts @@ -1,12 +1,6 @@ -import type { AllProviderStatus, CommonProvider } from '../../provider'; - -export type ProviderEntryInput< - TProvider extends CommonProvider = CommonProvider, -> = { +export type ProviderEntryInput = { provider: TProvider; name?: string; }; -export type RegisteredProvider< - TProvider extends CommonProvider = CommonProvider, -> = Required>; +export type RegisteredProvider = Required>; diff --git a/packages/web/src/provider/multi-provider/README.md b/packages/web/src/provider/multi-provider/README.md index fdbb56914..7d2c1c910 100644 --- a/packages/web/src/provider/multi-provider/README.md +++ b/packages/web/src/provider/multi-provider/README.md @@ -9,7 +9,7 @@ feature flagging interface. For example: - *Migration*: When migrating between two providers, you can run both in parallel under a unified flagging interface. As flags are added to the new provider, the Multi-Provider will automatically find and return them, falling back to the old provider if the new provider does not have -- *Multiple Data Sources*: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables, +- *Multiple Data Sources*: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables, local files, database values and SaaS hosted feature management systems. ## Usage diff --git a/packages/web/src/provider/multi-provider/multi-provider-web.ts b/packages/web/src/provider/multi-provider/multi-provider-web.ts index a8af8d8b5..c1b469301 100644 --- a/packages/web/src/provider/multi-provider/multi-provider-web.ts +++ b/packages/web/src/provider/multi-provider/multi-provider-web.ts @@ -13,18 +13,20 @@ import type { FlagValue, OpenFeatureError, TrackingEventDetails, + ProviderEntryInput, + ProviderResolutionResult, + RegisteredProvider, + BaseEvaluationStrategy, } from '@openfeature/core'; import type { Provider } from '../../provider'; +import { ProviderStatus } from '../../provider'; import type { Hook } from '../../hooks'; -import { OpenFeatureEventEmitter } from '../../events'; -import { DefaultLogger, GeneralError, ErrorCode, StandardResolutionReasons } from '@openfeature/core'; -import type { - BaseEvaluationStrategy, - ProviderEntryInput as CoreProviderEntryInput, - ProviderResolutionResult, - RegisteredProvider as CoreRegisteredProvider, -} from '@openfeature/core'; +import { OpenFeatureEventEmitter, ProviderEvents } from '../../events'; import { + DefaultLogger, + GeneralError, + ErrorCode, + StandardResolutionReasons, constructAggregateError, FirstMatchStrategy, StatusTracker, @@ -32,9 +34,6 @@ import { } from '@openfeature/core'; import { HookExecutor } from './hook-executor'; -type ProviderEntry = CoreProviderEntryInput; -type RegisteredProvider = CoreRegisteredProvider; - export class MultiProvider implements Provider { readonly runsOn = 'client'; @@ -45,15 +44,21 @@ export class MultiProvider implements Provider { metadata: ProviderMetadata; - providerEntries: RegisteredProvider[] = []; - private providerEntriesByName: Record = {}; + providerEntries: RegisteredProvider[] = []; + private providerEntriesByName: Record> = {}; private hookExecutor: HookExecutor; - private statusTracker = new StatusTracker(this.events); + private statusTracker = new StatusTracker< + (typeof ProviderEvents)[keyof typeof ProviderEvents], + ProviderStatus, + Provider + >(this.events, ProviderStatus, ProviderEvents); constructor( - readonly constructorProviders: ProviderEntry[], - private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy(), + readonly constructorProviders: ProviderEntryInput[], + private readonly evaluationStrategy: BaseEvaluationStrategy = new FirstMatchStrategy( + ProviderStatus, + ), private readonly logger: Logger = new DefaultLogger(), ) { this.hookExecutor = new HookExecutor(this.logger); @@ -70,7 +75,7 @@ export class MultiProvider implements Provider { }; } - private registerProviders(constructorProviders: ProviderEntry[]) { + private registerProviders(constructorProviders: ProviderEntryInput[]) { const providersByName: Record = {}; for (const constructorProvider of constructorProviders) { @@ -157,7 +162,7 @@ export class MultiProvider implements Provider { } const strategyContext = { - provider: providerEntry.provider, + provider: providerEntry.provider as Provider, providerName: providerEntry.name, providerStatus: this.statusTracker.providerStatus(providerEntry.name), }; @@ -195,7 +200,7 @@ export class MultiProvider implements Provider { throw new GeneralError('Hook context not available for evaluation'); } - const results = [] as (ProviderResolutionResult | null)[]; + const results = [] as (ProviderResolutionResult | null)[]; for (const providerEntry of this.providerEntries) { const [shouldEvaluateNext, result] = this.evaluateProviderEntry( @@ -215,7 +220,7 @@ export class MultiProvider implements Provider { } } - const resolutions = results.filter((r): r is ProviderResolutionResult => Boolean(r)); + const resolutions = results.filter((r): r is ProviderResolutionResult => Boolean(r)); const finalResult = this.evaluationStrategy.determineFinalResult({ flagKey, flagType }, context, resolutions); if (finalResult.errors?.length) { @@ -233,17 +238,17 @@ export class MultiProvider implements Provider { flagKey: string, flagType: FlagValueType, defaultValue: T, - providerEntry: RegisteredProvider, + providerEntry: RegisteredProvider, hookContext: HookContext, hookHints: HookHints, context: EvaluationContext, - ): [boolean, ProviderResolutionResult | null] { + ): [boolean, ProviderResolutionResult | null] { let evaluationResult: ResolutionDetails | undefined = undefined; const provider = providerEntry.provider; const strategyContext = { flagKey, flagType, - provider, + provider: provider as Provider, providerName: providerEntry.name, providerStatus: this.statusTracker.providerStatus(providerEntry.name), }; @@ -252,19 +257,19 @@ export class MultiProvider implements Provider { return [true, null]; } - let resolution: ProviderResolutionResult; + let resolution: ProviderResolutionResult; try { evaluationResult = this.evaluateProviderAndHooks(flagKey, defaultValue, provider, hookContext, hookHints); resolution = { details: evaluationResult, - provider: provider, + provider: provider as Provider, providerName: providerEntry.name, }; } catch (error: unknown) { resolution = { thrownError: error, - provider: provider, + provider: provider as Provider, providerName: providerEntry.name, }; }