diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 4fcab326..8c06b57c 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1,6 +1,10 @@ import fs from "fs"; -import { evaluateFeatureRules, flattenJSON } from "@bucketco/flag-evaluation"; +import { + EvaluationResult, + flattenJSON, + newEvaluator, +} from "@bucketco/flag-evaluation"; import BatchBuffer from "./batch-buffer"; import { @@ -19,15 +23,14 @@ import inRequestCache from "./inRequestCache"; import periodicallyUpdatingCache from "./periodicallyUpdatingCache"; import { newRateLimiter } from "./rate-limiter"; import type { + CachedFeatureDefinition, CacheStrategy, EvaluatedFeaturesAPIResponse, - FeatureAPIResponse, FeatureDefinition, FeatureOverrides, FeatureOverridesFn, IdType, RawFeature, - RawFeatureRemoteConfig, } from "./types"; import { Attributes, @@ -127,7 +130,7 @@ export class BucketClient { }; httpClient: HttpClient; - private featuresCache: Cache; + private featuresCache: Cache; private batchBuffer: BatchBuffer; private rateLimiter: ReturnType; @@ -334,18 +337,40 @@ export class BucketClient { this.logger.warn("features cache: invalid response", res); return undefined; } - return res; + + return res.features.map((featureDef) => { + return { + ...featureDef, + enabledEvaluator: newEvaluator( + featureDef.targeting.rules.map((rule) => ({ + filter: rule.filter, + value: true, + })), + ), + configEvaluator: featureDef.config + ? newEvaluator( + featureDef.config?.variants.map((variant) => ({ + filter: variant.filter, + value: { + key: variant.key, + payload: variant.payload, + }, + })), + ) + : undefined, + } satisfies CachedFeatureDefinition; + }); }; if (this._config.cacheStrategy === "periodically-update") { - this.featuresCache = periodicallyUpdatingCache( + this.featuresCache = periodicallyUpdatingCache( this._config.refetchInterval, this._config.staleWarningInterval, this.logger, fetchFeatures, ); } else { - this.featuresCache = inRequestCache( + this.featuresCache = inRequestCache( this._config.refetchInterval, this.logger, fetchFeatures, @@ -582,7 +607,7 @@ export class BucketClient { * @returns The features definitions. */ public async getFeatureDefinitions(): Promise { - const features = this.featuresCache.get()?.features || []; + const features = this.featuresCache.get() || []; return features.map((f) => ({ key: f.key, description: f.description, @@ -1022,72 +1047,46 @@ export class BucketClient { } void this.syncContext(options); - let featureDefinitions: FeaturesAPIResponse["features"]; + let featureDefinitions: CachedFeatureDefinition[] = []; - if (this._config.offline) { - featureDefinitions = []; - } else { - const fetchedFeatures = this.featuresCache.get(); - if (!fetchedFeatures) { + if (!this._config.offline) { + const featureDefs = this.featuresCache.get(); + if (!featureDefs) { this.logger.warn( "no feature definitions available, using fallback features.", ); return this._config.fallbackFeatures || {}; } - - featureDefinitions = fetchedFeatures.features; + featureDefinitions = featureDefs; } - const featureMap = featureDefinitions.reduce( - (acc, f) => { - acc[f.key] = f; - return acc; - }, - {} as Record, - ); - const { enableTracking = true, meta: _, ...context } = options; - const evaluated = featureDefinitions.map((feature) => - evaluateFeatureRules({ - featureKey: feature.key, - rules: feature.targeting.rules.map((r) => ({ ...r, value: true })), - context, - }), - ); - - const evaluatedConfigs = evaluated.reduce( - (acc, { featureKey }) => { - const feature = featureMap[featureKey]; - if (feature.config) { - const variant = evaluateFeatureRules({ - featureKey, - rules: feature.config.variants.map(({ filter, ...rest }) => ({ - filter, - value: rest, - })), - context, - }); - - if (variant.value) { - acc[featureKey] = { - ...variant.value, - targetingVersion: feature.config.version, - ruleEvaluationResults: variant.ruleEvaluationResults, - missingContextFields: variant.missingContextFields, - }; - } - } - return acc; - }, - {} as Record, - ); + const evaluated = featureDefinitions.map((feature) => ({ + featureKey: feature.key, + targetingVersion: feature.targeting.version, + configVersion: feature.config?.version, + enabledResult: feature.enabledEvaluator(context, feature.key), + configResult: + feature.configEvaluator?.(context, feature.key) ?? + ({ + featureKey: feature.key, + context, + value: undefined, + ruleEvaluationResults: [], + missingContextFields: [], + } satisfies EvaluationResult), + })); this.warnMissingFeatureContextFields( context, - evaluated.map(({ featureKey, missingContextFields }) => ({ + evaluated.map(({ featureKey, enabledResult, configResult }) => ({ key: featureKey, - missingContextFields, + missingContextFields: enabledResult.missingContextFields ?? [], + config: { + key: configResult.value, + missingContextFields: configResult.missingContextFields ?? [], + }, })), ); @@ -1099,23 +1098,23 @@ export class BucketClient { this.sendFeatureEvent({ action: "evaluate", key: res.featureKey, - targetingVersion: featureMap[res.featureKey].targeting.version, - evalResult: res.value ?? false, - evalContext: res.context, - evalRuleResults: res.ruleEvaluationResults, - evalMissingFields: res.missingContextFields, + targetingVersion: res.targetingVersion, + evalResult: res.enabledResult.value ?? false, + evalContext: res.enabledResult.context, + evalRuleResults: res.enabledResult.ruleEvaluationResults, + evalMissingFields: res.enabledResult.missingContextFields, }), ); - const config = evaluatedConfigs[res.featureKey]; - if (config) { + const config = res.configResult; + if (config.value) { outPromises.push( this.sendFeatureEvent({ action: "evaluate-config", key: res.featureKey, - targetingVersion: config.targetingVersion, - evalResult: { key: config.key, payload: config.payload }, - evalContext: res.context, + targetingVersion: res.configVersion, + evalResult: config.value, + evalContext: config.context, evalRuleResults: config.ruleEvaluationResults, evalMissingFields: config.missingContextFields, }), @@ -1144,11 +1143,17 @@ export class BucketClient { (acc, res) => { acc[res.featureKey as keyof TypedFeatures] = { key: res.featureKey, - isEnabled: res.value ?? false, - config: evaluatedConfigs[res.featureKey], - ruleEvaluationResults: res.ruleEvaluationResults, - missingContextFields: res.missingContextFields, - targetingVersion: featureMap[res.featureKey].targeting.version, + isEnabled: res.enabledResult.value ?? false, + ruleEvaluationResults: res.enabledResult.ruleEvaluationResults, + missingContextFields: res.enabledResult.missingContextFields, + targetingVersion: res.targetingVersion, + config: { + key: res.configResult?.value?.key, + payload: res.configResult?.value?.payload, + targetingVersion: res.configVersion, + ruleEvaluationResults: res.configResult?.ruleEvaluationResults, + missingContextFields: res.configResult?.missingContextFields, + }, }; return acc; }, diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 1acd4fa3..f9005d6d 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ -import { RuleFilter } from "@bucketco/flag-evaluation"; +import { newEvaluator, RuleFilter } from "@bucketco/flag-evaluation"; /** * Describes the meta context associated with tracking. @@ -365,6 +365,17 @@ export type FeaturesAPIResponse = { features: FeatureAPIResponse[]; }; +/** + * (Internal) Feature definitions with the addition of a pre-prepared + * evaluators functions for the rules. + * + * @internal + */ +export type CachedFeatureDefinition = FeatureAPIResponse & { + enabledEvaluator: ReturnType>; + configEvaluator: ReturnType> | undefined; +}; + /** * (Internal) Describes the response of the evaluated features endpoint. * diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 46e86936..5927a1ff 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -10,7 +10,7 @@ import { vi, } from "vitest"; -import { evaluateFeatureRules, flattenJSON } from "@bucketco/flag-evaluation"; +import { flattenJSON } from "@bucketco/flag-evaluation"; import { BoundBucketClient, BucketClient } from "../src"; import { @@ -30,15 +30,6 @@ import { ClientOptions, Context, FeaturesAPIResponse } from "../src/types"; const BULK_ENDPOINT = "https://api.example.com/bulk"; -vi.mock("@bucketco/flag-evaluation", async (importOriginal) => { - const original = (await importOriginal()) as any; - - return { - ...original, - evaluateFeatureRules: vi.fn(original.evaluateFeatureRules), - }; -}); - vi.mock("../src/rate-limiter", async (importOriginal) => { const original = (await importOriginal()) as any; @@ -1370,7 +1361,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); }); @@ -1427,7 +1417,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); }); @@ -1458,7 +1447,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); }); @@ -1489,7 +1477,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).not.toHaveBeenCalled(); }); @@ -1517,7 +1504,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); });