From fc5fe4c00c4e136c39a8ddcaf42eeded95d0c38b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 07:15:27 +0200 Subject: [PATCH 01/18] feat: add protocol-v2 multivariate flag evaluation --- .changeset/multivariate-evaluation.md | 12 + packages/flag-evaluation/src/index.ts | 176 +++++++++ .../flag-evaluation/test/variants.test.ts | 341 ++++++++++++++++++ 3 files changed, 529 insertions(+) create mode 100644 .changeset/multivariate-evaluation.md create mode 100644 packages/flag-evaluation/test/variants.test.ts diff --git a/.changeset/multivariate-evaluation.md b/.changeset/multivariate-evaluation.md new file mode 100644 index 000000000..dbe1c3431 --- /dev/null +++ b/.changeset/multivariate-evaluation.md @@ -0,0 +1,12 @@ +--- +"@reflag/flag-evaluation": major +--- + +Add protocol-v2 `CompiledFlag` evaluation via `evaluateFlag(flag, context)`: + +- Return any JSON variant value, including `null`, arrays, and booleans. +- Evaluate rules sequentially with fixed variants, percentage distributions, and explicit fallthrough. +- Preserve the existing hash function and legacy rollout filters; assign the inclusive maximum distribution bucket to the final allocation. +- Return source version, selected variant, matched rule, allocation, and per-rule diagnostics for exposure events and debugging. + +Existing `evaluateFlagRules` and `newEvaluator` exports remain available for legacy consumers. SDK adoption of `/flags` and variant-valued public APIs is a separate change. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 33c69e891..4c9bdf73c 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -734,3 +734,179 @@ export function newEvaluator(rules: Rule[]) { }); }; } + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export type VariantDestination = + | { type: "variant"; variantKey: string } + | { type: "nextRule" }; + +export type RuleResult = + | { type: "variant"; variantKey: string } + | { + type: "percentageDistribution"; + key: string; + attribute: string; + allocations: Array<{ + percentage: number; + destination: VariantDestination; + }>; + }; + +export type CompiledRule = { + id: string; + filter: RuleFilter; + result: RuleResult; +}; + +export type CompiledFlag = { + key: string; + sourceVersionId: string; + variants: Record; + rules: CompiledRule[]; +}; + +export type VariantEvaluationError = + | EvaluationError + | { code: "INVALID_FLAG_DEFINITION"; message: string } + | { + code: "UNSUPPORTED_ARRAY_OPERATOR"; + field: string; + operator: "percentageDistribution"; + message: string; + }; + +export type VariantEvaluationResult = { + flagKey: string; + sourceVersionId: string; + value: JsonValue | undefined; + variantKey?: string; + matchedRuleId?: string; + allocationIndex?: number; + context: FlattenedContext; + ruleResults: Array<{ + ruleId: string; + matched: boolean; + allocationIndex?: number; + destination?: VariantDestination; + }>; + errors: VariantEvaluationError[]; +}; + +/** Evaluate protocol-v2 rules sequentially, stopping at the first selected variant. + * Legacy evaluateFlagRules/newEvaluator retain their existing behavior. + */ +export function evaluateFlag( + flag: CompiledFlag, + context: Record, +): VariantEvaluationResult { + const flatContext = flattenContext(context); + const errors = new Map(); + const result: VariantEvaluationResult = { + flagKey: flag.key, + sourceVersionId: flag.sourceVersionId, + value: undefined, + context: flatContext, + ruleResults: [], + errors: [], + }; + const finish = () => ({ ...result, errors: Array.from(errors.values()) }); + const invalid = (message: string) => { + errors.set("invalid", { code: "INVALID_FLAG_DEFINITION", message }); + return finish(); + }; + + for (const rule of flag.rules) { + const filterErrors = new Map(); + const matched = evaluateRecursively(rule.filter, flatContext, filterErrors); + for (const [key, error] of filterErrors) errors.set(key, error); + const ruleResult: VariantEvaluationResult["ruleResults"][number] = { + ruleId: rule.id, + matched: matched && filterErrors.size === 0, + }; + result.ruleResults.push(ruleResult); + if (!ruleResult.matched) continue; + + let destination: VariantDestination; + if (rule.result.type === "percentageDistribution") { + const distribution = rule.result; + const units = distribution.allocations.map(({ percentage }) => + Math.round(percentage * 1000), + ); + if ( + !units.length || + distribution.allocations.some( + ({ percentage }, index) => + !Number.isFinite(percentage) || + percentage < 0 || + percentage > 100 || + Math.abs(percentage * 1000 - units[index]) > 1e-8, + ) || + units.reduce((total, percentage) => total + percentage, 0) !== 100000 + ) { + return invalid(`Invalid percentage allocations in rule ${rule.id}`); + } + if ( + !Object.prototype.hasOwnProperty.call( + flatContext, + distribution.attribute, + ) + ) { + errors.set(`missing:${distribution.attribute}`, { + code: "MISSING_CONTEXT_FIELD", + field: distribution.attribute, + message: `Context field "${distribution.attribute}" is required to evaluate targeting rules.`, + }); + continue; + } + const attributeValue = flatContext[distribution.attribute]; + if (Array.isArray(attributeValue)) { + errors.set(`array:${distribution.attribute}:percentageDistribution`, { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field: distribution.attribute, + operator: "percentageDistribution", + message: `Context field "${distribution.attribute}" must be scalar for percentage distribution.`, + }); + continue; + } + const bucket = hashInt(`${distribution.key}.${attributeValue}`); + let threshold = 0; + const allocationIndex = units.findIndex((percentage, index) => { + threshold += percentage; + // The legacy hash range includes 100000; only this endpoint needs + // special treatment, leaving every other company's bucket unchanged. + return bucket < threshold || index === units.length - 1; + }); + ruleResult.allocationIndex = allocationIndex; + destination = distribution.allocations[allocationIndex].destination; + } else { + destination = rule.result; + } + ruleResult.destination = destination; + if (destination.type === "nextRule") continue; + if ( + !Object.prototype.hasOwnProperty.call( + flag.variants, + destination.variantKey, + ) + ) { + return invalid( + `Unknown variant ${destination.variantKey} in rule ${rule.id}`, + ); + } + result.value = flag.variants[destination.variantKey]; + result.variantKey = destination.variantKey; + result.matchedRuleId = rule.id; + if (ruleResult.allocationIndex !== undefined) { + result.allocationIndex = ruleResult.allocationIndex; + } + return finish(); + } + return invalid("No rule selected a variant; a catch-all default is required"); +} diff --git a/packages/flag-evaluation/test/variants.test.ts b/packages/flag-evaluation/test/variants.test.ts new file mode 100644 index 000000000..c5f048813 --- /dev/null +++ b/packages/flag-evaluation/test/variants.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from "vitest"; + +import { + CompiledFlag, + evaluateFlag, + evaluateFlagRules, + hashInt, + JsonValue, + RuleResult, +} from "../src"; + +const defaultRule = { + id: "default", + filter: { type: "constant" as const, value: true }, + result: { type: "variant" as const, variantKey: "control" }, +}; + +function flag(result: RuleResult): CompiledFlag { + return { + key: "model", + sourceVersionId: "version-1", + variants: { quality: { model: "large" }, control: null, list: [1, false] }, + rules: [ + { id: "split-rule", filter: { type: "constant", value: true }, result }, + { ...defaultRule }, + ], + }; +} + +function split(): RuleResult { + return { + type: "percentageDistribution", + key: "split", + attribute: "company.id", + allocations: [ + { + percentage: 60, + destination: { type: "variant", variantKey: "quality" }, + }, + { + percentage: 40, + destination: { type: "variant", variantKey: "control" }, + }, + ], + }; +} + +describe("protocol-v2 evaluation", () => { + it.each( + ( + [ + null, + false, + true, + 0, + "", + [1, false], + { model: "large" }, + ] satisfies JsonValue[] + ).map((value) => ({ value })), + )( + "returns JSON value $value without treating it as an unresolved result", + ({ value }) => { + const definition = flag({ type: "variant", variantKey: "quality" }); + definition.variants.quality = value; + expect(evaluateFlag(definition, {})).toMatchObject({ + sourceVersionId: "version-1", + value, + variantKey: "quality", + matchedRuleId: "split-rule", + errors: [], + }); + }, + ); + + it.each([ + ["company-6295", 0, 0, "quality"], + ["company-20422", 59999, 0, "quality"], + ["company-27366", 60000, 1, "control"], + ["company-161395", 99999, 1, "control"], + ["company-1208267", 100000, 1, "control"], + ] as const)( + "assigns %s at hash %i to allocation %i", + (id, bucket, allocationIndex, variantKey) => { + expect(hashInt(`split.${id}`)).toBe(bucket); + const result = evaluateFlag(flag(split()), { company: { id } }); + expect(result).toMatchObject({ + variantKey, + allocationIndex, + matchedRuleId: "split-rule", + errors: [], + }); + expect(result.ruleResults).toHaveLength(1); + }, + ); + + it("honors compiled user targets before company targets and ordered rules", () => { + const definition = flag({ type: "variant", variantKey: "list" }); + definition.rules.unshift( + { + id: "user-target", + filter: { + type: "context", + field: "user.id", + operator: "ANY_OF", + values: ["alice"], + }, + result: { type: "variant", variantKey: "quality" }, + }, + { + id: "company-target", + filter: { + type: "context", + field: "company.id", + operator: "ANY_OF", + values: ["acme"], + }, + result: { type: "variant", variantKey: "control" }, + }, + ); + expect( + evaluateFlag(definition, { + user: { id: "alice" }, + company: { id: "acme" }, + }).matchedRuleId, + ).toBe("user-target"); + expect( + evaluateFlag(definition, { user: { id: "bob" }, company: { id: "acme" } }) + .matchedRuleId, + ).toBe("company-target"); + expect( + evaluateFlag(definition, { + user: { id: "bob" }, + company: { id: "other" }, + }).matchedRuleId, + ).toBe("split-rule"); + }); + + it("continues to the next authored rule rather than directly to the default", () => { + const definition = flag({ + type: "percentageDistribution", + key: "split", + attribute: "company.id", + allocations: [{ percentage: 100, destination: { type: "nextRule" } }], + }); + definition.rules.splice(1, 0, { + id: "second-rule", + filter: { type: "constant", value: true }, + result: { type: "variant", variantKey: "list" }, + }); + const result = evaluateFlag(definition, { + company: { id: "company-6295" }, + }); + expect(result).toMatchObject({ + value: [1, false], + matchedRuleId: "second-rule", + variantKey: "list", + }); + expect(result.allocationIndex).toBeUndefined(); + expect(result.ruleResults).toEqual([ + { + ruleId: "split-rule", + matched: true, + allocationIndex: 0, + destination: { type: "nextRule" }, + }, + { + ruleId: "second-rule", + matched: true, + destination: { type: "variant", variantKey: "list" }, + }, + ]); + }); + + it("records missing bucketing context and continues evaluation", () => { + const result = evaluateFlag(flag(split()), {}); + expect(result).toMatchObject({ + variantKey: "control", + value: null, + matchedRuleId: "default", + }); + expect(result.errors).toEqual([ + { + code: "MISSING_CONTEXT_FIELD", + field: "company.id", + message: + 'Context field "company.id" is required to evaluate targeting rules.', + }, + ]); + }); + + it("rejects array bucketing values instead of implicitly stringifying them", () => { + const result = evaluateFlag(flag(split()), { company: { id: ["a", "b"] } }); + expect(result.variantKey).toBe("control"); + expect(result.errors).toMatchObject([ + { + code: "UNSUPPORTED_ARRAY_OPERATOR", + operator: "percentageDistribution", + field: "company.id", + }, + ]); + }); + + it("does not inspect filters or report missing fields after selecting a variant", () => { + const definition = flag({ type: "variant", variantKey: "quality" }); + definition.rules[1].filter = { + type: "context", + field: "user.missing", + operator: "IS", + values: ["x"], + }; + const result = evaluateFlag(definition, {}); + expect(result.errors).toEqual([]); + expect(result.ruleResults).toHaveLength(1); + }); + + it("does not turn an invalid negated filter into a matching rule", () => { + const definition = flag({ type: "variant", variantKey: "quality" }); + definition.rules[0].filter = { + type: "negation", + filter: { + type: "context", + field: "company.missing", + operator: "IS", + values: ["x"], + }, + }; + const result = evaluateFlag(definition, {}); + expect(result.variantKey).toBe("control"); + expect(result.ruleResults[0].matched).toBe(false); + expect(result.errors).toHaveLength(1); + }); + + it.each([99, 100.001, -1, Number.NaN, 99.9999])( + "rejects invalid allocation total/precision %s", + (percentage) => { + const result = evaluateFlag( + flag({ + type: "percentageDistribution", + key: "split", + attribute: "company.id", + allocations: [ + { + percentage, + destination: { type: "variant", variantKey: "quality" }, + }, + ], + }), + { company: { id: "acme" } }, + ); + expect(result.value).toBeUndefined(); + expect(result.errors).toMatchObject([ + { code: "INVALID_FLAG_DEFINITION" }, + ]); + }, + ); + + it("accepts three-decimal splits without floating-point sum errors", () => { + const definition = flag({ + type: "percentageDistribution", + key: "split", + attribute: "company.id", + allocations: [ + { + percentage: 33.333, + destination: { type: "variant", variantKey: "quality" }, + }, + { + percentage: 33.333, + destination: { type: "variant", variantKey: "list" }, + }, + { + percentage: 33.334, + destination: { type: "variant", variantKey: "control" }, + }, + ], + }); + expect( + evaluateFlag(definition, { company: { id: "acme" } }).errors, + ).toEqual([]); + }); + + it("does not resolve inherited object properties as variant values", () => { + const result = evaluateFlag( + flag({ type: "variant", variantKey: "constructor" }), + {}, + ); + expect(result.value).toBeUndefined(); + expect(result.errors).toMatchObject([{ code: "INVALID_FLAG_DEFINITION" }]); + }); + + it("reports missing catch-all defaults as unresolved", () => { + const definition = flag({ type: "variant", variantKey: "quality" }); + definition.rules = []; + const result = evaluateFlag(definition, {}); + expect(result.value).toBeUndefined(); + expect(result.errors).toMatchObject([{ code: "INVALID_FLAG_DEFINITION" }]); + }); + + it("preserves legacy boolean rollout cohorts, including the maximum hash", () => { + for (const id of [ + "company-6295", + "company-20422", + "company-27366", + "company-1208267", + ]) { + const filter = { + type: "rolloutPercentage" as const, + key: "split", + partialRolloutAttribute: "company.id", + partialRolloutThreshold: 60000, + }; + const context = { company: { id } }; + const legacy = evaluateFlagRules({ + flagKey: "boolean", + context, + rules: [{ filter, value: true }], + }); + const result = evaluateFlag( + { + key: "boolean", + sourceVersionId: "version-1", + variants: { true: true, false: false }, + rules: [ + { + id: "target", + filter, + result: { type: "variant", variantKey: "true" }, + }, + { + ...defaultRule, + result: { type: "variant", variantKey: "false" }, + }, + ], + }, + context, + ); + expect(result.value).toBe(legacy.value ?? false); + } + }); +}); From 7c28d1d70d80bd630f9575608d7ad02ad3693711 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 07:38:07 +0200 Subject: [PATCH 02/18] perf: prepare v2 flag lookups and percentage allocations once --- .changeset/multivariate-evaluation.md | 4 +- packages/flag-evaluation/README.md | 52 +++++ packages/flag-evaluation/src/index.ts | 206 ++++++++++++------ .../flag-evaluation/test/variants.bench.ts | 81 +++++++ .../flag-evaluation/test/variants.test.ts | 203 ++++++++++++++++- 5 files changed, 474 insertions(+), 72 deletions(-) create mode 100644 packages/flag-evaluation/README.md create mode 100644 packages/flag-evaluation/test/variants.bench.ts diff --git a/.changeset/multivariate-evaluation.md b/.changeset/multivariate-evaluation.md index dbe1c3431..8ad4f813e 100644 --- a/.changeset/multivariate-evaluation.md +++ b/.changeset/multivariate-evaluation.md @@ -6,7 +6,9 @@ Add protocol-v2 `CompiledFlag` evaluation via `evaluateFlag(flag, context)`: - Return any JSON variant value, including `null`, arrays, and booleans. - Evaluate rules sequentially with fixed variants, percentage distributions, and explicit fallthrough. -- Preserve the existing hash function and legacy rollout filters; assign the inclusive maximum distribution bucket to the final allocation. +- Preserve the existing hash function and legacy rollout filters; assign the inclusive maximum distribution bucket to the final nonzero allocation. - Return source version, selected variant, matched rule, allocation, and per-rule diagnostics for exposure events and debugging. +Use `newFlagEvaluator(flag)` for repeated checks: it prepares hash-set membership lookups and validated cumulative percentage thresholds once, including filters nested under groups and negations. Evaluations reuse those structures, binary-search percentage allocations, and reuse per-rule diagnostic scratch storage. + Existing `evaluateFlagRules` and `newEvaluator` exports remain available for legacy consumers. SDK adoption of `/flags` and variant-valued public APIs is a separate change. diff --git a/packages/flag-evaluation/README.md b/packages/flag-evaluation/README.md new file mode 100644 index 000000000..632f7ad47 --- /dev/null +++ b/packages/flag-evaluation/README.md @@ -0,0 +1,52 @@ +# Flag evaluation + +## Protocol v2 + +Prepare a compiled flag once when its definition is loaded or refreshed, then +reuse the evaluator for each context: + +```ts +import { newFlagEvaluator } from "@reflag/flag-evaluation"; + +const evaluate = newFlagEvaluator(compiledFlag); +const result = evaluate({ company: { id: "acme" }, user: { id: "alice" } }); + +// Selected JSON value; null is a valid value, undefined means unresolved. +console.log(result.value); +// Metadata for diagnostics/exposure events, not the public SDK getFlag result. +console.log(result.sourceVersionId, result.variantKey, result.matchedRuleId); +``` + +Rebuild the evaluator when the compiled definition changes. Treat definitions and +returned JSON values as immutable. Each evaluation has independent diagnostics. + +- `ANY_OF` / `NOT_ANY_OF` candidate lists are converted to `Set`s at preparation, + including inside groups and negations. Scalar checks use hash lookups. An + array-valued context requires one lookup per examined context element, not a + traversal of the candidate list. +- Percentage validation and cumulative integer thresholds are computed once. + Checks hash the company/context attribute and binary-search the thresholds. + The inclusive maximum hash maps to the last nonzero allocation; 0% allocations + never receive traffic. +- Rules stop at the first selected variant. `nextRule` continues evaluation. + Results include diagnostics only for visited rules. + +`evaluateFlag(flag, context)` is a one-shot convenience API: it prepares on each +call. Use `newFlagEvaluator` in SDK/server hot paths, not inside each `getFlag`. + +## Legacy protocol + +`evaluateFlagRules` and `newEvaluator` retain the legacy value-rule API. +Legacy rollout thresholds and hash cohorts are unchanged. + +## Performance checks + +From the repository root: + +```sh +yarn workspace @reflag/flag-evaluation exec vitest bench --run test/variants.bench.ts +``` + +Benchmarks exclude preparation and cover candidate-list sizes up to 100,000 and +percentage distributions up to 50 allocations. Unit tests additionally assert +cached Set usage and that repeated checks do not reread percentage definitions. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 4c9bdf73c..d8318adc0 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -599,11 +599,12 @@ function evaluateRecursively( return hashVal < filter.partialRolloutThreshold; } case "group": { - const evaluateChild = (child: RuleFilter) => - evaluateRecursively(child, context, errors); - return filter.operator === "and" - ? filter.filters.every(evaluateChild) - : filter.filters.some(evaluateChild); + for (const child of filter.filters) { + const matched = evaluateRecursively(child, context, errors); + if (filter.operator === "and" && !matched) return false; + if (filter.operator === "or" && matched) return true; + } + return filter.operator === "and"; } case "negation": return !evaluateRecursively(filter.filter, context, errors); @@ -691,37 +692,29 @@ export function evaluateFlagRules({ }; } -export function newEvaluator(rules: Rule[]) { - function translateRule(rule: RuleFilter): RuleFilter { - if (rule.type === "group") { - return { - ...rule, - filters: rule.filters.map(translateRule), - }; - } - - if ( - rule.type === "context" && - (rule.operator === "ANY_OF" || rule.operator === "NOT_ANY_OF") - ) { - return { - ...rule, - valueSet: new Set(rule.values ?? []), - }; - } - - return { ...rule }; +function prepareFilter(filter: RuleFilter): RuleFilter { + if (filter.type === "group") { + return { ...filter, filters: filter.filters.map(prepareFilter) }; } + if (filter.type === "negation") { + return { ...filter, filter: prepareFilter(filter.filter) }; + } + if ( + filter.type === "context" && + (filter.operator === "ANY_OF" || filter.operator === "NOT_ANY_OF") + ) { + // The Set owns the candidate references; don't retain the source array in + // the prepared filter as well. Keep an empty array to avoid per-check []s. + return { ...filter, values: [], valueSet: new Set(filter.values ?? []) }; + } + return { ...filter }; +} - const translatedRules = rules.map((rule) => { - const { filter } = rule; - const translatedFilter = translateRule(filter); - - return { - ...rule, - filter: translatedFilter, - }; - }); +export function newEvaluator(rules: Rule[]) { + const translatedRules = rules.map((rule) => ({ + ...rule, + filter: prepareFilter(rule.filter), + })); return function evaluateOptimized( context: Record, @@ -799,15 +792,94 @@ export type VariantEvaluationResult = { errors: VariantEvaluationError[]; }; -/** Evaluate protocol-v2 rules sequentially, stopping at the first selected variant. - * Legacy evaluateFlagRules/newEvaluator retain their existing behavior. +type PreparedRule = CompiledRule & { + thresholds?: number[]; + allocationError?: string; +}; +type PreparedFlag = Omit & { rules: PreparedRule[] }; + +function prepareVariantRule(rule: CompiledRule): PreparedRule { + const prepared: PreparedRule = { + ...rule, + filter: prepareFilter(rule.filter), + result: { ...rule.result }, + }; + if (rule.result.type !== "percentageDistribution") return prepared; + + let total = 0; + let valid = rule.result.allocations.length > 0; + const thresholds: number[] = []; + for (const { percentage } of rule.result.allocations) { + const units = Math.round(percentage * 1000); + valid &&= + Number.isFinite(percentage) && + percentage >= 0 && + percentage <= 100 && + Math.abs(percentage * 1000 - units) <= 1e-8; + total += units; + thresholds.push(total); + } + if (!valid || total !== 100000) { + prepared.allocationError = `Invalid percentage allocations in rule ${rule.id}`; + } + prepared.thresholds = thresholds; + prepared.result = { + ...rule.result, + allocations: rule.result.allocations.map((allocation) => ({ + ...allocation, + destination: { ...allocation.destination }, + })), + }; + return prepared; +} + +/** Prepare once per definition refresh, then reuse across evaluation contexts. + * ANY_OF/NOT_ANY_OF candidates become Sets, including inside negations/groups. + * Percentage validation and cumulative threshold construction happen only here. */ +export function newFlagEvaluator(flag: CompiledFlag) { + const prepared: PreparedFlag = { + ...flag, + variants: { ...flag.variants }, + rules: flag.rules.map(prepareVariantRule), + }; + return (context: Record): VariantEvaluationResult => + evaluatePreparedFlag(prepared, context); +} + +/** One-shot protocol-v2 evaluation. For repeated checks use newFlagEvaluator. */ export function evaluateFlag( flag: CompiledFlag, context: Record, +): VariantEvaluationResult { + return newFlagEvaluator(flag)(context); +} + +function finishVariantEvaluation( + result: VariantEvaluationResult, + errors: Map, +): VariantEvaluationResult { + if (errors.size) result.errors = Array.from(errors.values()); + return result; +} + +function invalidVariantDefinition( + result: VariantEvaluationResult, + errors: Map, + message: string, +): VariantEvaluationResult { + errors.set("invalid", { code: "INVALID_FLAG_DEFINITION", message }); + return finishVariantEvaluation(result, errors); +} + +function evaluatePreparedFlag( + flag: PreparedFlag, + context: Record, ): VariantEvaluationResult { const flatContext = flattenContext(context); const errors = new Map(); + // Reuse scratch diagnostics across rules instead of allocating one Map per rule. + const filterErrors = new Map(); const result: VariantEvaluationResult = { flagKey: flag.key, sourceVersionId: flag.sourceVersionId, @@ -816,14 +888,8 @@ export function evaluateFlag( ruleResults: [], errors: [], }; - const finish = () => ({ ...result, errors: Array.from(errors.values()) }); - const invalid = (message: string) => { - errors.set("invalid", { code: "INVALID_FLAG_DEFINITION", message }); - return finish(); - }; - for (const rule of flag.rules) { - const filterErrors = new Map(); + filterErrors.clear(); const matched = evaluateRecursively(rule.filter, flatContext, filterErrors); for (const [key, error] of filterErrors) errors.set(key, error); const ruleResult: VariantEvaluationResult["ruleResults"][number] = { @@ -836,21 +902,8 @@ export function evaluateFlag( let destination: VariantDestination; if (rule.result.type === "percentageDistribution") { const distribution = rule.result; - const units = distribution.allocations.map(({ percentage }) => - Math.round(percentage * 1000), - ); - if ( - !units.length || - distribution.allocations.some( - ({ percentage }, index) => - !Number.isFinite(percentage) || - percentage < 0 || - percentage > 100 || - Math.abs(percentage * 1000 - units[index]) > 1e-8, - ) || - units.reduce((total, percentage) => total + percentage, 0) !== 100000 - ) { - return invalid(`Invalid percentage allocations in rule ${rule.id}`); + if (rule.allocationError) { + return invalidVariantDefinition(result, errors, rule.allocationError); } if ( !Object.prototype.hasOwnProperty.call( @@ -875,20 +928,27 @@ export function evaluateFlag( }); continue; } - const bucket = hashInt(`${distribution.key}.${attributeValue}`); - let threshold = 0; - const allocationIndex = units.findIndex((percentage, index) => { - threshold += percentage; - // The legacy hash range includes 100000; only this endpoint needs - // special treatment, leaving every other company's bucket unchanged. - return bucket < threshold || index === units.length - 1; - }); + // Only the inclusive endpoint is clamped. This also keeps trailing 0% + // allocations empty, without moving any other company's bucket. + const bucket = Math.min( + hashInt(`${distribution.key}.${attributeValue}`), + 99999, + ); + const thresholds = rule.thresholds!; + let low = 0; + let high = thresholds.length - 1; + while (low < high) { + const mid = (low + high) >>> 1; + if (bucket < thresholds[mid]) high = mid; + else low = mid + 1; + } + const allocationIndex = low; ruleResult.allocationIndex = allocationIndex; destination = distribution.allocations[allocationIndex].destination; } else { destination = rule.result; } - ruleResult.destination = destination; + ruleResult.destination = { ...destination }; if (destination.type === "nextRule") continue; if ( !Object.prototype.hasOwnProperty.call( @@ -896,7 +956,9 @@ export function evaluateFlag( destination.variantKey, ) ) { - return invalid( + return invalidVariantDefinition( + result, + errors, `Unknown variant ${destination.variantKey} in rule ${rule.id}`, ); } @@ -906,7 +968,11 @@ export function evaluateFlag( if (ruleResult.allocationIndex !== undefined) { result.allocationIndex = ruleResult.allocationIndex; } - return finish(); + return finishVariantEvaluation(result, errors); } - return invalid("No rule selected a variant; a catch-all default is required"); + return invalidVariantDefinition( + result, + errors, + "No rule selected a variant; a catch-all default is required", + ); } diff --git a/packages/flag-evaluation/test/variants.bench.ts b/packages/flag-evaluation/test/variants.bench.ts new file mode 100644 index 000000000..f64c9f7e2 --- /dev/null +++ b/packages/flag-evaluation/test/variants.bench.ts @@ -0,0 +1,81 @@ +import { bench, describe } from "vitest"; + +import { + CompiledFlag, + newEvaluator, + newFlagEvaluator, + RuleFilter, +} from "../src"; + +// Preparation is deliberately outside the timed callback, just as it should be +// outside the SDK's getFlag hot path (rebuild only when definitions refresh). +const context = { company: { id: "candidate-not-in-list" } }; + +for (const size of [10, 1000, 100000]) { + describe(`ANY_OF candidate count ${size}`, () => { + const filter: RuleFilter = { + type: "context", + field: "company.id", + operator: "ANY_OF", + values: Array.from({ length: size }, (_, i) => `candidate-${i}`), + }; + const legacy = newEvaluator([ + { filter, value: true }, + { filter: { type: "constant", value: true }, value: false }, + ]); + const prepared = newFlagEvaluator({ + key: "boolean", + sourceVersionId: "v1", + variants: { true: true, false: false }, + rules: [ + { + id: "target", + filter, + result: { type: "variant", variantKey: "true" }, + }, + { + id: "default", + filter: { type: "constant", value: true }, + result: { type: "variant", variantKey: "false" }, + }, + ], + }); + bench("prepared legacy", () => { + legacy(context, "boolean"); + }); + bench("prepared v2", () => { + prepared(context); + }); + }); +} + +for (const size of [1, 10, 50]) { + describe(`percentage distribution with ${size} allocations`, () => { + const flag: CompiledFlag = { + key: "split", + sourceVersionId: "v1", + variants: Object.fromEntries( + Array.from({ length: size }, (_, i) => [`v${i}`, i]), + ), + rules: [ + { + id: "split", + filter: { type: "constant", value: true }, + result: { + type: "percentageDistribution", + key: "split", + attribute: "company.id", + allocations: Array.from({ length: size }, (_, i) => ({ + percentage: 100 / size, + destination: { type: "variant", variantKey: `v${i}` }, + })), + }, + }, + ], + }; + const prepared = newFlagEvaluator(flag); + bench("prepared v2", () => { + prepared(context); + }); + }); +} diff --git a/packages/flag-evaluation/test/variants.test.ts b/packages/flag-evaluation/test/variants.test.ts index c5f048813..1d010dcae 100644 --- a/packages/flag-evaluation/test/variants.test.ts +++ b/packages/flag-evaluation/test/variants.test.ts @@ -1,11 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CompiledFlag, + ContextFilter, evaluateFlag, evaluateFlagRules, hashInt, JsonValue, + newEvaluator, + newFlagEvaluator, RuleResult, } from "../src"; @@ -339,3 +342,201 @@ describe("protocol-v2 evaluation", () => { } }); }); + +describe("prepared protocol-v2 evaluator", () => { + describe.each(["ANY_OF", "NOT_ANY_OF"] as const)("%s", (operator) => { + it.each(["plain", "group", "negation"] as const)( + "uses cached Set lookups for %s filters", + (shape) => { + const values = Array.from( + { length: 10000 }, + (_, i) => `candidate-${i}`, + ); + // Element 0 is read by the shared scalar comparison helper. Watching + // element 1 distinguishes list traversal/rebuilding from constant work. + const readCandidate = vi.fn(() => "candidate-1"); + Object.defineProperty(values, 1, { get: readCandidate }); + const leaf: ContextFilter = { + type: "context", + field: "company.id", + operator, + values, + }; + const definition = flag({ type: "variant", variantKey: "quality" }); + definition.rules[0].filter = + shape === "plain" + ? leaf + : shape === "group" + ? { type: "group", operator: "and", filters: [leaf] } + : { + type: "negation", + filter: { type: "group", operator: "and", filters: [leaf] }, + }; + const prepared = newFlagEvaluator(definition); + expect(readCandidate).toHaveBeenCalled(); + readCandidate.mockClear(); + const lookups = vi.spyOn(Set.prototype, "has"); + const traversal = vi + .spyOn(values, "includes") + .mockImplementation(() => { + throw new Error( + "Candidate array must not be scanned during evaluation", + ); + }); + let results: ReturnType[]; + let lookupCount: number; + try { + results = [ + prepared({ company: { id: "candidate-9999" } }), + prepared({ company: { id: ["missing", "candidate-9999"] } }), + ]; + lookupCount = lookups.mock.calls.filter( + ([key]) => key === "candidate-9999" || key === "missing", + ).length; + } finally { + lookups.mockRestore(); + traversal.mockRestore(); + } + expect(lookupCount).toBe(3); + expect(readCandidate).not.toHaveBeenCalled(); + expect(leaf.valueSet).toBeUndefined(); + const matches = (operator === "ANY_OF") !== (shape === "negation"); + expect(results.map(({ variantKey }) => variantKey)).toEqual([ + matches ? "quality" : "control", + matches ? "quality" : "control", + ]); + }, + ); + }); + + it("also prepares lookups under negation for legacy newEvaluator", () => { + const values = ["acme"]; + const prepared = newEvaluator([ + { + value: true, + filter: { + type: "negation", + filter: { + type: "context", + field: "company.id", + operator: "ANY_OF", + values, + }, + }, + }, + ]); + const traversal = vi.spyOn(values, "includes").mockImplementation(() => { + throw new Error("Unexpected traversal"); + }); + try { + expect(prepared({ company: { id: "other" } }, "legacy").value).toBe(true); + } finally { + traversal.mockRestore(); + } + }); + + it("does not reread or validate percentages on repeated evaluations", () => { + const distribution = split(); + if (distribution.type !== "percentageDistribution") + throw new Error("Expected split"); + const percentageReads = vi.fn(() => 60); + Object.defineProperty(distribution.allocations[0], "percentage", { + get: percentageReads, + }); + const prepared = newFlagEvaluator(flag(distribution)); + expect(percentageReads).toHaveBeenCalled(); + percentageReads.mockClear(); + for (let i = 0; i < 10; i++) { + expect( + prepared({ company: { id: "company-6295" } }).allocationIndex, + ).toBe(0); + expect( + prepared({ company: { id: "company-27366" } }).allocationIndex, + ).toBe(1); + } + expect(percentageReads).not.toHaveBeenCalled(); + }); + + it.each([ + "company-6295", + "company-20422", + "company-27366", + "company-161395", + "company-1208267", + ])( + "preserves allocation boundaries for %s while skipping zero-weight entries", + (id) => { + const distribution = split(); + if (distribution.type !== "percentageDistribution") + throw new Error("Expected split"); + distribution.allocations.unshift({ + percentage: 0, + destination: { type: "variant", variantKey: "list" }, + }); + distribution.allocations.splice(2, 0, { + percentage: 0, + destination: { type: "variant", variantKey: "list" }, + }); + distribution.allocations.push({ + percentage: 0, + destination: { type: "variant", variantKey: "list" }, + }); + const prepared = newFlagEvaluator(flag(distribution)); + const expectedIndex = hashInt(`split.${id}`) < 60000 ? 1 : 3; + expect(prepared({ company: { id } }).allocationIndex).toBe(expectedIndex); + }, + ); + + it("matches cumulative allocation semantics across 50 variants", () => { + const definition: CompiledFlag = { + key: "many", + sourceVersionId: "v1", + variants: Object.fromEntries( + Array.from({ length: 50 }, (_, i) => [`v${i}`, i]), + ), + rules: [ + { + id: "split", + filter: { type: "constant", value: true }, + result: { + type: "percentageDistribution", + key: "split", + attribute: "company.id", + allocations: Array.from({ length: 50 }, (_, i) => ({ + percentage: 2, + destination: { type: "variant", variantKey: `v${i}` }, + })), + }, + }, + ], + }; + const prepared = newFlagEvaluator(definition); + for (let i = 0; i < 200; i++) { + const id = `company-${i}`; + const index = Math.min(Math.floor(hashInt(`split.${id}`) / 2000), 49); + expect(prepared({ company: { id } })).toMatchObject({ + allocationIndex: index, + variantKey: `v${index}`, + value: index, + errors: [], + }); + } + }); + + it("isolates diagnostics and returned destinations between contexts", () => { + const definition = flag(split()); + const prepared = newFlagEvaluator(definition); + const missing = prepared({}); + expect(missing.errors).toHaveLength(1); + const selected = prepared({ company: { id: "company-6295" } }); + expect(selected.errors).toEqual([]); + const destination = selected.ruleResults[0].destination!; + if (destination.type === "variant") destination.variantKey = "control"; + const again = prepared({ company: { id: "company-6295" } }); + expect(again.variantKey).toBe("quality"); + expect(missing.errors).toHaveLength(1); + expect(again).toEqual( + evaluateFlag(definition, { company: { id: "company-6295" } }), + ); + }); +}); From 1127ba8389838fea06715facb9fa44753f9f9be6 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 09:13:16 +0200 Subject: [PATCH 03/18] refactor: use a simple loop for percentage allocation selection --- .changeset/multivariate-evaluation.md | 2 +- packages/flag-evaluation/README.md | 2 +- packages/flag-evaluation/src/index.ts | 13 ++++++------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.changeset/multivariate-evaluation.md b/.changeset/multivariate-evaluation.md index 8ad4f813e..5fb9bc3f0 100644 --- a/.changeset/multivariate-evaluation.md +++ b/.changeset/multivariate-evaluation.md @@ -9,6 +9,6 @@ Add protocol-v2 `CompiledFlag` evaluation via `evaluateFlag(flag, context)`: - Preserve the existing hash function and legacy rollout filters; assign the inclusive maximum distribution bucket to the final nonzero allocation. - Return source version, selected variant, matched rule, allocation, and per-rule diagnostics for exposure events and debugging. -Use `newFlagEvaluator(flag)` for repeated checks: it prepares hash-set membership lookups and validated cumulative percentage thresholds once, including filters nested under groups and negations. Evaluations reuse those structures, binary-search percentage allocations, and reuse per-rule diagnostic scratch storage. +Use `newFlagEvaluator(flag)` for repeated checks: it prepares hash-set membership lookups and validated cumulative percentage thresholds once, including filters nested under groups and negations. Evaluations reuse those structures, scan precomputed percentage thresholds, and reuse per-rule diagnostic scratch storage. Existing `evaluateFlagRules` and `newEvaluator` exports remain available for legacy consumers. SDK adoption of `/flags` and variant-valued public APIs is a separate change. diff --git a/packages/flag-evaluation/README.md b/packages/flag-evaluation/README.md index 632f7ad47..f665fbce7 100644 --- a/packages/flag-evaluation/README.md +++ b/packages/flag-evaluation/README.md @@ -25,7 +25,7 @@ returned JSON values as immutable. Each evaluation has independent diagnostics. array-valued context requires one lookup per examined context element, not a traversal of the candidate list. - Percentage validation and cumulative integer thresholds are computed once. - Checks hash the company/context attribute and binary-search the thresholds. + Checks hash the company/context attribute and scan the precomputed thresholds. The inclusive maximum hash maps to the last nonzero allocation; 0% allocations never receive traffic. - Rules stop at the first selected variant. `nextRule` continues evaluation. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index d8318adc0..3f8e73c8c 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -935,14 +935,13 @@ function evaluatePreparedFlag( 99999, ); const thresholds = rule.thresholds!; - let low = 0; - let high = thresholds.length - 1; - while (low < high) { - const mid = (low + high) >>> 1; - if (bucket < thresholds[mid]) high = mid; - else low = mid + 1; + let allocationIndex = 0; + while ( + allocationIndex < thresholds.length - 1 && + bucket >= thresholds[allocationIndex] + ) { + allocationIndex++; } - const allocationIndex = low; ruleResult.allocationIndex = allocationIndex; destination = distribution.allocations[allocationIndex].destination; } else { From fc5fa6fbf657065d4295ba8f6e575b7b5c3e252b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 09:16:56 +0200 Subject: [PATCH 04/18] refactor: preserve generic variant value types through evaluation --- .changeset/multivariate-evaluation.md | 2 +- packages/flag-evaluation/README.md | 8 ++- packages/flag-evaluation/src/index.ts | 54 ++++++++--------- .../flag-evaluation/test/variants.test.ts | 58 ++++++++++++++----- 4 files changed, 76 insertions(+), 46 deletions(-) diff --git a/.changeset/multivariate-evaluation.md b/.changeset/multivariate-evaluation.md index 5fb9bc3f0..bfb5f3a6d 100644 --- a/.changeset/multivariate-evaluation.md +++ b/.changeset/multivariate-evaluation.md @@ -4,7 +4,7 @@ Add protocol-v2 `CompiledFlag` evaluation via `evaluateFlag(flag, context)`: -- Return any JSON variant value, including `null`, arrays, and booleans. +- Preserve variant value types through generic `CompiledFlag` and evaluation results (default `any`), without imposing JSON constraints in the evaluator. - Evaluate rules sequentially with fixed variants, percentage distributions, and explicit fallthrough. - Preserve the existing hash function and legacy rollout filters; assign the inclusive maximum distribution bucket to the final nonzero allocation. - Return source version, selected variant, matched rule, allocation, and per-rule diagnostics for exposure events and debugging. diff --git a/packages/flag-evaluation/README.md b/packages/flag-evaluation/README.md index f665fbce7..d8921d267 100644 --- a/packages/flag-evaluation/README.md +++ b/packages/flag-evaluation/README.md @@ -11,14 +11,18 @@ import { newFlagEvaluator } from "@reflag/flag-evaluation"; const evaluate = newFlagEvaluator(compiledFlag); const result = evaluate({ company: { id: "acme" }, user: { id: "alice" } }); -// Selected JSON value; null is a valid value, undefined means unresolved. +// Selected value (T | undefined); null is a valid value. console.log(result.value); // Metadata for diagnostics/exposure events, not the public SDK getFlag result. console.log(result.sourceVersionId, result.variantKey, result.matchedRuleId); ``` +`CompiledFlag` and `VariantEvaluationResult` preserve the +caller's value type. Both evaluator functions infer `T` from the flag; it can be +any type. JSON validation belongs at the API/persistence boundary, not here. + Rebuild the evaluator when the compiled definition changes. Treat definitions and -returned JSON values as immutable. Each evaluation has independent diagnostics. +returned values as immutable. Each evaluation has independent diagnostics. - `ANY_OF` / `NOT_ANY_OF` candidate lists are converted to `Set`s at preparation, including inside groups and negations. Scalar checks use hash lookups. An diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 3f8e73c8c..e121b5c8e 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -728,14 +728,6 @@ export function newEvaluator(rules: Rule[]) { }; } -export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | { [key: string]: JsonValue }; - export type VariantDestination = | { type: "variant"; variantKey: string } | { type: "nextRule" }; @@ -758,10 +750,10 @@ export type CompiledRule = { result: RuleResult; }; -export type CompiledFlag = { +export type CompiledFlag = { key: string; sourceVersionId: string; - variants: Record; + variants: Record; rules: CompiledRule[]; }; @@ -775,10 +767,10 @@ export type VariantEvaluationError = message: string; }; -export type VariantEvaluationResult = { +export type VariantEvaluationResult = { flagKey: string; sourceVersionId: string; - value: JsonValue | undefined; + value: T | undefined; variantKey?: string; matchedRuleId?: string; allocationIndex?: number; @@ -796,7 +788,9 @@ type PreparedRule = CompiledRule & { thresholds?: number[]; allocationError?: string; }; -type PreparedFlag = Omit & { rules: PreparedRule[] }; +type PreparedFlag = Omit, "rules"> & { + rules: PreparedRule[]; +}; function prepareVariantRule(rule: CompiledRule): PreparedRule { const prepared: PreparedRule = { @@ -837,50 +831,50 @@ function prepareVariantRule(rule: CompiledRule): PreparedRule { * ANY_OF/NOT_ANY_OF candidates become Sets, including inside negations/groups. * Percentage validation and cumulative threshold construction happen only here. */ -export function newFlagEvaluator(flag: CompiledFlag) { - const prepared: PreparedFlag = { +export function newFlagEvaluator(flag: CompiledFlag) { + const prepared: PreparedFlag = { ...flag, variants: { ...flag.variants }, rules: flag.rules.map(prepareVariantRule), }; - return (context: Record): VariantEvaluationResult => + return (context: Record): VariantEvaluationResult => evaluatePreparedFlag(prepared, context); } /** One-shot protocol-v2 evaluation. For repeated checks use newFlagEvaluator. */ -export function evaluateFlag( - flag: CompiledFlag, +export function evaluateFlag( + flag: CompiledFlag, context: Record, -): VariantEvaluationResult { +): VariantEvaluationResult { return newFlagEvaluator(flag)(context); } -function finishVariantEvaluation( - result: VariantEvaluationResult, +function finishVariantEvaluation( + result: VariantEvaluationResult, errors: Map, -): VariantEvaluationResult { +): VariantEvaluationResult { if (errors.size) result.errors = Array.from(errors.values()); return result; } -function invalidVariantDefinition( - result: VariantEvaluationResult, +function invalidVariantDefinition( + result: VariantEvaluationResult, errors: Map, message: string, -): VariantEvaluationResult { +): VariantEvaluationResult { errors.set("invalid", { code: "INVALID_FLAG_DEFINITION", message }); return finishVariantEvaluation(result, errors); } -function evaluatePreparedFlag( - flag: PreparedFlag, +function evaluatePreparedFlag( + flag: PreparedFlag, context: Record, -): VariantEvaluationResult { +): VariantEvaluationResult { const flatContext = flattenContext(context); const errors = new Map(); // Reuse scratch diagnostics across rules instead of allocating one Map per rule. const filterErrors = new Map(); - const result: VariantEvaluationResult = { + const result: VariantEvaluationResult = { flagKey: flag.key, sourceVersionId: flag.sourceVersionId, value: undefined, @@ -892,7 +886,7 @@ function evaluatePreparedFlag( filterErrors.clear(); const matched = evaluateRecursively(rule.filter, flatContext, filterErrors); for (const [key, error] of filterErrors) errors.set(key, error); - const ruleResult: VariantEvaluationResult["ruleResults"][number] = { + const ruleResult: VariantEvaluationResult["ruleResults"][number] = { ruleId: rule.id, matched: matched && filterErrors.size === 0, }; diff --git a/packages/flag-evaluation/test/variants.test.ts b/packages/flag-evaluation/test/variants.test.ts index 1d010dcae..2aee3fa11 100644 --- a/packages/flag-evaluation/test/variants.test.ts +++ b/packages/flag-evaluation/test/variants.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { CompiledFlag, @@ -6,7 +6,6 @@ import { evaluateFlag, evaluateFlagRules, hashInt, - JsonValue, newEvaluator, newFlagEvaluator, RuleResult, @@ -49,18 +48,51 @@ function split(): RuleResult { } describe("protocol-v2 evaluation", () => { + it("preserves explicit boolean and nullable value types in both APIs", () => { + const booleanFlag: CompiledFlag = { + ...flag({ type: "variant", variantKey: "quality" }), + variants: { quality: true, control: false }, + }; + expectTypeOf(evaluateFlag(booleanFlag, {}).value).toEqualTypeOf< + boolean | undefined + >(); + expectTypeOf(newFlagEvaluator(booleanFlag)({}).value).toEqualTypeOf< + boolean | undefined + >(); + + const nullableFlag: CompiledFlag = { + ...booleanFlag, + variants: { quality: null, control: 0 }, + }; + const result = newFlagEvaluator(nullableFlag)({}); + expectTypeOf(result.value).toEqualTypeOf(); + expect(result.value).toBeNull(); + }); + + it("infers non-JSON value types and returns the selected value unchanged", () => { + const value = new Date("2026-01-01T00:00:00Z"); + const definition = { + ...flag({ type: "variant", variantKey: "quality" }), + variants: { quality: value }, + }; + const result = evaluateFlag(definition, {}); + const preparedResult = newFlagEvaluator(definition)({}); + expectTypeOf(result.value).toEqualTypeOf(); + expectTypeOf(preparedResult.value).toEqualTypeOf(); + expect(result.value).toBe(value); + expect(preparedResult.value).toBe(value); + + const callback = () => "hello"; + const callbackFlag = { ...definition, variants: { quality: callback } }; + const selected = newFlagEvaluator(callbackFlag)({}).value; + expectTypeOf(selected).toEqualTypeOf(); + expect(selected).toBe(callback); + }); + it.each( - ( - [ - null, - false, - true, - 0, - "", - [1, false], - { model: "large" }, - ] satisfies JsonValue[] - ).map((value) => ({ value })), + [null, false, true, 0, "", [1, false], { model: "large" }].map((value) => ({ + value, + })), )( "returns JSON value $value without treating it as an unresolved result", ({ value }) => { From f05d37e3a78553348d3b5ee88d04eaaa74da3630 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 11:27:48 +0200 Subject: [PATCH 05/18] feat!: expose a v2-only prepared flag evaluator --- .changeset/multivariate-evaluation.md | 16 +- .changeset/pin-legacy-node-evaluator.md | 5 + package.json | 1 + packages/flag-evaluation/README.md | 81 +- packages/flag-evaluation/package.json | 1 + packages/flag-evaluation/src/index.ts | 1083 +++------- packages/flag-evaluation/test/index.test.ts | 1891 +++-------------- packages/flag-evaluation/test/release.test.ts | 24 + .../flag-evaluation/test/variants.bench.ts | 15 +- .../flag-evaluation/test/variants.test.ts | 503 ++--- packages/node-sdk/package.json | 2 +- packages/node-sdk/src/client.ts | 2 +- packages/node-sdk/src/types.ts | 2 +- yarn.lock | 14 +- 14 files changed, 890 insertions(+), 2750 deletions(-) create mode 100644 .changeset/pin-legacy-node-evaluator.md create mode 100644 packages/flag-evaluation/test/release.test.ts diff --git a/.changeset/multivariate-evaluation.md b/.changeset/multivariate-evaluation.md index bfb5f3a6d..f30b13d9c 100644 --- a/.changeset/multivariate-evaluation.md +++ b/.changeset/multivariate-evaluation.md @@ -2,13 +2,13 @@ "@reflag/flag-evaluation": major --- -Add protocol-v2 `CompiledFlag` evaluation via `evaluateFlag(flag, context)`: +Introduce a v2-only compiled-variant evaluator: -- Preserve variant value types through generic `CompiledFlag` and evaluation results (default `any`), without imposing JSON constraints in the evaluator. -- Evaluate rules sequentially with fixed variants, percentage distributions, and explicit fallthrough. -- Preserve the existing hash function and legacy rollout filters; assign the inclusive maximum distribution bucket to the final nonzero allocation. -- Return source version, selected variant, matched rule, allocation, and per-rule diagnostics for exposure events and debugging. +- `newEvaluator(flag)` prepares a compiled flag once and returns a reusable context evaluator. Remove the v1 value-rule APIs and the one-shot `evaluateFlag` wrapper. +- Preserve arbitrary variant value types through `CompiledFlag` and `EvaluationResult` (default `any`). `resolved: true | false` distinguishes a selected value, including `undefined`, from failure. Successful results require the variant key and matched rule ID. +- Keep membership Sets and prepared percentage bounds private. `ANY_OF` / `NOT_ANY_OF` use cached Set lookups, including under groups and negations; percentage distributions use precomputed bounds and a simple scan. +- Evaluate rules sequentially with explicit fallthrough and source-version/rule/allocation diagnostics. +- Return structured `INVALID_COMPARISON` errors for invalid numeric/date comparisons instead of logging context values. Invalid conditions cannot match through negation. +- Retain the original hash function and threshold rollout filter for boolean cohort compatibility. The inclusive maximum distribution bucket selects the last nonzero allocation. -Use `newFlagEvaluator(flag)` for repeated checks: it prepares hash-set membership lookups and validated cumulative percentage thresholds once, including filters nested under groups and negations. Evaluations reuse those structures, scan precomputed percentage thresholds, and reuse per-rule diagnostic scratch storage. - -Existing `evaluateFlagRules` and `newEvaluator` exports remain available for legacy consumers. SDK adoption of `/flags` and variant-valued public APIs is a separate change. +The runtime exports are `newEvaluator`, `flattenContext`, and `hashInt`. Removed APIs include `evaluateFlagRules`, `newFlagEvaluator`, `evaluateFlag`, the scalar `evaluate` helper, and the legacy `flattenJSON` / `unflattenJSON` helpers. SDK adoption of `/flags` and variant-valued public APIs is a separate change; unchanged consumers must stay on evaluator 1.x. diff --git a/.changeset/pin-legacy-node-evaluator.md b/.changeset/pin-legacy-node-evaluator.md new file mode 100644 index 000000000..523bc86f6 --- /dev/null +++ b/.changeset/pin-legacy-node-evaluator.md @@ -0,0 +1,5 @@ +--- +"@reflag/node-sdk": patch +--- + +Keep the existing Node SDK on evaluator 1.1.1 through the `@reflag/flag-evaluation-v1` npm alias. This preserves its legacy protocol and prevents the evaluator's v2 workspace release from automatically upgrading its dependency before SDK migration. diff --git a/package.json b/package.json index c8e3ecad6..de81c7f75 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "typescript": "^5.7.3" }, "resolutions": { + "@reflag/flag-evaluation-v1": "https://registry.npmjs.org/@reflag/flag-evaluation/-/flag-evaluation-1.1.1.tgz", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/packages/flag-evaluation/README.md b/packages/flag-evaluation/README.md index d8921d267..ec78a3bef 100644 --- a/packages/flag-evaluation/README.md +++ b/packages/flag-evaluation/README.md @@ -1,47 +1,64 @@ -# Flag evaluation - -## Protocol v2 +# Flag evaluation v2 Prepare a compiled flag once when its definition is loaded or refreshed, then reuse the evaluator for each context: ```ts -import { newFlagEvaluator } from "@reflag/flag-evaluation"; +import { newEvaluator } from "@reflag/flag-evaluation"; -const evaluate = newFlagEvaluator(compiledFlag); +const evaluate = newEvaluator(compiledFlag); const result = evaluate({ company: { id: "acme" }, user: { id: "alice" } }); -// Selected value (T | undefined); null is a valid value. -console.log(result.value); -// Metadata for diagnostics/exposure events, not the public SDK getFlag result. -console.log(result.sourceVersionId, result.variantKey, result.matchedRuleId); +if (result.resolved) { + // T, not T | undefined. An undefined variant value is still a success. + console.log(result.value); + // Required success metadata for diagnostics/exposure events. + console.log(result.variantKey, result.matchedRuleId); +} else { + // No variant was selected: use the SDK's fallback behavior. + console.log(result.errors); +} ``` -`CompiledFlag` and `VariantEvaluationResult` preserve the -caller's value type. Both evaluator functions infer `T` from the flag; it can be -any type. JSON validation belongs at the API/persistence boundary, not here. - -Rebuild the evaluator when the compiled definition changes. Treat definitions and -returned values as immutable. Each evaluation has independent diagnostics. - -- `ANY_OF` / `NOT_ANY_OF` candidate lists are converted to `Set`s at preparation, - including inside groups and negations. Scalar checks use hash lookups. An - array-valued context requires one lookup per examined context element, not a - traversal of the candidate list. -- Percentage validation and cumulative integer thresholds are computed once. - Checks hash the company/context attribute and scan the precomputed thresholds. - The inclusive maximum hash maps to the last nonzero allocation; 0% allocations - never receive traffic. +`CompiledFlag` and `EvaluationResult` preserve the caller's +value type. JSON validation belongs at the API/persistence boundary, not here. +Non-fatal diagnostics may also accompany a successful default/fallthrough result. + +Rebuild the evaluator when definitions change. Treat definitions and returned +values as immutable. Each evaluation has independent diagnostics. + +## Preparation and evaluation + +- `ANY_OF` / `NOT_ANY_OF` lists become private Sets at preparation, including + inside groups and negations. Scalars use hash lookups. Array-valued contexts + require a lookup per examined context element, not a candidate-list scan. +- Percentage validation and cumulative integer bounds are computed once and + stored with each destination. Checks hash the context attribute and scan those + bounds. The inclusive maximum hash maps to the last nonzero allocation; + 0% allocations never receive traffic. - Rules stop at the first selected variant. `nextRule` continues evaluation. Results include diagnostics only for visited rules. +- Invalid numeric/date comparisons produce `INVALID_COMPARISON` diagnostics and + cannot match through negation. The evaluator never writes to the console or + embeds operand values in error messages. + +## Migration from v1 -`evaluateFlag(flag, context)` is a one-shot convenience API: it prepares on each -call. Use `newFlagEvaluator` in SDK/server hot paths, not inside each `getFlag`. +V2 exports `newEvaluator(flag)`, `flattenContext`, and `hashInt`, plus their public +types. `newEvaluator` now accepts a compiled flag, not value rules. The returned +function accepts only context; the flag key is part of the definition. -## Legacy protocol +V1's `evaluateFlagRules`, scalar `evaluate`, `flattenJSON`, `unflattenJSON`, and +value-rule types are removed. There is no v2 one-shot evaluation wrapper. +The hash and `rolloutPercentage` filter remain to preserve boolean cohorts. -`evaluateFlagRules` and `newEvaluator` retain the legacy value-rule API. -Legacy rollout thresholds and hash cohorts are unchanged. +Consumers of the old `/features` protocol must continue using the published 1.x +package. In this monorepo, the unchanged Node SDK uses the explicitly pinned +`@reflag/flag-evaluation-v1` npm alias, so Changesets does not advance its evaluator +dependency to v2. A root Yarn resolution forces the published v1 tarball before +Changesets bumps this workspace from 1.x to 2.x; otherwise Yarn can transparently +link the alias back to the workspace. The alias is also +used as a test/benchmark reference, not as a runtime dependency of evaluator v2. ## Performance checks @@ -51,6 +68,6 @@ From the repository root: yarn workspace @reflag/flag-evaluation exec vitest bench --run test/variants.bench.ts ``` -Benchmarks exclude preparation and cover candidate-list sizes up to 100,000 and -percentage distributions up to 50 allocations. Unit tests additionally assert -cached Set usage and that repeated checks do not reread percentage definitions. +Benchmarks exclude preparation and compare with published v1 across candidate +lists up to 100,000 and percentage distributions up to 50 allocations. Unit tests +assert cached Set usage, no repeated percentage reads, and stable hash cohorts. diff --git a/packages/flag-evaluation/package.json b/packages/flag-evaluation/package.json index b7c6c8b44..8c1976334 100644 --- a/packages/flag-evaluation/package.json +++ b/packages/flag-evaluation/package.json @@ -30,6 +30,7 @@ "js-sha256": "0.11.0" }, "devDependencies": { + "@reflag/flag-evaluation-v1": "npm:@reflag/flag-evaluation@1.1.1", "@reflag/tsconfig": "^0.0.2", "@types/node": "^22.12.0", "ts-node": "^10.9.2", diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index e121b5c8e..c3bc70d99 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -1,79 +1,5 @@ import { sha256 } from "js-sha256"; -/** - * Represents a filter class with a specific type property. - * - * This type is intended to define the structure for objects - * that classify or categorize based on a particular filter type. - * - * Properties: - * - type: Specifies the classification type as a string. - */ -export type FilterClass = { - type: string; -}; - -/** - * Represents a group of filters that can be combined with a logical operator. - * - * @template T The type of filter class that defines the criteria within the filter group. - * @property type The fixed type indicator for this filter structure, always "group". - * @property operator The logical operator used to combine the filters in the group. It can be either "and" (all conditions must pass) or "or" (at least one condition must pass). - * @property filters An array of filter trees containing individual filters or nested groups of filters. - */ -export type FilterGroup = { - type: "group"; - operator: "and" | "or"; - filters: FilterTree[]; -}; - -/** - * Represents a filter negation structure for use within filtering systems. - * - * A `FilterNegation` is used to encapsulate a negation operation, - * which negates the conditions defined in the provided `filter`. - * - * @template T - A generic type that extends FilterClass, indicating the type of the filter. - * @property type - Specifies the type of this filter operation as "negation". - * @property filter - A `FilterTree` structure of type `T` that defines the filter conditions to be negated. - */ -export type FilterNegation = { - type: "negation"; - filter: FilterTree; -}; - -/** - * Represents a tree structure for filters that can be composed of filter groups, - * filter negations, or individual filter instances of a specified type. - * - * @template T - A type that extends the `FilterClass`. - */ -export type FilterTree = - | FilterGroup - | FilterNegation - | T; - -/** - * Represents a set of predefined operators that can be used to filter a specific context. - * These operators can express various conditions, including equality checks, comparison, - * set membership, and boolean evaluations. - * - * Possible values: - * - "IS": Exact scalar match, or an array containing exactly one matching element. - * - "IS_NOT": Negates scalar equality or singleton-array equality. - * - "ANY_OF": Checks if a value is present in a set of specified values. - * - "NOT_ANY_OF": Checks if a value is not present in a set of specified values. - * - "CONTAINS": Case-insensitive substring match for strings; exact, case-sensitive element membership for arrays. - * - "NOT_CONTAINS": Negates substring matching for strings or element membership for arrays. - * - "GT": Greater than comparison. - * - "LT": Less than comparison. - * - "AFTER": Compares if a value is after a specified point (e.g., time, rank). - * - "BEFORE": Compares if a value is before a specified point (e.g., time, rank). - * - "SET": Checks if a value is set or exists. - * - "NOT_SET": Checks if a value is not set or does not exist. - * - "IS_TRUE": Checks if a boolean value is true. - * - "IS_FALSE": Checks if a boolean value is false. - */ export type ContextFilterOperator = | "IS" | "IS_NOT" @@ -92,47 +18,14 @@ export type ContextFilterOperator = | "IS_TRUE" | "IS_FALSE"; -/** - * Represents a filter configuration used to filter data based on specific context. - * - * This interface defines the structure of a context filter, containing a field, - * an operator, and optional values to control the filtering behavior. - * - * The `type` property must always have the value "context" to classify filters - * of this type. - * - * The `field` property specifies the name of the context field to filter. - * - * The `operator` property defines the filtering operation to perform on the - * specified field (e.g., equals, contains, etc.). - * - * The optional `values` property is an array of strings that lists the values - * to be used in conjunction with the operator for filtering. - * - * This interface is typically utilized in contexts where data needs to be - * dynamically filtered based on specific criteria derived from contextual - * attributes. - */ export interface ContextFilter { type: "context"; field: string; operator: ContextFilterOperator; values?: string[]; - valueSet?: Set; } -/** - * Represents a filter configuration to enable percentage-based rollout of a flag or functionality. - * - * This type defines the necessary parameters to control access to a flag - * by evaluating a specific attribute and applying it against a defined percentage threshold. - * - * Properties: - * - `type` - Indicates the type of the filter. For this filter type, it will always be "rolloutPercentage". - * - `key` - A unique key or identifier that distinguishes this rollout filter. - * - `partialRolloutAttribute` - Specifies the attribute used to evaluate eligibility for the rollout. - * - `partialRolloutThreshold` - A numeric value representing the upper-bound threshold (0-100000) for the percentage-based rollout. - */ +/** Retained for boolean cohort compatibility, including the original hash keys. */ export type PercentageRolloutFilter = { type: "rolloutPercentage"; key: string; @@ -140,86 +33,108 @@ export type PercentageRolloutFilter = { partialRolloutThreshold: number; }; -/** - * Represents a constant filter configuration. - * - * The ConstantFilter type is used to define a filter configuration with a fixed, - * immutable value. It always evaluates to the specified boolean `value`. - * - * @property {string} type - Indicates the type of filter, which is always "constant". - * @property {boolean} value - The fixed boolean value for the filter. - */ -export type ConstantFilter = { - type: "constant"; - value: boolean; +export type ConstantFilter = { type: "constant"; value: boolean }; +export type FilterClass = { type: string }; +export type FilterGroup = { + type: "group"; + operator: "and" | "or"; + filters: FilterTree[]; }; - -/** - * A composite type for representing a rule-based filter system. - * - * This type is constructed using a `FilterTree` structure that consists of - * nested filters of the following types: - * - `ContextFilter`: A filter that evaluates based on specified context criteria. - * - `PercentageRolloutFilter`: A filter that performs a percentage-based rollout. - * - `ConstantFilter`: A filter that evaluates based on fixed conditions or constants. - * - * `RuleFilter` is typically used in scenarios where a hierarchical filtering mechanism - * is needed to determine outcomes based on multiple layered conditions. - */ +export type FilterNegation = { + type: "negation"; + filter: FilterTree; +}; +export type FilterTree = + | FilterGroup + | FilterNegation + | T; export type RuleFilter = FilterTree< ContextFilter | PercentageRolloutFilter | ConstantFilter >; -/** - * Represents a value that can be used in a rule configuration. - * - * RuleValue can take on different types, allowing flexibility based on the - * specific rule's requirements. This can include: - * - A boolean value: to represent true/false conditions. - * - A string: typically used for textual or keyword-based rules. - * - A number: for numerical rules or thresholds. - * - An object: for more complex rule definitions or configurations. - * - * This type is useful for accommodating various rule structures in applications - * that work with dynamic or user-defined regulations. - */ -type RuleValue = boolean | string | number | object; - -/** - * Represents a rule that defines a filtering criterion and an associated value. - * - * @template T - Specifies the type of the associated value that extends RuleValue. - * @property {RuleFilter} filter - The filtering criterion used by the rule. - * @property {T} value - The value associated with the rule. - */ -export interface Rule { - filter: RuleFilter; - value: T; -} - export type NormalizedContextValue = string | string[]; export type FlattenedContext = Record; export type EvaluationError = + | { code: "MISSING_CONTEXT_FIELD"; field: string; message: string } | { - code: "MISSING_CONTEXT_FIELD"; + code: "UNSUPPORTED_ARRAY_OPERATOR"; field: string; + operator: + | ContextFilterOperator + | "rolloutPercentage" + | "percentageDistribution"; message: string; } | { - code: "UNSUPPORTED_ARRAY_OPERATOR"; + code: "INVALID_COMPARISON"; field: string; - operator: ContextFilterOperator | "rolloutPercentage"; + operator: ContextFilterOperator; message: string; + } + | { code: "INVALID_FLAG_DEFINITION"; message: string }; + +export type VariantDestination = + | { type: "variant"; variantKey: string } + | { type: "nextRule" }; +export type RuleResult = + | { type: "variant"; variantKey: string } + | { + type: "percentageDistribution"; + key: string; + attribute: string; + allocations: Array<{ + percentage: number; + destination: VariantDestination; + }>; }; +export type CompiledRule = { + id: string; + filter: RuleFilter; + result: RuleResult; +}; +export type CompiledFlag = { + key: string; + sourceVersionId: string; + variants: Record; + rules: CompiledRule[]; +}; + +export type RuleEvaluationResult = { + ruleId: string; + matched: boolean; + allocationIndex?: number; + destination?: VariantDestination; +}; + +/** A resolved variant can contain any T, including undefined. */ +export type EvaluationResult = { + flagKey: string; + sourceVersionId: string; + context: FlattenedContext; + ruleResults: RuleEvaluationResult[]; + errors: EvaluationError[]; +} & ( + | { + resolved: true; + value: T; + variantKey: string; + matchedRuleId: string; + allocationIndex?: number; + } + | { + resolved: false; + value: undefined; + variantKey?: never; + matchedRuleId?: never; + allocationIndex?: never; + } +); function normalizeArrayElement(value: unknown): string | undefined { if (value === undefined) return undefined; if (value === null) return ""; if (typeof value !== "object") return String(value); - - // Composite array elements are outside the targeting model. Keep their - // behavior explicit by comparing their JSON encoding. try { return JSON.stringify(value); } catch { @@ -227,717 +142,369 @@ function normalizeArrayElement(value: unknown): string | undefined { } } -function normalizeArray(value: unknown[]): string[] { - return value.flatMap((entry) => { - const normalized = normalizeArrayElement(entry); - return normalized === undefined ? [] : [normalized]; - }); -} - -/** - * Flattens context for evaluation while preserving arrays as leaf values. - * Primitive array elements use the same string coercion as scalar values; - * composite elements are JSON encoded. - */ +/** Flatten nested context, preserving arrays as normalized leaf values. */ export function flattenContext(data: object): FlattenedContext { const result = Object.create(null) as FlattenedContext; - function recurse(value: unknown, prop: string): void { if (value === undefined) return; - if (value === null) { result[prop] = ""; } else if (Array.isArray(value)) { - result[prop] = normalizeArray(value); + result[prop] = value.flatMap((entry) => { + const normalized = normalizeArrayElement(entry); + return normalized === undefined ? [] : [normalized]; + }); } else if (typeof value !== "object") { result[prop] = String(value); } else { const entries = Object.entries(value); - if (entries.length === 0) { + if (!entries.length) { result[prop] = ""; return; } - - for (const [key, entry] of entries) { + for (const [key, entry] of entries) recurse(entry, prop ? `${prop}.${key}` : key); - } } } - - if (Object.keys(data).length > 0) recurse(data, ""); + if (Object.keys(data).length) recurse(data, ""); return result; } -/** - * Flattens a nested JSON object into a single-level object, with keys indicating the nesting levels. - * Keys in the resulting object are represented in a dot notation to reflect the nesting structure of the original data. - * - * @param {object} data - The nested JSON object to be flattened. - * @return {Record} A flattened JSON object with "stringified" keys and values. - */ -export function flattenJSON(data: object): Record { - const result: Record = {}; - - if (Object.keys(data).length === 0) { - return result; - } - - function recurse(value: any, prop: string) { - if (value === undefined) { - return; - } - - if (value === null) { - result[prop] = ""; - } else if (typeof value !== "object") { - result[prop] = String(value); - } else if (Array.isArray(value)) { - if (value.length === 0) { - result[prop] = ""; - } - - for (let i = 0; i < value.length; i++) { - recurse(value[i], prop ? prop + "." + i : "" + i); - } - } else { - let isEmpty = true; +/** The original SHA-256 cohort hash, with an inclusive range of 0..100000. */ +export function hashInt(hashInput: string): number { + const value = + new DataView(sha256.create().update(hashInput).arrayBuffer()).getUint32( + 0, + true, + ) & 0xfffff; + return Math.floor((value / 0xfffff) * 100000); +} - for (const p in value) { - isEmpty = false; - recurse(value[p], prop ? prop + "." + p : p); - } +// Prepared lookup structures never appear in the public/wire filter types. +type PreparedContextFilter = ContextFilter & { valueSet?: Set }; +type PreparedFilter = FilterTree< + PreparedContextFilter | PercentageRolloutFilter | ConstantFilter +>; +type Distribution = Extract; +type PreparedDistribution = Omit & { + allocations: Array<{ upperBound: number; destination: VariantDestination }>; + error?: string; +}; +type PreparedRule = { + id: string; + filter: PreparedFilter; + result: Exclude | PreparedDistribution; +}; +type PreparedFlag = Omit, "rules"> & { + rules: PreparedRule[]; +}; - if (isEmpty) { - result[prop] = ""; - } - } +function prepareFilter(filter: RuleFilter): PreparedFilter { + if (filter.type === "group") + return { ...filter, filters: filter.filters.map(prepareFilter) }; + if (filter.type === "negation") + return { ...filter, filter: prepareFilter(filter.filter) }; + if ( + filter.type === "context" && + (filter.operator === "ANY_OF" || filter.operator === "NOT_ANY_OF") + ) { + // Keep only the Set's candidate references, not the source array as well. + return { ...filter, values: [], valueSet: new Set(filter.values ?? []) }; } - - recurse(data, ""); - return result; + return { ...filter }; } -/** - * Converts a flattened JSON object with dot-separated keys into a nested JSON object. - * - * @param {Record} data - The flattened JSON object where keys are dot-separated representing nested levels. - * @return {Record} The unflattened JSON object with nested structure restored. - */ -export function unflattenJSON(data: Record): Record { - const result: Record = {}; - // Traversing these properties on a plain object can reach Object.prototype. - const unsafePathSegments = new Set(["__proto__", "constructor", "prototype"]); - - for (const i of Object.keys(data)) { - const keys = i.split("."); - - if (keys.some((key) => unsafePathSegments.has(key))) { - continue; - } - - keys.reduce((acc, key, index) => { - if (index === keys.length - 1) { - if (typeof acc === "object") { - acc[key] = data[i]; - } - } else if (!acc[key]) { - acc[key] = {}; - } - - return acc[key]; - }, result); - } +function prepareRule(rule: CompiledRule): PreparedRule { + const filter = prepareFilter(rule.filter); + if (rule.result.type === "variant") + return { id: rule.id, filter, result: { ...rule.result } }; + let total = 0; + let valid = rule.result.allocations.length > 0; + const allocations = rule.result.allocations.map( + ({ percentage, destination }) => { + const units = Math.round(percentage * 1000); + valid &&= + Number.isFinite(percentage) && + percentage >= 0 && + percentage <= 100 && + Math.abs(percentage * 1000 - units) <= 1e-8; + total += units; + return { upperBound: total, destination: { ...destination } }; + }, + ); + return { + id: rule.id, + filter, + result: { + ...rule.result, + allocations, + ...(!valid || total !== 100000 + ? { error: `Invalid percentage allocations in rule ${rule.id}` } + : {}), + }, + }; +} - return result; +function missingField( + errors: Map, + field: string, +): void { + errors.set(`missing:${field}`, { + code: "MISSING_CONTEXT_FIELD", + field, + message: `Context field "${field}" is required to evaluate targeting rules.`, + }); } -/** - * Generates a hashed integer based on the input string. The method extracts 20 bits from the hash, - * scales it to a range between 0 and 100000, and returns the resultant integer. - * - * @param {string} hashInput - The input string used to generate the hash. - * @return {number} A number between 0 and 100000 derived from the hash of the input string. - */ -export function hashInt(hashInput: string): number { - // 1. hash the key and the partial rollout attribute - // 2. take 20 bits from the hash and divide by 2^20 - 1 to get a number between 0 and 1 - // 3. multiply by 100000 to get a number between 0 and 100000 and compare it to the threshold - // - // we only need 20 bits to get to 100000 because 2^20 is 1048576 - const value = - new DataView(sha256.create().update(hashInput).arrayBuffer()).getUint32( - 0, - true, - ) & 0xfffff; +function unsupportedArray( + errors: Map, + field: string, + operator: + | ContextFilterOperator + | "rolloutPercentage" + | "percentageDistribution", +): void { + errors.set(`array:${field}:${operator}`, { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field, + operator, + message: `Operator ${operator} does not support array-valued context field "${field}".`, + }); +} - return Math.floor((value / 0xfffff) * 100000); +function invalidComparison( + errors: Map, + filter: ContextFilter, +): false { + errors.set(`comparison:${filter.field}:${filter.operator}`, { + code: "INVALID_COMPARISON", + field: filter.field, + operator: filter.operator, + // Do not log or embed context values; they may contain sensitive data. + message: `Operator ${filter.operator} requires valid comparison values for context field "${filter.field}".`, + }); + return false; } -const ARRAY_OPERATORS = new Set([ - "IS", - "IS_NOT", - "CONTAINS", - "NOT_CONTAINS", - "ANY_OF", - "NOT_ANY_OF", - "SET", - "NOT_SET", -]); +function invalidDefinition( + errors: Map, + message: string, +): void { + errors.set("invalid", { code: "INVALID_FLAG_DEFINITION", message }); +} -/** - * Evaluates a scalar or array field value against an operator and comparison values. - */ -export function evaluate( - normalizedFieldValue: NormalizedContextValue, - operator: ContextFilterOperator, - values: string[], - valueSet?: Set, +function compare( + fieldValue: NormalizedContextValue, + filter: PreparedContextFilter, + errors: Map, ): boolean { - const value = values[0]; - - if (Array.isArray(normalizedFieldValue)) { + const { operator, valueSet } = filter; + const value = filter.values?.[0]; + if (operator === "ANY_OF" || operator === "NOT_ANY_OF") { + // Always prepared at definition load, including within groups/negations. + const matches = Array.isArray(fieldValue) + ? fieldValue.some((entry) => valueSet!.has(entry)) + : valueSet!.has(fieldValue); + return operator === "ANY_OF" ? matches : !matches; + } + if (Array.isArray(fieldValue)) { switch (operator) { case "IS": return ( typeof value === "string" && - normalizedFieldValue.length === 1 && - normalizedFieldValue[0] === value + fieldValue.length === 1 && + fieldValue[0] === value ); case "IS_NOT": return ( typeof value === "string" && - !( - normalizedFieldValue.length === 1 && - normalizedFieldValue[0] === value - ) + !(fieldValue.length === 1 && fieldValue[0] === value) ); case "CONTAINS": - return ( - typeof value === "string" && normalizedFieldValue.includes(value) - ); + return typeof value === "string" && fieldValue.includes(value); case "NOT_CONTAINS": - return ( - typeof value === "string" && !normalizedFieldValue.includes(value) - ); - case "ANY_OF": { - const candidates = valueSet ?? new Set(values); - return normalizedFieldValue.some((entry) => candidates.has(entry)); - } - case "NOT_ANY_OF": { - const candidates = valueSet ?? new Set(values); - return !normalizedFieldValue.some((entry) => candidates.has(entry)); - } + return typeof value === "string" && !fieldValue.includes(value); case "SET": - return normalizedFieldValue.length > 0; + return fieldValue.length > 0; case "NOT_SET": - return normalizedFieldValue.length === 0; + return fieldValue.length === 0; default: - // Numeric, date, and boolean operators are scalar-only. - // Do not accidentally stringify arrays for comparison. + unsupportedArray(errors, filter.field, operator); return false; } } - switch (operator) { case "CONTAINS": return ( typeof value === "string" && - normalizedFieldValue.toLowerCase().includes(value.toLowerCase()) + fieldValue.toLowerCase().includes(value.toLowerCase()) ); case "NOT_CONTAINS": return ( typeof value === "string" && - !normalizedFieldValue.toLowerCase().includes(value.toLowerCase()) + !fieldValue.toLowerCase().includes(value.toLowerCase()) ); case "GT": - if (isNaN(Number(normalizedFieldValue)) || isNaN(Number(value))) { - // TODO: return error instead? used logger previously - console.error( - `GT operator requires numeric values: ${normalizedFieldValue}, ${value}`, - ); - return false; - } - return Number(normalizedFieldValue) > Number(value); - case "LT": - if (isNaN(Number(normalizedFieldValue)) || isNaN(Number(value))) { - console.error( - `LT operator requires numeric values: ${normalizedFieldValue}, ${value}`, - ); - return false; - } - return Number(normalizedFieldValue) < Number(value); + case "LT": { + const left = Number(fieldValue), + right = Number(value); + if (!Number.isFinite(left) || !Number.isFinite(right)) + return invalidComparison(errors, filter); + return operator === "GT" ? left > right : left < right; + } case "AFTER": case "BEFORE": { - // more/less than `value` days ago const daysAgo = new Date(); - daysAgo.setDate(daysAgo.getDate() - Number(value)); - const fieldValueDate = new Date(normalizedFieldValue).getTime(); - + const days = Number(value); + daysAgo.setDate(daysAgo.getDate() - days); + const timestamp = new Date(fieldValue).getTime(); + if ( + !Number.isFinite(days) || + Number.isNaN(timestamp) || + Number.isNaN(daysAgo.getTime()) + ) { + return invalidComparison(errors, filter); + } return operator === "AFTER" - ? fieldValueDate > daysAgo.getTime() - : fieldValueDate < daysAgo.getTime(); + ? timestamp > daysAgo.getTime() + : timestamp < daysAgo.getTime(); } case "DATE_AFTER": case "DATE_BEFORE": { - const fieldValueDate = new Date(normalizedFieldValue).getTime(); - const valueDate = new Date(value).getTime(); - if (isNaN(fieldValueDate) || isNaN(valueDate)) { - console.error( - `${operator} operator requires valid date values: ${normalizedFieldValue}, ${value}`, - ); - return false; - } - return operator === "DATE_AFTER" - ? fieldValueDate >= valueDate - : fieldValueDate <= valueDate; + const left = new Date(fieldValue).getTime(); + const right = value === undefined ? NaN : new Date(value).getTime(); + if (Number.isNaN(left) || Number.isNaN(right)) + return invalidComparison(errors, filter); + return operator === "DATE_AFTER" ? left >= right : left <= right; } case "SET": - return normalizedFieldValue !== ""; + return fieldValue !== ""; case "NOT_SET": - return normalizedFieldValue === ""; + return fieldValue === ""; case "IS": - return normalizedFieldValue === value; + return fieldValue === value; case "IS_NOT": - return normalizedFieldValue !== value; - case "ANY_OF": - return valueSet - ? valueSet.has(normalizedFieldValue) - : values.includes(normalizedFieldValue); - case "NOT_ANY_OF": - return valueSet - ? !valueSet.has(normalizedFieldValue) - : !values.includes(normalizedFieldValue); + return fieldValue !== value; case "IS_TRUE": - return normalizedFieldValue == "true"; + return fieldValue === "true"; case "IS_FALSE": - return normalizedFieldValue == "false"; + return fieldValue === "false"; default: - console.error(`unknown operator: ${operator}`); - return false; + return invalidComparison(errors, filter); } } -function addUnsupportedArrayOperatorError( - errors: Map, - field: string, - operator: ContextFilterOperator | "rolloutPercentage", -): void { - const message = - operator === "rolloutPercentage" - ? `Percentage rollout does not support array-valued context field "${field}".` - : `Operator ${operator} does not support array-valued context field "${field}".`; - errors.set(`${field}:${operator}`, { - code: "UNSUPPORTED_ARRAY_OPERATOR", - field, - operator, - message, - }); -} - -function addMissingContextFieldError( - errors: Map, - field: string, -): void { - errors.set(`missing:${field}`, { - code: "MISSING_CONTEXT_FIELD", - field, - message: `Context field "${field}" is required to evaluate targeting rules.`, - }); -} - -function evaluateRecursively( - filter: RuleFilter, +function evaluateFilter( + filter: PreparedFilter, context: FlattenedContext, errors: Map, ): boolean { switch (filter.type) { case "constant": return filter.value; - case "context": { + case "context": if ( !(filter.field in context) && filter.operator !== "SET" && filter.operator !== "NOT_SET" ) { - addMissingContextFieldError(errors, filter.field); + missingField(errors, filter.field); return false; } - - const normalizedFieldValue = context[filter.field] ?? ""; - if ( - Array.isArray(normalizedFieldValue) && - !ARRAY_OPERATORS.has(filter.operator) - ) { - addUnsupportedArrayOperatorError(errors, filter.field, filter.operator); - return false; - } - - return evaluate( - normalizedFieldValue, - filter.operator, - filter.values || [], - filter.valueSet, - ); - } + return compare(context[filter.field] ?? "", filter, errors); case "rolloutPercentage": { if (!(filter.partialRolloutAttribute in context)) { - addMissingContextFieldError(errors, filter.partialRolloutAttribute); + missingField(errors, filter.partialRolloutAttribute); return false; } - - const normalizedRolloutValue = context[filter.partialRolloutAttribute]; - if (Array.isArray(normalizedRolloutValue)) { - addUnsupportedArrayOperatorError( + const value = context[filter.partialRolloutAttribute]; + if (Array.isArray(value)) { + unsupportedArray( errors, filter.partialRolloutAttribute, "rolloutPercentage", ); return false; } - - const hashVal = hashInt(`${filter.key}.${normalizedRolloutValue}`); - return hashVal < filter.partialRolloutThreshold; + return hashInt(`${filter.key}.${value}`) < filter.partialRolloutThreshold; } - case "group": { + case "group": for (const child of filter.filters) { - const matched = evaluateRecursively(child, context, errors); + const matched = evaluateFilter(child, context, errors); if (filter.operator === "and" && !matched) return false; if (filter.operator === "or" && matched) return true; } return filter.operator === "and"; - } case "negation": - return !evaluateRecursively(filter.filter, context, errors); + return !evaluateFilter(filter.filter, context, errors); default: + invalidDefinition(errors, "Unsupported filter type"); return false; } } -/** - * Represents the parameters required for evaluating rules against a specific flag in a given context. - * - * @template T - The type of the rule value used in evaluation. - * - * @property {string} flagKey - The key that identifies the specific flag to be evaluated. - * @property {Rule[]} rules - An array of rules used for evaluation. - * @property {Record} context - The contextual data used during the evaluation process. - */ -export interface EvaluationParams { - flagKey: string; - rules: Rule[]; - context: Record; -} - -/** - * Represents the result of an evaluation process for a specific flag and its associated rules. - * - * @template T - The type of the rule value being evaluated. - * - * @property {string} flagKey - The unique key identifying the flag being evaluated. - * @property {T | undefined} value - The resolved value of the flag, if the evaluation is successful. - * @property {Record} context - The normalized contextual information used during evaluation. - * @property {boolean[]} ruleEvaluationResults - Array indicating the success or failure of each rule evaluated. - * @property {string} [reason] - Optional field providing additional explanation regarding the evaluation result. - * @property {string[]} [missingContextFields] - Legacy array of context fields that were required but not provided during evaluation. - * @property {EvaluationError[]} [errors] - Non-fatal diagnostics for rules that could not be evaluated. - */ -export interface EvaluationResult { - flagKey: string; - value: T | undefined; - context: Record; - ruleEvaluationResults: boolean[]; - reason?: string; - /** @deprecated Use `errors` and check for `MISSING_CONTEXT_FIELD`. */ - missingContextFields?: string[]; - errors?: EvaluationError[]; -} - -export function evaluateFlagRules({ - context, - flagKey, - rules, -}: EvaluationParams): EvaluationResult { - const flatContext = flattenContext(context); - const evaluationErrors = new Map(); - - const ruleEvaluationResults = rules.map((rule) => { - const ruleErrors = new Map(); - const matched = evaluateRecursively(rule.filter, flatContext, ruleErrors); - for (const [key, error] of ruleErrors) evaluationErrors.set(key, error); - - // An invalid condition must fail the entire rule, even when wrapped in a - // negation or combined with another condition that would otherwise match. - return ruleErrors.size === 0 && matched; - }); - - const errors = Array.from(evaluationErrors.values()); - const missingContextFields = errors.flatMap((error) => - error.code === "MISSING_CONTEXT_FIELD" ? [error.field] : [], - ); - - const firstMatchedRuleIndex = ruleEvaluationResults.findIndex(Boolean); - const firstMatchedRule = - firstMatchedRuleIndex > -1 ? rules[firstMatchedRuleIndex] : undefined; - return { - value: firstMatchedRule?.value, - flagKey, - context: flatContext, - ruleEvaluationResults, - reason: - firstMatchedRuleIndex > -1 - ? `rule #${firstMatchedRuleIndex} matched` - : "no matched rules", - missingContextFields, - ...(errors.length > 0 ? { errors } : {}), - }; -} - -function prepareFilter(filter: RuleFilter): RuleFilter { - if (filter.type === "group") { - return { ...filter, filters: filter.filters.map(prepareFilter) }; - } - if (filter.type === "negation") { - return { ...filter, filter: prepareFilter(filter.filter) }; - } - if ( - filter.type === "context" && - (filter.operator === "ANY_OF" || filter.operator === "NOT_ANY_OF") - ) { - // The Set owns the candidate references; don't retain the source array in - // the prepared filter as well. Keep an empty array to avoid per-check []s. - return { ...filter, values: [], valueSet: new Set(filter.values ?? []) }; - } - return { ...filter }; -} - -export function newEvaluator(rules: Rule[]) { - const translatedRules = rules.map((rule) => ({ - ...rule, - filter: prepareFilter(rule.filter), - })); - - return function evaluateOptimized( - context: Record, - flagKey: string, - ) { - return evaluateFlagRules({ - context, - flagKey, - rules: translatedRules, - }); - }; -} - -export type VariantDestination = - | { type: "variant"; variantKey: string } - | { type: "nextRule" }; - -export type RuleResult = - | { type: "variant"; variantKey: string } - | { - type: "percentageDistribution"; - key: string; - attribute: string; - allocations: Array<{ - percentage: number; - destination: VariantDestination; - }>; - }; - -export type CompiledRule = { - id: string; - filter: RuleFilter; - result: RuleResult; -}; - -export type CompiledFlag = { - key: string; - sourceVersionId: string; - variants: Record; - rules: CompiledRule[]; -}; - -export type VariantEvaluationError = - | EvaluationError - | { code: "INVALID_FLAG_DEFINITION"; message: string } - | { - code: "UNSUPPORTED_ARRAY_OPERATOR"; - field: string; - operator: "percentageDistribution"; - message: string; - }; - -export type VariantEvaluationResult = { - flagKey: string; - sourceVersionId: string; - value: T | undefined; - variantKey?: string; - matchedRuleId?: string; - allocationIndex?: number; - context: FlattenedContext; - ruleResults: Array<{ - ruleId: string; - matched: boolean; - allocationIndex?: number; - destination?: VariantDestination; - }>; - errors: VariantEvaluationError[]; -}; - -type PreparedRule = CompiledRule & { - thresholds?: number[]; - allocationError?: string; -}; -type PreparedFlag = Omit, "rules"> & { - rules: PreparedRule[]; -}; - -function prepareVariantRule(rule: CompiledRule): PreparedRule { - const prepared: PreparedRule = { - ...rule, - filter: prepareFilter(rule.filter), - result: { ...rule.result }, - }; - if (rule.result.type !== "percentageDistribution") return prepared; - - let total = 0; - let valid = rule.result.allocations.length > 0; - const thresholds: number[] = []; - for (const { percentage } of rule.result.allocations) { - const units = Math.round(percentage * 1000); - valid &&= - Number.isFinite(percentage) && - percentage >= 0 && - percentage <= 100 && - Math.abs(percentage * 1000 - units) <= 1e-8; - total += units; - thresholds.push(total); - } - if (!valid || total !== 100000) { - prepared.allocationError = `Invalid percentage allocations in rule ${rule.id}`; - } - prepared.thresholds = thresholds; - prepared.result = { - ...rule.result, - allocations: rule.result.allocations.map((allocation) => ({ - ...allocation, - destination: { ...allocation.destination }, - })), - }; - return prepared; -} - -/** Prepare once per definition refresh, then reuse across evaluation contexts. - * ANY_OF/NOT_ANY_OF candidates become Sets, including inside negations/groups. - * Percentage validation and cumulative threshold construction happen only here. - */ -export function newFlagEvaluator(flag: CompiledFlag) { +/** Prepare once on definition refresh, then reuse for all evaluation contexts. */ +export function newEvaluator( + flag: CompiledFlag, +): (context: Record) => EvaluationResult { const prepared: PreparedFlag = { ...flag, variants: { ...flag.variants }, - rules: flag.rules.map(prepareVariantRule), + rules: flag.rules.map(prepareRule), }; - return (context: Record): VariantEvaluationResult => - evaluatePreparedFlag(prepared, context); -} - -/** One-shot protocol-v2 evaluation. For repeated checks use newFlagEvaluator. */ -export function evaluateFlag( - flag: CompiledFlag, - context: Record, -): VariantEvaluationResult { - return newFlagEvaluator(flag)(context); -} - -function finishVariantEvaluation( - result: VariantEvaluationResult, - errors: Map, -): VariantEvaluationResult { - if (errors.size) result.errors = Array.from(errors.values()); - return result; -} - -function invalidVariantDefinition( - result: VariantEvaluationResult, - errors: Map, - message: string, -): VariantEvaluationResult { - errors.set("invalid", { code: "INVALID_FLAG_DEFINITION", message }); - return finishVariantEvaluation(result, errors); + return (context) => evaluatePreparedFlag(prepared, context); } function evaluatePreparedFlag( flag: PreparedFlag, context: Record, -): VariantEvaluationResult { +): EvaluationResult { const flatContext = flattenContext(context); - const errors = new Map(); - // Reuse scratch diagnostics across rules instead of allocating one Map per rule. + const errors = new Map(); const filterErrors = new Map(); - const result: VariantEvaluationResult = { - flagKey: flag.key, - sourceVersionId: flag.sourceVersionId, - value: undefined, - context: flatContext, - ruleResults: [], - errors: [], - }; + const ruleResults: RuleEvaluationResult[] = []; for (const rule of flag.rules) { filterErrors.clear(); - const matched = evaluateRecursively(rule.filter, flatContext, filterErrors); + const matched = evaluateFilter(rule.filter, flatContext, filterErrors); for (const [key, error] of filterErrors) errors.set(key, error); - const ruleResult: VariantEvaluationResult["ruleResults"][number] = { + const ruleResult: RuleEvaluationResult = { ruleId: rule.id, - matched: matched && filterErrors.size === 0, + matched: matched && !filterErrors.size, }; - result.ruleResults.push(ruleResult); + ruleResults.push(ruleResult); if (!ruleResult.matched) continue; let destination: VariantDestination; if (rule.result.type === "percentageDistribution") { const distribution = rule.result; - if (rule.allocationError) { - return invalidVariantDefinition(result, errors, rule.allocationError); + if (distribution.error) { + invalidDefinition(errors, distribution.error); + break; } - if ( - !Object.prototype.hasOwnProperty.call( - flatContext, - distribution.attribute, - ) - ) { - errors.set(`missing:${distribution.attribute}`, { - code: "MISSING_CONTEXT_FIELD", - field: distribution.attribute, - message: `Context field "${distribution.attribute}" is required to evaluate targeting rules.`, - }); + if (!(distribution.attribute in flatContext)) { + missingField(errors, distribution.attribute); continue; } - const attributeValue = flatContext[distribution.attribute]; - if (Array.isArray(attributeValue)) { - errors.set(`array:${distribution.attribute}:percentageDistribution`, { - code: "UNSUPPORTED_ARRAY_OPERATOR", - field: distribution.attribute, - operator: "percentageDistribution", - message: `Context field "${distribution.attribute}" must be scalar for percentage distribution.`, - }); + const value = flatContext[distribution.attribute]; + if (Array.isArray(value)) { + unsupportedArray( + errors, + distribution.attribute, + "percentageDistribution", + ); continue; } - // Only the inclusive endpoint is clamped. This also keeps trailing 0% - // allocations empty, without moving any other company's bucket. - const bucket = Math.min( - hashInt(`${distribution.key}.${attributeValue}`), - 99999, - ); - const thresholds = rule.thresholds!; - let allocationIndex = 0; + // Preserve every bucket except the inclusive endpoint; 0% stays empty. + const bucket = Math.min(hashInt(`${distribution.key}.${value}`), 99999); + const { allocations } = distribution; + let index = 0; while ( - allocationIndex < thresholds.length - 1 && - bucket >= thresholds[allocationIndex] - ) { - allocationIndex++; - } - ruleResult.allocationIndex = allocationIndex; - destination = distribution.allocations[allocationIndex].destination; + index < allocations.length - 1 && + bucket >= allocations[index].upperBound + ) + index++; + ruleResult.allocationIndex = index; + destination = allocations[index].destination; } else { destination = rule.result; } @@ -949,23 +516,37 @@ function evaluatePreparedFlag( destination.variantKey, ) ) { - return invalidVariantDefinition( - result, + invalidDefinition( errors, `Unknown variant ${destination.variantKey} in rule ${rule.id}`, ); + break; } - result.value = flag.variants[destination.variantKey]; - result.variantKey = destination.variantKey; - result.matchedRuleId = rule.id; - if (ruleResult.allocationIndex !== undefined) { - result.allocationIndex = ruleResult.allocationIndex; - } - return finishVariantEvaluation(result, errors); + return { + resolved: true, + flagKey: flag.key, + sourceVersionId: flag.sourceVersionId, + context: flatContext, + ruleResults, + errors: Array.from(errors.values()), + value: flag.variants[destination.variantKey], + variantKey: destination.variantKey, + matchedRuleId: rule.id, + allocationIndex: ruleResult.allocationIndex, + }; } - return invalidVariantDefinition( - result, - errors, - "No rule selected a variant; a catch-all default is required", - ); + if (!errors.has("invalid")) + invalidDefinition( + errors, + "No rule selected a variant; a catch-all default is required", + ); + return { + resolved: false, + value: undefined, + flagKey: flag.key, + sourceVersionId: flag.sourceVersionId, + context: flatContext, + ruleResults, + errors: Array.from(errors.values()), + }; } diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 56fc48174..d64ea2d25 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -1,1191 +1,110 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { - evaluate, - evaluateFlagRules, - EvaluationParams, + ContextFilterOperator, flattenContext, - flattenJSON, hashInt, newEvaluator, - Rule, - unflattenJSON, + RuleFilter, } from "../src"; -const flag = { - flagKey: "flag", - rules: [ - { - value: true, - filter: { - type: "group", - operator: "and", - filters: [ - { - type: "context", - field: "company.id", - operator: "IS", - values: ["company1"], - }, - { - type: "rolloutPercentage", - key: "flag", - partialRolloutAttribute: "company.id", - partialRolloutThreshold: 100000, - }, - ], - }, - }, - ], -} satisfies Omit, "context">; - -describe("evaluate flag targeting integration ", () => { - it("evaluates all kinds of filters", async () => { - const res = evaluateFlagRules({ - flagKey: "flag", - rules: [ - { - value: true, - filter: { - type: "group", - operator: "and", - filters: [ - { - type: "context", - field: "company.id", - operator: "IS", - values: ["company1"], - }, - { - type: "rolloutPercentage", - key: "flag", - partialRolloutAttribute: "company.id", - partialRolloutThreshold: 99999, - }, - { - type: "group", - operator: "or", - filters: [ - { - type: "context", - field: "company.id", - operator: "IS", - values: ["company2"], - }, - { - type: "negation", - filter: { - type: "context", - field: "company.id", - operator: "IS", - values: ["company3"], - }, - }, - ], - }, - { - type: "negation", - filter: { - type: "constant", - value: false, - }, - }, - ], - }, - }, - ], - context: { - "company.id": "company1", - }, - }); - - expect(res).toEqual({ - value: true, - context: { - "company.id": "company1", - }, - flagKey: "flag", - missingContextFields: [], - reason: "rule #0 matched", - ruleEvaluationResults: [true], - }); - }); - - it("evaluates flag when there's no matching rule", async () => { - const res = evaluateFlagRules({ - ...flag, - context: { - company: { - id: "wrong value", - }, - }, - }); - - expect(res).toEqual({ - value: undefined, - context: { - "company.id": "wrong value", - }, - flagKey: "flag", - missingContextFields: [], - reason: "no matched rules", - ruleEvaluationResults: [false], - }); - }); - - it("evaluates targeting when there's a matching rule", async () => { - const context = { - company: { - id: "company1", - }, - }; - - const res = evaluateFlagRules({ - ...flag, - context, - }); - - expect(res).toEqual({ - value: true, - context: { - "company.id": "company1", - }, - flagKey: "flag", - missingContextFields: [], - reason: "rule #0 matched", - ruleEvaluationResults: [true], - }); - }); - - it("evaluates flag with missing values", async () => { - const res = evaluateFlagRules({ - flagKey: "flag", - rules: [ - { - value: { custom: "value" }, - filter: { - type: "group", - operator: "and", - filters: [ - { - type: "context", - field: "some_field", - operator: "IS", - values: [""], - }, - { - type: "rolloutPercentage", - key: "flag", - partialRolloutAttribute: "some_field", - partialRolloutThreshold: 99000, - }, - ], - }, - }, - ], - context: { - some_field: "", - }, - }); - - expect(res).toEqual({ - context: { - some_field: "", - }, - value: { custom: "value" }, - flagKey: "flag", - missingContextFields: [], - reason: "rule #0 matched", - ruleEvaluationResults: [true], - }); - }); - - it("evaluates percentage rollouts using user.id", () => { - const userRollout = { - flagKey: "test-flag", - rules: [ - { - value: true, - filter: { - type: "rolloutPercentage" as const, - key: "test-flag", - partialRolloutAttribute: "user.id", - partialRolloutThreshold: 50000, - }, - }, - ], - }; - - expect( - evaluateFlagRules({ - ...userRollout, - context: { user: { id: "user-1" } }, - }).value, - ).toBe(true); - expect( - evaluateFlagRules({ - ...userRollout, - context: { user: { id: "user-2" } }, - }).value, - ).toBeUndefined(); - }); - - it("returns list of missing context keys ", async () => { - const res = evaluateFlagRules({ - ...flag, - context: {}, - }); - - expect(res).toEqual({ - context: {}, - value: undefined, - reason: "no matched rules", - flagKey: "flag", - missingContextFields: ["company.id"], - errors: [ - { - code: "MISSING_CONTEXT_FIELD", - field: "company.id", - message: - 'Context field "company.id" is required to evaluate targeting rules.', - }, - ], - ruleEvaluationResults: [false], - }); - }); - - it("fails evaluation and includes key in missing keys when rollout attribute is missing from context", async () => { - const res = evaluateFlagRules({ - flagKey: "flag-1", - rules: [ - { - value: 123, - filter: { - type: "rolloutPercentage" as const, - key: "flag-1", - partialRolloutAttribute: "happening.id", - partialRolloutThreshold: 50000, - }, - }, - ], - context: {}, - }); - - expect(res).toEqual({ - flagKey: "flag-1", - context: {}, - value: undefined, - reason: "no matched rules", - missingContextFields: ["happening.id"], - errors: [ - { - code: "MISSING_CONTEXT_FIELD", - field: "happening.id", - message: - 'Context field "happening.id" is required to evaluate targeting rules.', - }, - ], - ruleEvaluationResults: [false], - }); - }); - - it("evaluates optimized rule evaluations correctly", async () => { - const res = newEvaluator([ - { - value: true, - filter: { - type: "group", - operator: "and", - filters: [ - { - type: "context", - field: "company.id", - operator: "IS", - values: ["company1"], - }, - { - type: "rolloutPercentage", - key: "flag", - partialRolloutAttribute: "company.id", - partialRolloutThreshold: 99999, - }, - { - type: "group", - operator: "or", - filters: [ - { - type: "context", - field: "company.id", - operator: "ANY_OF", - values: ["company2"], - }, - { - type: "negation", - filter: { - type: "context", - field: "company.id", - operator: "IS", - values: ["company3"], - }, - }, - ], - }, - { - type: "negation", - filter: { - type: "constant", - value: false, - }, - }, - ], - }, - }, - ])( +function evaluateFilter(filter: RuleFilter, context: Record) { + return newEvaluator({ + key: "filter", + sourceVersionId: "version-1", + variants: { yes: true, no: false }, + rules: [ + { id: "target", filter, result: { type: "variant", variantKey: "yes" } }, { - "company.id": "company1", + id: "default", + filter: { type: "constant", value: true }, + result: { type: "variant", variantKey: "no" }, }, - "flag", - ); - - expect(res).toEqual({ - value: true, - context: { - "company.id": "company1", - }, - flagKey: "flag", - missingContextFields: [], - reason: "rule #0 matched", - ruleEvaluationResults: [true], - }); - }); - - describe("SET and NOT_SET operators", () => { - it("should handle `SET` operator with missing field value", () => { - const res = evaluateFlagRules({ - flagKey: "test_flag", - rules: [ - { - value: true, - filter: { - type: "context", - field: "user.name", - operator: "SET", - values: [], - }, - }, - ], - context: {}, - }); - - expect(res).toEqual({ - flagKey: "test_flag", - value: undefined, - context: {}, - ruleEvaluationResults: [false], - reason: "no matched rules", - missingContextFields: [], - }); - }); - - it("should handle `NOT_SET` operator with missing field value", () => { - const res = evaluateFlagRules({ - flagKey: "test_flag", - rules: [ - { - value: true, - filter: { - type: "context", - field: "user.name", - operator: "NOT_SET", - values: [], - }, - }, - ], - context: {}, - }); - - expect(res).toEqual({ - flagKey: "test_flag", - value: true, - context: {}, - ruleEvaluationResults: [true], - reason: "rule #0 matched", - missingContextFields: [], - }); - }); - - it("should handle `SET` operator with empty string field value", () => { - const res = evaluateFlagRules({ - flagKey: "test_flag", - rules: [ - { - value: true, - filter: { - type: "context", - field: "user.name", - operator: "SET", - values: [], - }, - }, - ], - context: { - user: { - name: "", - }, - }, - }); - - expect(res).toEqual({ - flagKey: "test_flag", - value: undefined, - context: { - "user.name": "", - }, - ruleEvaluationResults: [false], - reason: "no matched rules", - missingContextFields: [], - }); - }); - - it("should handle `NOT_SET` operator with empty string field value", () => { - const res = evaluateFlagRules({ - flagKey: "test_flag", - rules: [ - { - value: true, - filter: { - type: "context", - field: "user.name", - operator: "NOT_SET", - values: [], - }, - }, - ], - context: { - user: { - name: "", - }, - }, - }); - - expect(res).toEqual({ - flagKey: "test_flag", - value: true, - context: { - "user.name": "", - }, - ruleEvaluationResults: [true], - reason: "rule #0 matched", - missingContextFields: [], - }); - }); - }); - - it.each([ - { - context: { "company.id": "company1" }, - expected: true, - }, - { - context: { "company.id": "company2" }, - expected: true, - }, - { - context: { "company.id": "company3" }, - expected: false, - }, - ])( - "%#: evaluates optimized rule evaluations correctly", - async ({ context, expected }) => { - const evaluator = newEvaluator([ - { - value: true, - filter: { - type: "group", - operator: "and", - filters: [ - { - type: "context", - field: "company.id", - operator: "ANY_OF", - values: ["company1", "company2"], - }, - ], - }, - }, - ]); - - const res = evaluator(context, "flag-1"); - expect(res.value ?? false).toEqual(expected); - }, - ); - - describe("array-valued context", () => { - it("evaluates ANY_OF using array intersection", () => { - const res = evaluateFlagRules({ - flagKey: "role-based-flag", - rules: [ - { - value: true, - filter: { - type: "context", - field: "user.roles", - operator: "ANY_OF", - values: ["admin", "owner"], - }, - }, - ], - context: { - user: { roles: ["viewer", "admin"] }, - }, - }); - - expect(res).toEqual({ - flagKey: "role-based-flag", - value: true, - context: { "user.roles": ["viewer", "admin"] }, - ruleEvaluationResults: [true], - reason: "rule #0 matched", - missingContextFields: [], - }); - }); - - describe.each(["CONTAINS", "NOT_CONTAINS"] as const)( - "%s normalization", - (operator) => { - it.each(["admin", "2", "true", "", '{"level":3}', "[false]"])( - "matches normalized element %j without diagnostics", - (value) => { - const rules: Rule[] = [ - { - value: "matched", - filter: { - type: "context", - field: "user.roles", - operator, - values: [value], - }, - }, - ]; - const context = { - user: { roles: ["admin", 2, true, null, { level: 3 }, [false]] }, - }; - const expected = operator === "CONTAINS"; - for (const result of [ - evaluateFlagRules({ flagKey: "array", rules, context }), - newEvaluator(rules)(context, "array"), - ]) { - expect(result.value).toBe(expected ? "matched" : undefined); - expect(result.ruleEvaluationResults).toEqual([expected]); - expect(result.errors).toBeUndefined(); - expect(result.missingContextFields).toEqual([]); - } - }, - ); - }, - ); - - it.each(["CONTAINS", "NOT_CONTAINS"] as const)( - "allows negation of array %s without treating it as an error", - (operator) => { - const result = evaluateFlagRules({ - flagKey: "negated-membership", - rules: [ - { - value: true, - filter: { - type: "negation", - filter: { - type: "context", - field: "user.roles", - operator, - values: ["admin"], - }, - }, - }, - ], - context: { user: { roles: ["admin"] } }, - }); - expect(result.ruleEvaluationResults).toEqual([ - operator === "NOT_CONTAINS", - ]); - expect(result.errors).toBeUndefined(); - }, - ); - - describe.each(["IS", "IS_NOT"] as const)( - "%s singleton arrays", - (operator) => { - it.each([ - { entries: [2], candidate: "2", equal: true }, - { entries: [true], candidate: "true", equal: true }, - { entries: [null], candidate: "", equal: true }, - { entries: [{ level: 3 }], candidate: '{"level":3}', equal: true }, - { entries: [[false]], candidate: "[false]", equal: true }, - { entries: [2, 2], candidate: "2", equal: false }, - { entries: [], candidate: "2", equal: false }, - ])( - "evaluates $entries against $candidate without errors", - ({ entries, candidate, equal }) => { - const rules: Rule[] = [ - { - value: true, - filter: { - type: "context", - field: "user.roles", - operator, - values: [candidate], - }, - }, - ]; - const context = { user: { roles: entries } }; - for (const result of [ - evaluateFlagRules({ flagKey: "singleton", rules, context }), - newEvaluator(rules)(context, "singleton"), - ]) { - expect(result.ruleEvaluationResults).toEqual([ - operator === "IS" ? equal : !equal, - ]); - expect(result.errors).toBeUndefined(); - } - }, - ); - - it("fails closed for missing fields, even under negation", () => { - const result = evaluateFlagRules({ - flagKey: "missing", - rules: [ - { - value: true, - filter: { - type: "negation", - filter: { - type: "context", - field: "user.roles", - operator, - values: ["admin"], - }, - }, - }, - ], - context: { user: {} }, - }); - expect(result.ruleEvaluationResults).toEqual([false]); - expect(result.missingContextFields).toEqual(["user.roles"]); - expect(result.errors?.[0].code).toBe("MISSING_CONTEXT_FIELD"); - }); - }, - ); - - it("keeps JSON-looking strings scalar", () => { - const evaluator = newEvaluator([ - { - value: "matched", - filter: { - type: "context", - field: "user.roles", - operator: "ANY_OF", - values: ['["viewer","admin"]'], - }, - }, - ]); - - expect( - evaluator({ user: { roles: '["viewer","admin"]' } }, "scalar").value, - ).toBe("matched"); - }); - - it("does not apply percentage rollout to arrays", () => { - const res = evaluateFlagRules({ - flagKey: "rollout", - rules: [ - { - value: true, - filter: { - type: "rolloutPercentage", - key: "rollout", - partialRolloutAttribute: "user.ids", - partialRolloutThreshold: 100000, - }, - }, - ], - context: { user: { ids: ["u1"] } }, - }); - - expect(res.value).toBeUndefined(); - expect(res.missingContextFields).toEqual([]); - expect(res.errors).toEqual([ - { - code: "UNSUPPORTED_ARRAY_OPERATOR", - field: "user.ids", - operator: "rolloutPercentage", - message: - 'Percentage rollout does not support array-valued context field "user.ids".', - }, - ]); - }); - - it("returns a non-fatal diagnostic for scalar-only operators", () => { - const res = evaluateFlagRules({ - flagKey: "role-based-flag", - rules: [ - { - value: true, - filter: { - type: "context", - field: "user.roles", - operator: "GT", - values: ["admin"], - }, - }, - ], - context: { user: { roles: ["admin"] } }, - }); - - expect(res.value).toBeUndefined(); - expect(res.ruleEvaluationResults).toEqual([false]); - expect(res.errors).toEqual([ - { - code: "UNSUPPORTED_ARRAY_OPERATOR", - field: "user.roles", - operator: "GT", - message: - 'Operator GT does not support array-valued context field "user.roles".', - }, - ]); - }); - - it("fails the top-level rule when a nested condition produces an error", () => { - const res = evaluateFlagRules({ - flagKey: "invalid-rules", - rules: [ - { - value: "negated-array", - filter: { - type: "negation", - filter: { - type: "context", - field: "user.roles", - operator: "GT", - values: ["admin"], - }, - }, - }, - { - value: "negated-missing", - filter: { - type: "negation", - filter: { - type: "context", - field: "user.plan", - operator: "IS", - values: ["pro"], - }, - }, - }, - { - value: "matching-or", - filter: { - type: "group", - operator: "or", - filters: [ - { - type: "context", - field: "user.teams", - operator: "GT", - values: ["platform"], - }, - { type: "constant", value: true }, - ], - }, - }, - ], - context: { - user: { roles: ["admin"], teams: ["platform"] }, - }, - }); - - expect(res.value).toBeUndefined(); - expect(res.ruleEvaluationResults).toEqual([false, false, false]); - expect(res.errors?.map(({ code, field }) => ({ code, field }))).toEqual([ - { code: "UNSUPPORTED_ARRAY_OPERATOR", field: "user.roles" }, - { code: "MISSING_CONTEXT_FIELD", field: "user.plan" }, - { code: "UNSUPPORTED_ARRAY_OPERATOR", field: "user.teams" }, - ]); - }); - - it("does not report errors for short-circuited conditions", () => { - const res = evaluateFlagRules({ - flagKey: "conditional-context", - rules: [ - { - value: "admin", - filter: { - type: "group", - operator: "and", - filters: [ - { - type: "context", - field: "user.role", - operator: "SET", - }, - { - type: "context", - field: "user.role", - operator: "IS", - values: ["admin"], - }, - ], - }, - }, - { - value: "fallback", - filter: { - type: "group", - operator: "or", - filters: [ - { type: "constant", value: true }, - { - type: "context", - field: "user.department", - operator: "IS", - values: ["engineering"], - }, - ], - }, - }, - ], - context: { user: {} }, - }); - - expect(res.value).toBe("fallback"); - expect(res.ruleEvaluationResults).toEqual([false, true]); - expect(res.errors).toBeUndefined(); - expect(res.missingContextFields).toEqual([]); - }); - - it("does not report an array leaf as missing", () => { - const res = evaluateFlagRules({ - flagKey: "role-based-flag", - rules: [ - { - value: true, - filter: { - type: "context", - field: "user.roles", - operator: "ANY_OF", - values: ["owner"], - }, - }, - ], - context: { user: { roles: [] } }, - }); - - expect(res.context).toEqual({ "user.roles": [] }); - expect(res.missingContextFields).toEqual([]); - expect(res.value).toBeUndefined(); - }); - }); - - describe("DATE_AFTER and DATE_BEFORE in flag rules", () => { - it("should evaluate DATE_AFTER operator in flag rules", () => { - const res = evaluateFlagRules({ - flagKey: "time_based_flag", - rules: [ - { - value: "enabled", - filter: { - type: "context", - field: "user.createdAt", - operator: "DATE_AFTER", - values: ["2024-01-01"], - }, - }, - ], - context: { - user: { - createdAt: "2024-06-15", - }, - }, - }); - - expect(res).toEqual({ - flagKey: "time_based_flag", - value: "enabled", - context: { - "user.createdAt": "2024-06-15", - }, - ruleEvaluationResults: [true], - reason: "rule #0 matched", - missingContextFields: [], - }); - }); - - it("should evaluate DATE_BEFORE operator in flag rules", () => { - const res = evaluateFlagRules({ - flagKey: "legacy_flag", - rules: [ - { - value: "enabled", - filter: { - type: "context", - field: "user.lastLogin", - operator: "DATE_BEFORE", - values: ["2024-12-31"], - }, - }, - ], - context: { - user: { - lastLogin: "2024-01-15", - }, - }, - }); - - expect(res).toEqual({ - flagKey: "legacy_flag", - value: "enabled", - context: { - "user.lastLogin": "2024-01-15", - }, - ruleEvaluationResults: [true], - reason: "rule #0 matched", - missingContextFields: [], - }); - }); - - it("should handle complex rules with DATE_AFTER and DATE_BEFORE in groups", () => { - const res = evaluateFlagRules({ - flagKey: "time_window_flag", - rules: [ - { - value: "active", - filter: { - type: "group", - operator: "and", - filters: [ - { - type: "context", - field: "event.startDate", - operator: "DATE_AFTER", - values: ["2024-01-01"], - }, - { - type: "context", - field: "event.endDate", - operator: "DATE_BEFORE", - values: ["2024-12-31"], - }, - ], - }, - }, - ], - context: { - event: { - startDate: "2024-06-01", - endDate: "2024-11-30", - }, - }, - }); - - expect(res).toEqual({ - flagKey: "time_window_flag", - value: "active", - context: { - "event.startDate": "2024-06-01", - "event.endDate": "2024-11-30", - }, - ruleEvaluationResults: [true], - reason: "rule #0 matched", - missingContextFields: [], - }); - }); - - it("should fail when DATE_AFTER condition is not met", () => { - const res = evaluateFlagRules({ - flagKey: "future_flag", - rules: [ - { - value: "enabled", - filter: { - type: "context", - field: "user.signupDate", - operator: "DATE_AFTER", - values: ["2024-12-01"], - }, - }, - ], - context: { - user: { - signupDate: "2024-01-15", // Too early - }, - }, - }); - - expect(res).toEqual({ - flagKey: "future_flag", - value: undefined, - context: { - "user.signupDate": "2024-01-15", - }, - ruleEvaluationResults: [false], - reason: "no matched rules", - missingContextFields: [], - }); - }); - - it("should fail when DATE_BEFORE condition is not met", () => { - const res = evaluateFlagRules({ - flagKey: "past_flag", - rules: [ - { - value: "enabled", - filter: { - type: "context", - field: "user.lastActivity", - operator: "DATE_BEFORE", - values: ["2024-01-01"], - }, - }, - ], - context: { - user: { - lastActivity: "2024-06-15", // Too late - }, - }, - }); - - expect(res).toEqual({ - flagKey: "past_flag", - value: undefined, - context: { - "user.lastActivity": "2024-06-15", - }, - ruleEvaluationResults: [false], - reason: "no matched rules", - missingContextFields: [], - }); - }); - - it("should work with optimized evaluator", () => { - const evaluator = newEvaluator([ - { - value: "time_sensitive", - filter: { - type: "group", - operator: "and", - filters: [ - { - type: "context", - field: "user.subscriptionDate", - operator: "DATE_AFTER", - values: ["2024-01-01"], - }, - { - type: "context", - field: "user.trialEndDate", - operator: "DATE_BEFORE", - values: ["2024-12-31"], - }, - ], - }, - }, - ]); - - const res = evaluator( - { - user: { - subscriptionDate: "2024-03-15", - trialEndDate: "2024-09-30", - }, - }, - "subscription_flag", - ); - - expect(res).toEqual({ - flagKey: "subscription_flag", - value: "time_sensitive", - context: { - "user.subscriptionDate": "2024-03-15", - "user.trialEndDate": "2024-09-30", - }, - ruleEvaluationResults: [true], - reason: "rule #0 matched", - missingContextFields: [], - }); - }); - }); -}); - -describe("operator evaluation", () => { + ], + })(context); +} +function evaluate( + value: string | string[], + operator: ContextFilterOperator, + values: string[], +) { + return evaluateFilter( + { type: "context", field: "value", operator, values }, + { value }, + ).value; +} + +describe("operator evaluation through the v2 API", () => { beforeAll(() => { vi.useFakeTimers().setSystemTime(new Date("2024-01-10")); }); - afterAll(() => { vi.useRealTimers(); }); - - const tests = [ + it.each([ ["value", "IS", "value", true], - ["value", "IS", "wrong value", false], + ["value", "IS", "wrong", false], ["value", "IS_NOT", "value", false], - ["value", "IS_NOT", "wrong value", true], - + ["value", "IS_NOT", "wrong", true], ["value", "ANY_OF", "value", true], ["value", "ANY_OF", "nope", false], ["value", "NOT_ANY_OF", "value", false], ["value", "NOT_ANY_OF", "nope", true], - ["value", "IS_TRUE", "", false], + ["true", "IS_TRUE", "", true], ["value", "IS_FALSE", "", false], - + ["false", "IS_FALSE", "", true], ["value", "SET", "", true], ["", "SET", "", false], ["value", "NOT_SET", "", false], ["", "NOT_SET", "", true], - - // non numeric values should return false ["value", "GT", "value", false], ["value", "GT", "0", false], ["1", "GT", "0", true], ["2", "GT", "10", false], ["10", "GT", "2", true], - ["value", "LT", "value", false], ["value", "LT", "0", false], ["0", "LT", "1", true], ["2", "LT", "10", true], ["10", "LT", "2", false], - ["start VALUE end", "CONTAINS", "value", true], ["alue", "CONTAINS", "value", false], ["start VALUE end", "NOT_CONTAINS", "value", false], ["alue", "NOT_CONTAINS", "value", true], - - // today is 2024-01-10 - // 2024-01-10 - 5 days = 2024-01-05 - ["2024-01-15", "BEFORE", "5", false], // 2024-01-15 is before 2024-01-05 = false - ["2024-01-15", "AFTER", "5", true], // 2024-01-15 is after 2024-01-05 = true - ["2024-01-01", "BEFORE", "5", true], // 2024-01-01 is before 2024-01-05 = true - ["2024-01-01", "AFTER", "5", false], // 2024-01-01 is after 2024-01-05 = false - ] as const; - - for (const [value, op, filterValue, expected] of tests) { - it(`evaluates '${value}' ${op} 2024-01-10 minus ${filterValue} days = ${expected}`, () => { - const res = evaluate(value, op, [filterValue]); - expect(res).toEqual(expected); - }); - } - - it.each(["CONTAINS", "NOT_CONTAINS"] as const)( - "returns false for %s without comparison values", - (operator) => { - expect(evaluate("value", operator, [])).toBe(false); - expect(evaluate(["value"], operator, [])).toBe(false); - expect(evaluate([], operator, [])).toBe(false); - }, - ); + ["2024-01-15", "BEFORE", "5", false], + ["2024-01-15", "AFTER", "5", true], + ["2024-01-01", "BEFORE", "5", true], + ["2024-01-01", "AFTER", "5", false], + ["2024-01-15", "DATE_AFTER", "2024-01-10", true], + ["2024-01-10", "DATE_AFTER", "2024-01-10", true], + ["2024-01-05", "DATE_AFTER", "2024-01-10", false], + ["2024-12-31", "DATE_AFTER", "2024-01-01", true], + ["2023-01-01", "DATE_AFTER", "2024-01-01", false], + ["2024-01-05", "DATE_BEFORE", "2024-01-10", true], + ["2024-01-10", "DATE_BEFORE", "2024-01-10", true], + ["2024-01-15", "DATE_BEFORE", "2024-01-10", false], + ["2023-01-01", "DATE_BEFORE", "2024-01-01", true], + ["2024-12-31", "DATE_BEFORE", "2024-01-01", false], + ["2024-01-10T10:30:00Z", "DATE_AFTER", "2024-01-10T10:00:00Z", true], + ["2024-01-10T09:30:00Z", "DATE_BEFORE", "2024-01-10T10:00:00Z", true], + [ + "2024-01-10T10:30:00.123Z", + "DATE_AFTER", + "2024-01-10T10:00:00.000Z", + true, + ], + [ + "2024-01-10T09:30:00.123Z", + "DATE_BEFORE", + "2024-01-10T10:00:00.000Z", + true, + ], + ["01/15/2024", "DATE_AFTER", "01/10/2024", true], + ["01/05/2024", "DATE_BEFORE", "01/10/2024", true], + ] as const)("%s %s %s = %s", (value, operator, comparison, expected) => { + expect(evaluate(value, operator, [comparison])).toBe(expected); + }); it.each([ [["a"], "IS", ["a"], true], @@ -1236,11 +155,71 @@ describe("operator evaluation", () => { ["[1,true]", "ANY_OF", ["true"], false], ["[1,true]", "ANY_OF", ["[1,true]"], true], ] as const)( - "evaluates array semantics for %j %s %j", - (fieldValue, operator, values, expected) => { - expect( - evaluate(fieldValue as string | string[], operator, [...values]), - ).toBe(expected); + "array semantics: %j %s %j", + (value, operator, values, expected) => { + expect(evaluate(value as string | string[], operator, [...values])).toBe( + expected, + ); + }, + ); + + it.each(["CONTAINS", "NOT_CONTAINS"] as const)( + "%s without values remains false", + (operator) => { + for (const value of ["value", ["value"], []]) + expect(evaluate(value, operator, [])).toBe(false); + }, + ); +}); + +describe("invalid conditions", () => { + it.each([ + "GT", + "LT", + "AFTER", + "BEFORE", + "DATE_AFTER", + "DATE_BEFORE", + ] as const)( + "%s reports invalid operands, even under negation/OR, without logging values", + (operator) => { + const log = vi.spyOn(console, "error"); + try { + for (const [fieldValue, values] of [ + ["sensitive-invalid-value", ["1"]], + ["2024-01-01", ["sensitive-invalid-value"]], + ["Infinity", ["1"]], + ["1", []], + ] as const) { + const filter: RuleFilter = { + type: "context", + field: "value", + operator, + values: [...values], + }; + for (const wrapper of [ + filter, + { type: "negation", filter } as const, + { + type: "group", + operator: "or", + filters: [filter, { type: "constant", value: true }], + } as RuleFilter, + ]) { + const result = evaluateFilter(wrapper, { value: fieldValue }); + expect(result.value).toBe(false); + expect(result.errors).toMatchObject([ + { code: "INVALID_COMPARISON", field: "value", operator }, + ]); + expect(JSON.stringify(result.errors)).not.toContain( + "sensitive-invalid-value", + ); + } + } + expect(log).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } }, ); @@ -1253,72 +232,113 @@ describe("operator evaluation", () => { "DATE_BEFORE", "IS_TRUE", "IS_FALSE", - ] as const)("does not apply scalar operator %s to arrays", (operator) => { - expect(evaluate(["a"], operator, ["a"])).toBe(false); - }); - - describe("DATE_AFTER and DATE_BEFORE operators", () => { - const dateTests = [ - // DATE_AFTER tests - ["2024-01-15", "DATE_AFTER", "2024-01-10", true], // After - ["2024-01-10", "DATE_AFTER", "2024-01-10", true], // Same date (>=) - ["2024-01-05", "DATE_AFTER", "2024-01-10", false], // Before - ["2024-12-31", "DATE_AFTER", "2024-01-01", true], // Much later - ["2023-01-01", "DATE_AFTER", "2024-01-01", false], // Much earlier - - // DATE_BEFORE tests - ["2024-01-05", "DATE_BEFORE", "2024-01-10", true], // Before - ["2024-01-10", "DATE_BEFORE", "2024-01-10", true], // Same date (<=) - ["2024-01-15", "DATE_BEFORE", "2024-01-10", false], // After - ["2023-01-01", "DATE_BEFORE", "2024-01-01", true], // Much earlier - ["2024-12-31", "DATE_BEFORE", "2024-01-01", false], // Much later - - // Edge cases with different date formats - ["2024-01-10T10:30:00Z", "DATE_AFTER", "2024-01-10T10:00:00Z", true], // ISO format with time - ["2024-01-10T09:30:00Z", "DATE_BEFORE", "2024-01-10T10:00:00Z", true], // ISO format with time - [ - "2024-01-10T10:30:00.123Z", - "DATE_AFTER", - "2024-01-10T10:00:00.000Z", - true, - ], // ISO format with time and milliseconds - [ - "2024-01-10T09:30:00.123Z", - "DATE_BEFORE", - "2024-01-10T10:00:00.000Z", - true, - ], // ISO format with time and milliseconds - ["01/15/2024", "DATE_AFTER", "01/10/2024", true], // US format - ["01/05/2024", "DATE_BEFORE", "01/10/2024", true], // US format - ] as const; + ] as const)( + "%s never matches an array, including under negation", + (operator) => { + const filter: RuleFilter = { + type: "context", + field: "value", + operator, + values: ["a"], + }; + for (const wrapper of [filter, { type: "negation", filter } as const]) { + const result = evaluateFilter(wrapper, { value: ["a"] }); + expect(result.value).toBe(false); + expect(result.errors).toMatchObject([ + { code: "UNSUPPORTED_ARRAY_OPERATOR", field: "value", operator }, + ]); + } + }, + ); - for (const [fieldValue, operator, filterValue, expected] of dateTests) { - it(`evaluates '${fieldValue}' ${operator} '${filterValue}' = ${expected}`, () => { - const res = evaluate(fieldValue, operator, [filterValue]); - expect(res).toEqual(expected); - }); - } + it("fails repeated invalid rules even when the error is already recorded", () => { + const filter: RuleFilter = { + type: "negation", + filter: { + type: "context", + field: "missing", + operator: "IS", + values: ["x"], + }, + }; + const result = newEvaluator({ + key: "test", + sourceVersionId: "v1", + variants: { yes: true, no: false }, + rules: [ + { id: "one", filter, result: { type: "variant", variantKey: "yes" } }, + { id: "two", filter, result: { type: "variant", variantKey: "yes" } }, + { + id: "default", + filter: { type: "constant", value: true }, + result: { type: "variant", variantKey: "no" }, + }, + ], + })({}); + expect(result.value).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.ruleResults.map(({ matched }) => matched)).toEqual([ + false, + false, + true, + ]); + }); + + it("does not report errors for short-circuited filter branches", () => { + const result = evaluateFilter( + { + type: "group", + operator: "or", + filters: [ + { type: "constant", value: true }, + { + type: "context", + field: "missing", + operator: "GT", + values: ["bad"], + }, + ], + }, + {}, + ); + expect(result.value).toBe(true); + expect(result.errors).toEqual([]); + }); - it("handles invalid date formats gracefully", () => { - // Invalid dates should result in NaN comparisons and return false - expect(evaluate("invalid-date", "DATE_AFTER", ["2024-01-10"])).toBe( - false, - ); - expect(evaluate("2024-01-10", "DATE_AFTER", ["invalid-date"])).toBe( - false, - ); - expect(evaluate("invalid-date", "DATE_BEFORE", ["2024-01-10"])).toBe( - false, - ); - expect(evaluate("2024-01-10", "DATE_BEFORE", ["invalid-date"])).toBe( - false, + it.each(["SET", "NOT_SET"] as const)( + "allows %s on missing fields", + (operator) => { + const result = evaluateFilter( + { type: "context", field: "missing", operator }, + {}, ); - }); + expect(result.errors).toEqual([]); + expect(result.value).toBe(operator === "NOT_SET"); + }, + ); + + it("rejects array-valued legacy rollout context even under negation", () => { + const result = evaluateFilter( + { + type: "negation", + filter: { + type: "rolloutPercentage", + key: "key", + partialRolloutAttribute: "company.id", + partialRolloutThreshold: 50000, + }, + }, + { company: { id: ["a", "b"] } }, + ); + expect(result.value).toBe(false); + expect(result.errors).toMatchObject([ + { code: "UNSUPPORTED_ARRAY_OPERATOR", operator: "rolloutPercentage" }, + ]); }); }); -describe("rollout hash", () => { - const tests = [ +describe("stable rollout hash", () => { + it.each([ ["EEuoT8KShb", 38026], ["h7BOkvks5W", 81440], ["IZeSn3LCfJ", 80149], @@ -1369,59 +389,39 @@ describe("rollout hash", () => { ["0WEJv16LYd", 94865], ["dxV85hJ5t3", 96945], ["00d1uypkKy", 38988], - ] as const; - - for (const [input, expected] of tests) { - it(`evaluates '${input}' = ${expected}`, () => { - const res = hashInt(input); - expect(res).toEqual(expected); - }); - } + ] as const)("%s → %i", (input, expected) => { + expect(hashInt(input)).toBe(expected); + }); }); describe("flattenContext", () => { - it("preserves arrays as normalized leaf values", () => { + it("preserves arrays and normalizes primitive and composite entries", () => { expect( flattenContext({ user: { - id: "u1", roles: ["admin", "editor"], levels: [1, 2], states: [true, false], nullable: [null], + mixed: [{ role: "admin" }, [1, true]], }, }), ).toEqual({ - "user.id": "u1", "user.roles": ["admin", "editor"], "user.levels": ["1", "2"], "user.states": ["true", "false"], "user.nullable": [""], + "user.mixed": ['{"role":"admin"}', "[1,true]"], }); }); - - it("stores dangerous root keys without mutating the accumulator prototype", () => { + it("uses a null prototype for dangerous context keys", () => { const flattened = flattenContext( JSON.parse('{"__proto__":["safe"],"constructor":"value"}'), ); - expect(Object.getPrototypeOf(flattened)).toBeNull(); expect(flattened["__proto__"]).toEqual(["safe"]); expect(flattened.constructor).toBe("value"); }); - - it("JSON-encodes composite array elements without traversing them", () => { - expect( - flattenContext({ - other: { - values: [{ role: "admin" }, [1, true]], - }, - }), - ).toEqual({ - "other.values": ['{"role":"admin"}', "[1,true]"], - }); - }); - it("preserves nested scalar and empty-value behavior", () => { expect( flattenContext({ @@ -1430,6 +430,7 @@ describe("flattenContext", () => { emptyObject: {}, emptyArray: [], nil: null, + omitted: undefined, }, }), ).toEqual({ @@ -1438,418 +439,6 @@ describe("flattenContext", () => { "user.emptyArray": [], "user.nil": "", }); - }); -}); - -describe("flattenJSON", () => { - it("should handle an empty object correctly", () => { - const input = {}; - const output = flattenJSON(input); - - expect(output).toEqual({}); - }); - - it("should flatten a simple object", () => { - const input = { - a: { - b: "value", - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "a.b": "value", - }); - }); - - it("should flatten nested objects", () => { - const input = { - a: { - b: { - c: { - d: "value", - }, - }, - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "a.b.c.d": "value", - }); - }); - - it("should handle mixed data types", () => { - const input = { - a: { - b: "string", - c: 123, - d: true, - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "a.b": "string", - "a.c": "123", - "a.d": "true", - }); - }); - - it("should flatten arrays", () => { - const input = { - a: ["value1", "value2", "value3"], - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "a.0": "value1", - "a.1": "value2", - "a.2": "value3", - }); - }); - - it("should handle empty arrays", () => { - const input = { - a: [], - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - a: "", - }); - }); - - it("should correctly flatten mixed structures involving arrays and objects", () => { - const input = { - a: { - b: ["value1", { nested: "value2" }, "value3"], - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "a.b.0": "value1", - "a.b.1.nested": "value2", - "a.b.2": "value3", - }); - }); - - it("should flatten deeply nested objects", () => { - const input = { - level1: { - level2: { - level3: { - key: "value", - anotherKey: "anotherValue", - }, - }, - singleKey: "test", - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "level1.level2.level3.key": "value", - "level1.level2.level3.anotherKey": "anotherValue", - "level1.singleKey": "test", - }); - }); - - it("should handle objects with empty values", () => { - const input = { - a: { - b: "", - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "a.b": "", - }); - }); - - it("should handle null values", () => { - const input = { - a: null, - b: { - c: null, - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - a: "", - "b.c": "", - }); - }); - - it("should skip undefined values", () => { - const input = { - a: "value", - b: undefined, - c: { - d: undefined, - e: "another value", - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - a: "value", - "c.e": "another value", - }); - }); - - it("should handle empty nested objects", () => { - const input = { - a: {}, - b: { - c: {}, - d: "value", - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - a: "", - "b.c": "", - "b.d": "value", - }); - }); - - it("should handle top-level primitive values", () => { - const input = { - a: "simple", - b: 42, - c: true, - d: false, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - a: "simple", - b: "42", - c: "true", - d: "false", - }); - }); - - it("should handle arrays with null and undefined values", () => { - const input = { - a: ["value1", null, undefined, "value4"], - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "a.0": "value1", - "a.1": "", - "a.3": "value4", - }); - }); - - it("should handle deeply nested empty structures", () => { - const input = { - a: { - b: { - c: {}, - d: [], - }, - }, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "a.b.c": "", - "a.b.d": "", - }); - }); - - it("should handle keys with special characters", () => { - const input = { - "key.with.dots": "value1", - "key-with-dashes": "value2", - "key with spaces": "value3", - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - "key.with.dots": "value1", - "key-with-dashes": "value2", - "key with spaces": "value3", - }); - }); - - it("should handle edge case numbers and booleans", () => { - const input = { - zero: 0, - negativeNumber: -42, - float: 3.14, - infinity: Infinity, - negativeInfinity: -Infinity, - nan: NaN, - falseValue: false, - }; - - const output = flattenJSON(input); - - expect(output).toEqual({ - zero: "0", - negativeNumber: "-42", - float: "3.14", - infinity: "Infinity", - negativeInfinity: "-Infinity", - nan: "NaN", - falseValue: "false", - }); - }); -}); - -describe("unflattenJSON", () => { - it("should handle an empty object correctly", () => { - const input = {}; - const output = unflattenJSON(input); - - expect(output).toEqual({}); - }); - - it("should prevent prototype pollution", () => { - const unexpectedProperties = [ - "unexpectedConstructorPathProperty", - "unexpectedProtoPathProperty", - ]; - - for (const property of unexpectedProperties) { - delete Object.prototype[property]; - } - - try { - const output = unflattenJSON({ - "constructor.prototype.unexpectedConstructorPathProperty": "value", - "__proto__.unexpectedProtoPathProperty": "value", - }); - - expect(output).toEqual({}); - for (const property of unexpectedProperties) { - expect( - Object.prototype.hasOwnProperty.call(Object.prototype, property), - ).toBe(false); - } - } finally { - for (const property of unexpectedProperties) { - delete Object.prototype[property]; - } - } - }); - - it("should convert a flat object with one level deep keys to a nested object", () => { - const input = { - "a.b.c": "value", - "x.y": "anotherValue", - }; - - const output = unflattenJSON(input); - - expect(output).toEqual({ - a: { - b: { c: "value" }, - }, - x: { - y: "anotherValue", - }, - }); - }); - - it("should not handle arrays properly", () => { - const input = { - "arr.0": "first", - "arr.1": "second", - "arr.2": "third", - }; - - const output = unflattenJSON(input); - - expect(output).toEqual({ - arr: { - "0": "first", - "1": "second", - "2": "third", - }, - }); - }); - - it("should handle mixed data types in flat JSON", () => { - const input = { - "a.b": "string", - "a.c": 123, - "a.d": true, - }; - - const output = unflattenJSON(input); - - expect(output).toEqual({ - a: { - b: "string", - c: 123, - d: true, - }, - }); - }); - - it("should correctly handle scenarios with overlapping keys (ignore)", () => { - const input = { - "a.b": "value1", - "a.b.c": "value2", - }; - - const output = unflattenJSON(input); - expect(output).toEqual({ a: { b: "value1" } }); - }); - - it("should unflatten nested objects correctly", () => { - const input = { - "level1.level2.level3": "deepValue", - "level1.level2.key": 10, - "level1.singleKey": "test", - }; - - const output = unflattenJSON(input); - - expect(output).toEqual({ - level1: { - level2: { - level3: "deepValue", - key: 10, - }, - singleKey: "test", - }, - }); - }); - - it("should handle a scenario where a key is an empty string", () => { - const input = { - "": "rootValue", - }; - - const output = unflattenJSON(input); - - expect(output).toEqual({ - "": "rootValue", - }); + expect(flattenContext({})).toEqual({}); }); }); diff --git a/packages/flag-evaluation/test/release.test.ts b/packages/flag-evaluation/test/release.test.ts new file mode 100644 index 000000000..bbac83b49 --- /dev/null +++ b/packages/flag-evaluation/test/release.test.ts @@ -0,0 +1,24 @@ +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import * as legacy from "@reflag/flag-evaluation-v1"; + +import nodeSdk from "../../node-sdk/package.json"; + +describe("v1/v2 release isolation", () => { + it("resolves the legacy alias to the published package, not this workspace", () => { + // Catches Yarn transparent workspace linking even before dist is rebuilt. + expect(require.resolve("@reflag/flag-evaluation-v1/package.json")).not.toBe( + resolve(__dirname, "../package.json"), + ); + expect(typeof legacy.evaluateFlagRules).toBe("function"); + }); + + it("keeps the unchanged Node SDK off the v2 workspace dependency", () => { + expect(nodeSdk.dependencies).not.toHaveProperty("@reflag/flag-evaluation"); + expect(nodeSdk.dependencies["@reflag/flag-evaluation-v1"]).toBe( + "npm:@reflag/flag-evaluation@1.1.1", + ); + }); +}); diff --git a/packages/flag-evaluation/test/variants.bench.ts b/packages/flag-evaluation/test/variants.bench.ts index f64c9f7e2..fa53bf325 100644 --- a/packages/flag-evaluation/test/variants.bench.ts +++ b/packages/flag-evaluation/test/variants.bench.ts @@ -1,11 +1,8 @@ import { bench, describe } from "vitest"; -import { - CompiledFlag, - newEvaluator, - newFlagEvaluator, - RuleFilter, -} from "../src"; +import { newEvaluator as newLegacyEvaluator } from "@reflag/flag-evaluation-v1"; + +import { CompiledFlag, newEvaluator, RuleFilter } from "../src"; // Preparation is deliberately outside the timed callback, just as it should be // outside the SDK's getFlag hot path (rebuild only when definitions refresh). @@ -19,11 +16,11 @@ for (const size of [10, 1000, 100000]) { operator: "ANY_OF", values: Array.from({ length: size }, (_, i) => `candidate-${i}`), }; - const legacy = newEvaluator([ + const legacy = newLegacyEvaluator([ { filter, value: true }, { filter: { type: "constant", value: true }, value: false }, ]); - const prepared = newFlagEvaluator({ + const prepared = newEvaluator({ key: "boolean", sourceVersionId: "v1", variants: { true: true, false: false }, @@ -73,7 +70,7 @@ for (const size of [1, 10, 50]) { }, ], }; - const prepared = newFlagEvaluator(flag); + const prepared = newEvaluator(flag); bench("prepared v2", () => { prepared(context); }); diff --git a/packages/flag-evaluation/test/variants.test.ts b/packages/flag-evaluation/test/variants.test.ts index 2aee3fa11..780d5c022 100644 --- a/packages/flag-evaluation/test/variants.test.ts +++ b/packages/flag-evaluation/test/variants.test.ts @@ -1,13 +1,13 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { evaluateFlagRules } from "@reflag/flag-evaluation-v1"; + +import * as api from "../src"; import { CompiledFlag, ContextFilter, - evaluateFlag, - evaluateFlagRules, - hashInt, newEvaluator, - newFlagEvaluator, + hashInt, RuleResult, } from "../src"; @@ -16,7 +16,6 @@ const defaultRule = { filter: { type: "constant" as const, value: true }, result: { type: "variant" as const, variantKey: "control" }, }; - function flag(result: RuleResult): CompiledFlag { return { key: "model", @@ -28,8 +27,7 @@ function flag(result: RuleResult): CompiledFlag { ], }; } - -function split(): RuleResult { +function split(): Extract { return { type: "percentageDistribution", key: "split", @@ -46,82 +44,111 @@ function split(): RuleResult { ], }; } +const boundaries = [ + ["company-6295", 0, 0, "quality"], + ["company-20422", 59999, 0, "quality"], + ["company-27366", 60000, 1, "control"], + ["company-161395", 99999, 1, "control"], + ["company-1208267", 100000, 1, "control"], +] as const; + +describe("v2 public API", () => { + it("exposes only the prepared evaluator and shared context/hash helpers", () => { + expect(Object.keys(api).sort()).toEqual([ + "flattenContext", + "hashInt", + "newEvaluator", + ]); + expectTypeOf<"valueSet">().not.toMatchTypeOf(); + }); -describe("protocol-v2 evaluation", () => { - it("preserves explicit boolean and nullable value types in both APIs", () => { - const booleanFlag: CompiledFlag = { + it("narrows boolean/nullable values and required metadata on resolved", () => { + const definition: CompiledFlag = { ...flag({ type: "variant", variantKey: "quality" }), - variants: { quality: true, control: false }, + variants: { quality: true }, }; - expectTypeOf(evaluateFlag(booleanFlag, {}).value).toEqualTypeOf< - boolean | undefined - >(); - expectTypeOf(newFlagEvaluator(booleanFlag)({}).value).toEqualTypeOf< - boolean | undefined - >(); + const result = newEvaluator(definition)({}); + expectTypeOf(result.value).toEqualTypeOf(); + expect(result.resolved).toBe(true); + if (!result.resolved) throw new Error("Expected a selected variant"); + expectTypeOf(result.value).toEqualTypeOf(); + expectTypeOf(result.variantKey).toEqualTypeOf(); + expectTypeOf(result.matchedRuleId).toEqualTypeOf(); - const nullableFlag: CompiledFlag = { - ...booleanFlag, - variants: { quality: null, control: 0 }, + const nullable: CompiledFlag = { + ...definition, + variants: { quality: null }, }; - const result = newFlagEvaluator(nullableFlag)({}); - expectTypeOf(result.value).toEqualTypeOf(); - expect(result.value).toBeNull(); + const selected = newEvaluator(nullable)({}); + if (!selected.resolved) throw new Error("Expected null variant"); + expectTypeOf(selected.value).toEqualTypeOf(); + expect(selected.value).toBeNull(); }); - it("infers non-JSON value types and returns the selected value unchanged", () => { + it("infers unconstrained values without converting or cloning them", () => { const value = new Date("2026-01-01T00:00:00Z"); const definition = { ...flag({ type: "variant", variantKey: "quality" }), variants: { quality: value }, }; - const result = evaluateFlag(definition, {}); - const preparedResult = newFlagEvaluator(definition)({}); - expectTypeOf(result.value).toEqualTypeOf(); - expectTypeOf(preparedResult.value).toEqualTypeOf(); + const result = newEvaluator(definition)({}); + if (!result.resolved) throw new Error("Expected a selected variant"); + expectTypeOf(result.value).toEqualTypeOf(); expect(result.value).toBe(value); - expect(preparedResult.value).toBe(value); - const callback = () => "hello"; - const callbackFlag = { ...definition, variants: { quality: callback } }; - const selected = newFlagEvaluator(callbackFlag)({}).value; - expectTypeOf(selected).toEqualTypeOf(); - expect(selected).toBe(callback); + const selected = newEvaluator({ + ...definition, + variants: { quality: callback }, + })({}); + expectTypeOf(selected.value).toEqualTypeOf(); + expect(selected.value).toBe(callback); }); + it("distinguishes a selected undefined value from failure", () => { + const definition: CompiledFlag = { + ...flag({ type: "variant", variantKey: "quality" }), + variants: { quality: undefined }, + }; + expect(newEvaluator(definition)({})).toMatchObject({ + resolved: true, + value: undefined, + variantKey: "quality", + }); + const failed = newEvaluator({ ...definition, rules: [] })({}); + expect(failed).toMatchObject({ resolved: false, value: undefined }); + if (failed.resolved) throw new Error("Expected failure"); + expectTypeOf(failed.value).toEqualTypeOf(); + expectTypeOf(failed.variantKey).toEqualTypeOf(); + }); +}); + +describe("variant selection", () => { it.each( [null, false, true, 0, "", [1, false], { model: "large" }].map((value) => ({ value, })), - )( - "returns JSON value $value without treating it as an unresolved result", - ({ value }) => { - const definition = flag({ type: "variant", variantKey: "quality" }); - definition.variants.quality = value; - expect(evaluateFlag(definition, {})).toMatchObject({ - sourceVersionId: "version-1", - value, - variantKey: "quality", - matchedRuleId: "split-rule", - errors: [], - }); - }, - ); + )("selects $value", ({ value }) => { + const definition = flag({ type: "variant", variantKey: "quality" }); + definition.variants.quality = value; + expect(newEvaluator(definition)({})).toMatchObject({ + resolved: true, + value, + variantKey: "quality", + matchedRuleId: "split-rule", + sourceVersionId: "version-1", + errors: [], + }); + }); - it.each([ - ["company-6295", 0, 0, "quality"], - ["company-20422", 59999, 0, "quality"], - ["company-27366", 60000, 1, "control"], - ["company-161395", 99999, 1, "control"], - ["company-1208267", 100000, 1, "control"], - ] as const)( + it.each(boundaries)( "assigns %s at hash %i to allocation %i", (id, bucket, allocationIndex, variantKey) => { expect(hashInt(`split.${id}`)).toBe(bucket); - const result = evaluateFlag(flag(split()), { company: { id } }); + const result = newEvaluator(flag(split()))({ company: { id } }); expect(result).toMatchObject({ - variantKey, + resolved: true, allocationIndex, + variantKey, matchedRuleId: "split-rule", errors: [], }); @@ -129,11 +156,11 @@ describe("protocol-v2 evaluation", () => { }, ); - it("honors compiled user targets before company targets and ordered rules", () => { + it("honors compiled user targets before company targets and rules", () => { const definition = flag({ type: "variant", variantKey: "list" }); definition.rules.unshift( { - id: "user-target", + id: "user", filter: { type: "context", field: "user.id", @@ -143,7 +170,7 @@ describe("protocol-v2 evaluation", () => { result: { type: "variant", variantKey: "quality" }, }, { - id: "company-target", + id: "company", filter: { type: "context", field: "company.id", @@ -153,43 +180,34 @@ describe("protocol-v2 evaluation", () => { result: { type: "variant", variantKey: "control" }, }, ); + const evaluate = newEvaluator(definition); expect( - evaluateFlag(definition, { - user: { id: "alice" }, - company: { id: "acme" }, - }).matchedRuleId, - ).toBe("user-target"); - expect( - evaluateFlag(definition, { user: { id: "bob" }, company: { id: "acme" } }) + evaluate({ user: { id: "alice" }, company: { id: "acme" } }) .matchedRuleId, - ).toBe("company-target"); + ).toBe("user"); + expect( + evaluate({ user: { id: "bob" }, company: { id: "acme" } }).matchedRuleId, + ).toBe("company"); expect( - evaluateFlag(definition, { - user: { id: "bob" }, - company: { id: "other" }, - }).matchedRuleId, + evaluate({ user: { id: "bob" }, company: { id: "other" } }).matchedRuleId, ).toBe("split-rule"); }); - it("continues to the next authored rule rather than directly to the default", () => { + it("falls through to the next authored rule, not directly to default", () => { const definition = flag({ - type: "percentageDistribution", - key: "split", - attribute: "company.id", + ...split(), allocations: [{ percentage: 100, destination: { type: "nextRule" } }], }); definition.rules.splice(1, 0, { - id: "second-rule", + id: "second", filter: { type: "constant", value: true }, result: { type: "variant", variantKey: "list" }, }); - const result = evaluateFlag(definition, { - company: { id: "company-6295" }, - }); + const result = newEvaluator(definition)({ company: { id: "acme" } }); expect(result).toMatchObject({ + resolved: true, value: [1, false], - matchedRuleId: "second-rule", - variantKey: "list", + matchedRuleId: "second", }); expect(result.allocationIndex).toBeUndefined(); expect(result.ruleResults).toEqual([ @@ -200,101 +218,70 @@ describe("protocol-v2 evaluation", () => { destination: { type: "nextRule" }, }, { - ruleId: "second-rule", + ruleId: "second", matched: true, destination: { type: "variant", variantKey: "list" }, }, ]); }); - it("records missing bucketing context and continues evaluation", () => { - const result = evaluateFlag(flag(split()), {}); - expect(result).toMatchObject({ - variantKey: "control", - value: null, - matchedRuleId: "default", - }); - expect(result.errors).toEqual([ - { - code: "MISSING_CONTEXT_FIELD", - field: "company.id", - message: - 'Context field "company.id" is required to evaluate targeting rules.', - }, - ]); - }); - - it("rejects array bucketing values instead of implicitly stringifying them", () => { - const result = evaluateFlag(flag(split()), { company: { id: ["a", "b"] } }); - expect(result.variantKey).toBe("control"); - expect(result.errors).toMatchObject([ - { - code: "UNSUPPORTED_ARRAY_OPERATOR", - operator: "percentageDistribution", - field: "company.id", - }, - ]); - }); - - it("does not inspect filters or report missing fields after selecting a variant", () => { + it("does not inspect later rules after resolving", () => { const definition = flag({ type: "variant", variantKey: "quality" }); definition.rules[1].filter = { type: "context", - field: "user.missing", + field: "missing", operator: "IS", values: ["x"], }; - const result = evaluateFlag(definition, {}); + const result = newEvaluator(definition)({}); expect(result.errors).toEqual([]); expect(result.ruleResults).toHaveLength(1); }); - it("does not turn an invalid negated filter into a matching rule", () => { - const definition = flag({ type: "variant", variantKey: "quality" }); - definition.rules[0].filter = { - type: "negation", - filter: { - type: "context", - field: "company.missing", - operator: "IS", - values: ["x"], - }, - }; - const result = evaluateFlag(definition, {}); - expect(result.variantKey).toBe("control"); - expect(result.ruleResults[0].matched).toBe(false); - expect(result.errors).toHaveLength(1); + it("continues on missing/scalar-invalid bucketing context", () => { + const evaluate = newEvaluator(flag(split())); + expect(evaluate({})).toMatchObject({ + resolved: true, + variantKey: "control", + errors: [{ code: "MISSING_CONTEXT_FIELD", field: "company.id" }], + }); + expect(evaluate({ company: { id: ["a", "b"] } })).toMatchObject({ + resolved: true, + variantKey: "control", + errors: [ + { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field: "company.id", + operator: "percentageDistribution", + }, + ], + }); }); - it.each([99, 100.001, -1, Number.NaN, 99.9999])( - "rejects invalid allocation total/precision %s", + it.each([99, 100.001, -1, NaN, 99.9999])( + "rejects invalid percentages %s", (percentage) => { - const result = evaluateFlag( - flag({ - type: "percentageDistribution", - key: "split", - attribute: "company.id", - allocations: [ - { - percentage, - destination: { type: "variant", variantKey: "quality" }, - }, - ], - }), - { company: { id: "acme" } }, - ); - expect(result.value).toBeUndefined(); - expect(result.errors).toMatchObject([ - { code: "INVALID_FLAG_DEFINITION" }, - ]); + const definition = flag({ + ...split(), + allocations: [ + { + percentage, + destination: { type: "variant", variantKey: "quality" }, + }, + ], + }); + expect( + newEvaluator(definition)({ company: { id: "acme" } }), + ).toMatchObject({ + resolved: false, + errors: [{ code: "INVALID_FLAG_DEFINITION" }], + }); }, ); - it("accepts three-decimal splits without floating-point sum errors", () => { + it("accepts three-decimal percentages", () => { const definition = flag({ - type: "percentageDistribution", - key: "split", - attribute: "company.id", + ...split(), allocations: [ { percentage: 33.333, @@ -311,34 +298,21 @@ describe("protocol-v2 evaluation", () => { ], }); expect( - evaluateFlag(definition, { company: { id: "acme" } }).errors, + newEvaluator(definition)({ company: { id: "acme" } }).errors, ).toEqual([]); }); - it("does not resolve inherited object properties as variant values", () => { - const result = evaluateFlag( - flag({ type: "variant", variantKey: "constructor" }), - {}, - ); - expect(result.value).toBeUndefined(); - expect(result.errors).toMatchObject([{ code: "INVALID_FLAG_DEFINITION" }]); - }); - - it("reports missing catch-all defaults as unresolved", () => { - const definition = flag({ type: "variant", variantKey: "quality" }); - definition.rules = []; - const result = evaluateFlag(definition, {}); - expect(result.value).toBeUndefined(); - expect(result.errors).toMatchObject([{ code: "INVALID_FLAG_DEFINITION" }]); + it("rejects inherited property names as variants", () => { + expect( + newEvaluator(flag({ type: "variant", variantKey: "constructor" }))({}), + ).toMatchObject({ + resolved: false, + errors: [{ code: "INVALID_FLAG_DEFINITION" }], + }); }); - it("preserves legacy boolean rollout cohorts, including the maximum hash", () => { - for (const id of [ - "company-6295", - "company-20422", - "company-27366", - "company-1208267", - ]) { + it("preserves published v1 boolean cohorts, including the maximum hash", () => { + for (const [id] of boundaries) { const filter = { type: "rolloutPercentage" as const, key: "split", @@ -351,41 +325,34 @@ describe("protocol-v2 evaluation", () => { context, rules: [{ filter, value: true }], }); - const result = evaluateFlag( - { - key: "boolean", - sourceVersionId: "version-1", - variants: { true: true, false: false }, - rules: [ - { - id: "target", - filter, - result: { type: "variant", variantKey: "true" }, - }, - { - ...defaultRule, - result: { type: "variant", variantKey: "false" }, - }, - ], - }, - context, - ); + const result = newEvaluator({ + key: "boolean", + sourceVersionId: "v1", + variants: { true: true, false: false }, + rules: [ + { + id: "target", + filter, + result: { type: "variant", variantKey: "true" }, + }, + { ...defaultRule, result: { type: "variant", variantKey: "false" } }, + ], + })(context); + expect(result.resolved).toBe(true); expect(result.value).toBe(legacy.value ?? false); } }); }); -describe("prepared protocol-v2 evaluator", () => { +describe("prepared evaluator", () => { describe.each(["ANY_OF", "NOT_ANY_OF"] as const)("%s", (operator) => { it.each(["plain", "group", "negation"] as const)( - "uses cached Set lookups for %s filters", + "caches Set lookups for %s", (shape) => { const values = Array.from( { length: 10000 }, (_, i) => `candidate-${i}`, ); - // Element 0 is read by the shared scalar comparison helper. Watching - // element 1 distinguishes list traversal/rebuilding from constant work. const readCandidate = vi.fn(() => "candidate-1"); Object.defineProperty(values, 1, { get: readCandidate }); const leaf: ContextFilter = { @@ -404,23 +371,21 @@ describe("prepared protocol-v2 evaluator", () => { type: "negation", filter: { type: "group", operator: "and", filters: [leaf] }, }; - const prepared = newFlagEvaluator(definition); + const evaluate = newEvaluator(definition); expect(readCandidate).toHaveBeenCalled(); readCandidate.mockClear(); const lookups = vi.spyOn(Set.prototype, "has"); const traversal = vi .spyOn(values, "includes") .mockImplementation(() => { - throw new Error( - "Candidate array must not be scanned during evaluation", - ); + throw new Error("Candidate array traversed"); }); - let results: ReturnType[]; + let results: ReturnType[]; let lookupCount: number; try { results = [ - prepared({ company: { id: "candidate-9999" } }), - prepared({ company: { id: ["missing", "candidate-9999"] } }), + evaluate({ company: { id: "candidate-9999" } }), + evaluate({ company: { id: ["missing", "candidate-9999"] } }), ]; lookupCount = lookups.mock.calls.filter( ([key]) => key === "candidate-9999" || key === "missing", @@ -431,95 +396,49 @@ describe("prepared protocol-v2 evaluator", () => { } expect(lookupCount).toBe(3); expect(readCandidate).not.toHaveBeenCalled(); - expect(leaf.valueSet).toBeUndefined(); + expect(leaf).not.toHaveProperty("valueSet"); const matches = (operator === "ANY_OF") !== (shape === "negation"); - expect(results.map(({ variantKey }) => variantKey)).toEqual([ - matches ? "quality" : "control", - matches ? "quality" : "control", - ]); + expect(results.map(({ variantKey }) => variantKey)).toEqual( + Array(2).fill(matches ? "quality" : "control"), + ); }, ); }); - it("also prepares lookups under negation for legacy newEvaluator", () => { - const values = ["acme"]; - const prepared = newEvaluator([ - { - value: true, - filter: { - type: "negation", - filter: { - type: "context", - field: "company.id", - operator: "ANY_OF", - values, - }, - }, - }, - ]); - const traversal = vi.spyOn(values, "includes").mockImplementation(() => { - throw new Error("Unexpected traversal"); - }); - try { - expect(prepared({ company: { id: "other" } }, "legacy").value).toBe(true); - } finally { - traversal.mockRestore(); - } - }); - - it("does not reread or validate percentages on repeated evaluations", () => { + it("does not reread percentages after preparation", () => { const distribution = split(); - if (distribution.type !== "percentageDistribution") - throw new Error("Expected split"); - const percentageReads = vi.fn(() => 60); + const reads = vi.fn(() => 60); Object.defineProperty(distribution.allocations[0], "percentage", { - get: percentageReads, + get: reads, }); - const prepared = newFlagEvaluator(flag(distribution)); - expect(percentageReads).toHaveBeenCalled(); - percentageReads.mockClear(); + const evaluate = newEvaluator(flag(distribution)); + reads.mockClear(); for (let i = 0; i < 10; i++) { expect( - prepared({ company: { id: "company-6295" } }).allocationIndex, + evaluate({ company: { id: "company-6295" } }).allocationIndex, ).toBe(0); expect( - prepared({ company: { id: "company-27366" } }).allocationIndex, + evaluate({ company: { id: "company-27366" } }).allocationIndex, ).toBe(1); } - expect(percentageReads).not.toHaveBeenCalled(); + expect(reads).not.toHaveBeenCalled(); }); - it.each([ - "company-6295", - "company-20422", - "company-27366", - "company-161395", - "company-1208267", - ])( - "preserves allocation boundaries for %s while skipping zero-weight entries", - (id) => { - const distribution = split(); - if (distribution.type !== "percentageDistribution") - throw new Error("Expected split"); - distribution.allocations.unshift({ - percentage: 0, - destination: { type: "variant", variantKey: "list" }, - }); - distribution.allocations.splice(2, 0, { - percentage: 0, - destination: { type: "variant", variantKey: "list" }, - }); - distribution.allocations.push({ - percentage: 0, - destination: { type: "variant", variantKey: "list" }, - }); - const prepared = newFlagEvaluator(flag(distribution)); - const expectedIndex = hashInt(`split.${id}`) < 60000 ? 1 : 3; - expect(prepared({ company: { id } }).allocationIndex).toBe(expectedIndex); - }, - ); + it.each(boundaries)("skips zero weights for %s", (id, bucket) => { + const distribution = split(); + const zero = { + percentage: 0, + destination: { type: "variant" as const, variantKey: "list" }, + }; + distribution.allocations.unshift(zero); + distribution.allocations.splice(2, 0, zero); + distribution.allocations.push(zero); + expect( + newEvaluator(flag(distribution))({ company: { id } }).allocationIndex, + ).toBe(bucket < 60000 ? 1 : 3); + }); - it("matches cumulative allocation semantics across 50 variants", () => { + it("selects correctly across 50 allocations", () => { const definition: CompiledFlag = { key: "many", sourceVersionId: "v1", @@ -531,9 +450,7 @@ describe("prepared protocol-v2 evaluator", () => { id: "split", filter: { type: "constant", value: true }, result: { - type: "percentageDistribution", - key: "split", - attribute: "company.id", + ...split(), allocations: Array.from({ length: 50 }, (_, i) => ({ percentage: 2, destination: { type: "variant", variantKey: `v${i}` }, @@ -542,11 +459,12 @@ describe("prepared protocol-v2 evaluator", () => { }, ], }; - const prepared = newFlagEvaluator(definition); + const evaluate = newEvaluator(definition); for (let i = 0; i < 200; i++) { const id = `company-${i}`; const index = Math.min(Math.floor(hashInt(`split.${id}`) / 2000), 49); - expect(prepared({ company: { id } })).toMatchObject({ + expect(evaluate({ company: { id } })).toMatchObject({ + resolved: true, allocationIndex: index, variantKey: `v${index}`, value: index, @@ -555,20 +473,17 @@ describe("prepared protocol-v2 evaluator", () => { } }); - it("isolates diagnostics and returned destinations between contexts", () => { - const definition = flag(split()); - const prepared = newFlagEvaluator(definition); - const missing = prepared({}); + it("isolates returned diagnostics and destinations between calls", () => { + const evaluate = newEvaluator(flag(split())); + const missing = evaluate({}); + const selected = evaluate({ company: { id: "company-6295" } }); expect(missing.errors).toHaveLength(1); - const selected = prepared({ company: { id: "company-6295" } }); expect(selected.errors).toEqual([]); const destination = selected.ruleResults[0].destination!; if (destination.type === "variant") destination.variantKey = "control"; - const again = prepared({ company: { id: "company-6295" } }); - expect(again.variantKey).toBe("quality"); - expect(missing.errors).toHaveLength(1); - expect(again).toEqual( - evaluateFlag(definition, { company: { id: "company-6295" } }), + expect(evaluate({ company: { id: "company-6295" } }).variantKey).toBe( + "quality", ); + expect(missing.errors).toHaveLength(1); }); }); diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index f7608a8c4..2276b1df7 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -34,7 +34,7 @@ "@aws-sdk/client-s3": "^3.888.0", "@googleapis/storage": "21.2.0", "@redis/client": "^5.11.0", - "@reflag/flag-evaluation": "1.1.1" + "@reflag/flag-evaluation-v1": "npm:@reflag/flag-evaluation@1.1.1" }, "devDependencies": { "@babel/core": "~7.29.6", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 293ee46dc..de2068173 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -5,7 +5,7 @@ import { EvaluationResult, flattenJSON, newEvaluator, -} from "@reflag/flag-evaluation"; +} from "@reflag/flag-evaluation-v1"; import BatchBuffer from "./batch-buffer"; import { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d7bd0123d..5701c027d 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -4,7 +4,7 @@ import { EvaluationError, newEvaluator, RuleFilter, -} from "@reflag/flag-evaluation"; +} from "@reflag/flag-evaluation-v1"; /** * Describes the meta context associated with tracking. diff --git a/yarn.lock b/yarn.lock index dbffbb08f..f0a9afac1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6948,10 +6948,20 @@ __metadata: languageName: unknown linkType: soft -"@reflag/flag-evaluation@npm:1.1.1, @reflag/flag-evaluation@workspace:packages/flag-evaluation": +"@reflag/flag-evaluation-v1@https://registry.npmjs.org/@reflag/flag-evaluation/-/flag-evaluation-1.1.1.tgz": + version: 1.1.1 + resolution: "@reflag/flag-evaluation-v1@https://registry.npmjs.org/@reflag/flag-evaluation/-/flag-evaluation-1.1.1.tgz" + dependencies: + js-sha256: "npm:0.11.0" + checksum: 10c0/d27433b0eb8c7a8460d105f70d836f1198d0661dd479c01350a0cc2b10b548e6eb666d2dc9ef67eeb9045c00be0a4b37ad1e1dfbc3694d107383d61b80f4689a + languageName: node + linkType: hard + +"@reflag/flag-evaluation@workspace:packages/flag-evaluation": version: 0.0.0-use.local resolution: "@reflag/flag-evaluation@workspace:packages/flag-evaluation" dependencies: + "@reflag/flag-evaluation-v1": "npm:@reflag/flag-evaluation@1.1.1" "@reflag/tsconfig": "npm:^0.0.2" "@types/node": "npm:^22.12.0" js-sha256: "npm:0.11.0" @@ -6998,7 +7008,7 @@ __metadata: "@babel/core": "npm:~7.29.6" "@googleapis/storage": "npm:21.2.0" "@redis/client": "npm:^5.11.0" - "@reflag/flag-evaluation": "npm:1.1.1" + "@reflag/flag-evaluation-v1": "npm:@reflag/flag-evaluation@1.1.1" "@reflag/tsconfig": "npm:~0.0.2" "@types/node": "npm:^22.12.0" "@vitest/coverage-v8": "npm:~1.6.0" From 03d9be51be413a16aa17c2a872c183f3a92c0840 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 11:43:07 +0200 Subject: [PATCH 06/18] build: prepare evaluator artifacts when packing the workspace --- packages/flag-evaluation/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flag-evaluation/package.json b/packages/flag-evaluation/package.json index 8c1976334..1c36576ed 100644 --- a/packages/flag-evaluation/package.json +++ b/packages/flag-evaluation/package.json @@ -16,6 +16,7 @@ }, "scripts": { "build": "tsc --project tsconfig.build.json", + "prepack": "yarn build", "test": "vitest", "test:ci": "vitest --reporter=default --reporter=junit --outputFile=junit.xml", "lint": "oxlint .", From 8d8bd9761c2ad8a61b466dc3a7d0cdd100efed01 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 11:56:02 +0200 Subject: [PATCH 07/18] docs: remove SDK rollout note from evaluator changeset --- .changeset/multivariate-evaluation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/multivariate-evaluation.md b/.changeset/multivariate-evaluation.md index f30b13d9c..b144a5cfd 100644 --- a/.changeset/multivariate-evaluation.md +++ b/.changeset/multivariate-evaluation.md @@ -11,4 +11,4 @@ Introduce a v2-only compiled-variant evaluator: - Return structured `INVALID_COMPARISON` errors for invalid numeric/date comparisons instead of logging context values. Invalid conditions cannot match through negation. - Retain the original hash function and threshold rollout filter for boolean cohort compatibility. The inclusive maximum distribution bucket selects the last nonzero allocation. -The runtime exports are `newEvaluator`, `flattenContext`, and `hashInt`. Removed APIs include `evaluateFlagRules`, `newFlagEvaluator`, `evaluateFlag`, the scalar `evaluate` helper, and the legacy `flattenJSON` / `unflattenJSON` helpers. SDK adoption of `/flags` and variant-valued public APIs is a separate change; unchanged consumers must stay on evaluator 1.x. +The runtime exports are `newEvaluator`, `flattenContext`, and `hashInt`. Removed APIs include `evaluateFlagRules`, `newFlagEvaluator`, `evaluateFlag`, the scalar `evaluate` helper, and the legacy `flattenJSON` / `unflattenJSON` helpers. From 912f4fc9709431356e3df17583151a368806af05 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 11:58:04 +0200 Subject: [PATCH 08/18] fix: distinguish v2 prerelease package identity from v1 --- packages/flag-evaluation/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flag-evaluation/package.json b/packages/flag-evaluation/package.json index 1c36576ed..deec8ecad 100644 --- a/packages/flag-evaluation/package.json +++ b/packages/flag-evaluation/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/flag-evaluation", - "version": "1.1.1", + "version": "2.0.0-next.0", "license": "MIT", "repository": { "type": "git", From c597521788cb0e14c2357bbdd7463600375a7cf1 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 16 Sep 2026 12:10:30 +0200 Subject: [PATCH 09/18] build: use the registry v1 alias with distinct v2 package identity --- package.json | 1 - packages/flag-evaluation/README.md | 7 +++---- yarn.lock | 6 +++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index de81c7f75..c8e3ecad6 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "typescript": "^5.7.3" }, "resolutions": { - "@reflag/flag-evaluation-v1": "https://registry.npmjs.org/@reflag/flag-evaluation/-/flag-evaluation-1.1.1.tgz", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/packages/flag-evaluation/README.md b/packages/flag-evaluation/README.md index ec78a3bef..adc3a28d1 100644 --- a/packages/flag-evaluation/README.md +++ b/packages/flag-evaluation/README.md @@ -55,10 +55,9 @@ The hash and `rolloutPercentage` filter remain to preserve boolean cohorts. Consumers of the old `/features` protocol must continue using the published 1.x package. In this monorepo, the unchanged Node SDK uses the explicitly pinned `@reflag/flag-evaluation-v1` npm alias, so Changesets does not advance its evaluator -dependency to v2. A root Yarn resolution forces the published v1 tarball before -Changesets bumps this workspace from 1.x to 2.x; otherwise Yarn can transparently -link the alias back to the workspace. The alias is also -used as a test/benchmark reference, not as a runtime dependency of evaluator v2. +dependency to v2. The distinct v2 prerelease version also prevents Yarn workspace +linking and TypeScript package-identity collisions with v1. The alias is used as a +test/benchmark reference, not as a runtime dependency of evaluator v2. ## Performance checks diff --git a/yarn.lock b/yarn.lock index f0a9afac1..9b47f9376 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6948,12 +6948,12 @@ __metadata: languageName: unknown linkType: soft -"@reflag/flag-evaluation-v1@https://registry.npmjs.org/@reflag/flag-evaluation/-/flag-evaluation-1.1.1.tgz": +"@reflag/flag-evaluation-v1@npm:@reflag/flag-evaluation@1.1.1": version: 1.1.1 - resolution: "@reflag/flag-evaluation-v1@https://registry.npmjs.org/@reflag/flag-evaluation/-/flag-evaluation-1.1.1.tgz" + resolution: "@reflag/flag-evaluation@npm:1.1.1" dependencies: js-sha256: "npm:0.11.0" - checksum: 10c0/d27433b0eb8c7a8460d105f70d836f1198d0661dd479c01350a0cc2b10b548e6eb666d2dc9ef67eeb9045c00be0a4b37ad1e1dfbc3694d107383d61b80f4689a + checksum: 10c0/fe6388d3d75f67ef9dd14d302925be368aabde3066019729627faa1fd25b9914f1ae908f64618a3d3217a8cad4f7c065d5ea3587156d8e4b05c1dde396d0efee languageName: node linkType: hard From 1277a5902f11121b399c0d263314df768798b72a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 22 Sep 2026 14:08:39 +0200 Subject: [PATCH 10/18] docs: simplify evaluator readme --- packages/flag-evaluation/README.md | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/packages/flag-evaluation/README.md b/packages/flag-evaluation/README.md index adc3a28d1..f30d201b7 100644 --- a/packages/flag-evaluation/README.md +++ b/packages/flag-evaluation/README.md @@ -9,6 +9,9 @@ import { newEvaluator } from "@reflag/flag-evaluation"; const evaluate = newEvaluator(compiledFlag); const result = evaluate({ company: { id: "acme" }, user: { id: "alice" } }); +// Diagnostics can accompany either a successful result or a failure. +if (result.errors.length) console.warn(result.errors); + if (result.resolved) { // T, not T | undefined. An undefined variant value is still a success. console.log(result.value); @@ -16,32 +19,16 @@ if (result.resolved) { console.log(result.variantKey, result.matchedRuleId); } else { // No variant was selected: use the SDK's fallback behavior. - console.log(result.errors); } ``` `CompiledFlag` and `EvaluationResult` preserve the caller's -value type. JSON validation belongs at the API/persistence boundary, not here. -Non-fatal diagnostics may also accompany a successful default/fallthrough result. +value type. Non-fatal diagnostics may also accompany a successful +default/fallthrough result. Rebuild the evaluator when definitions change. Treat definitions and returned values as immutable. Each evaluation has independent diagnostics. -## Preparation and evaluation - -- `ANY_OF` / `NOT_ANY_OF` lists become private Sets at preparation, including - inside groups and negations. Scalars use hash lookups. Array-valued contexts - require a lookup per examined context element, not a candidate-list scan. -- Percentage validation and cumulative integer bounds are computed once and - stored with each destination. Checks hash the context attribute and scan those - bounds. The inclusive maximum hash maps to the last nonzero allocation; - 0% allocations never receive traffic. -- Rules stop at the first selected variant. `nextRule` continues evaluation. - Results include diagnostics only for visited rules. -- Invalid numeric/date comparisons produce `INVALID_COMPARISON` diagnostics and - cannot match through negation. The evaluator never writes to the console or - embeds operand values in error messages. - ## Migration from v1 V2 exports `newEvaluator(flag)`, `flattenContext`, and `hashInt`, plus their public @@ -52,13 +39,6 @@ V1's `evaluateFlagRules`, scalar `evaluate`, `flattenJSON`, `unflattenJSON`, and value-rule types are removed. There is no v2 one-shot evaluation wrapper. The hash and `rolloutPercentage` filter remain to preserve boolean cohorts. -Consumers of the old `/features` protocol must continue using the published 1.x -package. In this monorepo, the unchanged Node SDK uses the explicitly pinned -`@reflag/flag-evaluation-v1` npm alias, so Changesets does not advance its evaluator -dependency to v2. The distinct v2 prerelease version also prevents Yarn workspace -linking and TypeScript package-identity collisions with v1. The alias is used as a -test/benchmark reference, not as a runtime dependency of evaluator v2. - ## Performance checks From the repository root: From c9a736dd21b3868bf076babb59b27db6a8a5bafb Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 29 Oct 2026 09:37:07 +0000 Subject: [PATCH 11/18] feat(flag-evaluation): resolve flag and segment collection references --- .changeset/multivariate-evaluation.md | 5 +- packages/flag-evaluation/README.md | 68 +- packages/flag-evaluation/src/index.ts | 198 ++++- packages/flag-evaluation/test/helpers.ts | 7 + packages/flag-evaluation/test/index.test.ts | 6 +- .../flag-evaluation/test/references.test.ts | 792 ++++++++++++++++++ .../flag-evaluation/test/variants.bench.ts | 7 +- .../flag-evaluation/test/variants.test.ts | 55 +- 8 files changed, 1071 insertions(+), 67 deletions(-) create mode 100644 packages/flag-evaluation/test/helpers.ts create mode 100644 packages/flag-evaluation/test/references.test.ts diff --git a/.changeset/multivariate-evaluation.md b/.changeset/multivariate-evaluation.md index b144a5cfd..465148ed9 100644 --- a/.changeset/multivariate-evaluation.md +++ b/.changeset/multivariate-evaluation.md @@ -4,7 +4,10 @@ Introduce a v2-only compiled-variant evaluator: -- `newEvaluator(flag)` prepares a compiled flag once and returns a reusable context evaluator. Remove the v1 value-rule APIs and the one-shot `evaluateFlag` wrapper. +- `newEvaluator({ flags, segments })` prepares a complete collection and returns a reusable `(flagKey, context)` evaluator. Remove the v1 value-rule APIs and the one-shot `evaluateFlag` wrapper. +- Preserve flag and segment reference nodes instead of inlining their filters. Flag references explicitly match variant keys; segments remain reusable predicates. Resolve references recursively without dependency-result caching or memoization. +- Preserve diagnostics from unsuccessful referenced rules without suppressing a valid matching selection. Invalid nonmatches remain fail-closed through negation. Propagated diagnostics identify their source flag or segment. +- Reject duplicate keys, missing references, unknown referenced variant keys and cycles at preparation, including mixed flag/segment cycles. Evaluating an unknown flag key throws. - Preserve arbitrary variant value types through `CompiledFlag` and `EvaluationResult` (default `any`). `resolved: true | false` distinguishes a selected value, including `undefined`, from failure. Successful results require the variant key and matched rule ID. - Keep membership Sets and prepared percentage bounds private. `ANY_OF` / `NOT_ANY_OF` use cached Set lookups, including under groups and negations; percentage distributions use precomputed bounds and a simple scan. - Evaluate rules sequentially with explicit fallthrough and source-version/rule/allocation diagnostics. diff --git a/packages/flag-evaluation/README.md b/packages/flag-evaluation/README.md index f30d201b7..2c488a8cf 100644 --- a/packages/flag-evaluation/README.md +++ b/packages/flag-evaluation/README.md @@ -1,13 +1,19 @@ # Flag evaluation v2 -Prepare a compiled flag once when its definition is loaded or refreshed, then -reuse the evaluator for each context: +Prepare a complete collection of compiled flags and segments when definitions +are loaded or refreshed, then select a flag by key for each context: ```ts import { newEvaluator } from "@reflag/flag-evaluation"; -const evaluate = newEvaluator(compiledFlag); -const result = evaluate({ company: { id: "acme" }, user: { id: "alice" } }); +const evaluate = newEvaluator({ + flags: compiledFlags, + segments: compiledSegments, +}); +const result = evaluate("my-flag", { + company: { id: "acme" }, + user: { id: "alice" }, +}); // Diagnostics can accompany either a successful result or a failure. if (result.errors.length) console.warn(result.errors); @@ -22,18 +28,60 @@ if (result.resolved) { } ``` -`CompiledFlag` and `EvaluationResult` preserve the caller's -value type. Non-fatal diagnostics may also accompany a successful -default/fallthrough result. +`CompiledFlag`, `CompiledCollection` and +`EvaluationResult` preserve the caller's value type. A heterogeneous +collection can use a union for `T`. Non-fatal diagnostics may also accompany a +successful default/fallthrough result. Rebuild the evaluator when definitions change. Treat definitions and returned values as immutable. Each evaluation has independent diagnostics. +## References + +A collection contains `flags: CompiledFlag[]` and optional +`segments: { key: string; filter: RuleFilter }[]`. Keep references in filters +instead of substituting the referenced definition's filters: + +```ts +// Match the variant selected by another flag in the collection. +const flagReference = { type: "flag", key: "model", variantKey: "quality" }; +// Reuse a segment predicate from the same collection. +const segmentReference = { type: "segment", key: "enterprise" }; +// Negation and authored AND/OR groups work with either reference type. +const negativeReference = { type: "negation", filter: flagReference }; +``` + +Flag references match **variant keys**, never the truthiness of variant values. +The referenced flag uses its own ordered rules, distributions and rollout salts. +A matching selection remains valid despite diagnostics from unsuccessful sibling +rules. A nonmatch accompanied by diagnostics is invalid, not a clean `false`, so +negating an uncertain nonmatch cannot grant access. A clean nonmatch can be +negated. This is intentionally more conservative than explicitly matching a +selected fallback variant. + +Segments are predicates, not independent enable-rule groups: errors in a segment +invalidate the containing rule, including through negation. Diagnostics propagated +through references include `source: { type: "flag" | "segment", key: string }` +identifying their original definition. Existing short-circuit behavior is retained; +unvisited runtime branches do not produce diagnostics. + +`newEvaluator` throws for duplicate keys, missing references, unknown referenced +variant keys, and cycles, including mixed flag/segment cycles and references in +unreachable branches. Evaluating an unknown flag key also throws. Flag and segment +keys occupy separate namespaces. + +References are evaluated recursively without dependency-result caching or +memoization. Context is normalized once per call; repeated references are evaluated +again. Only the existing definition-level membership Sets and percentage bounds +are prepared ahead of time. + ## Migration from v1 -V2 exports `newEvaluator(flag)`, `flattenContext`, and `hashInt`, plus their public -types. `newEvaluator` now accepts a compiled flag, not value rules. The returned -function accepts only context; the flag key is part of the definition. +V2 exports `newEvaluator(collection)`, `flattenContext`, and `hashInt`, plus their +public types. `newEvaluator` accepts a complete compiled collection, not value +rules or a single flag. The returned function accepts `(flagKey, context)`. +Publishers and consumers must adopt the collection/reference protocol together; +the unchanged Node SDK remains on the published v1 evaluator. V1's `evaluateFlagRules`, scalar `evaluate`, `flattenJSON`, `unflattenJSON`, and value-rule types are removed. There is no v2 one-shot evaluation wrapper. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index c3bc70d99..9c4375093 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -33,6 +33,13 @@ export type PercentageRolloutFilter = { partialRolloutThreshold: number; }; +/** Match the selected variant, not the truthiness of its value. */ +export type FlagReferenceFilter = { + type: "flag"; + key: string; + variantKey: string; +}; +export type SegmentReferenceFilter = { type: "segment"; key: string }; export type ConstantFilter = { type: "constant"; value: boolean }; export type FilterClass = { type: string }; export type FilterGroup = { @@ -49,13 +56,17 @@ export type FilterTree = | FilterNegation | T; export type RuleFilter = FilterTree< - ContextFilter | PercentageRolloutFilter | ConstantFilter + | ContextFilter + | PercentageRolloutFilter + | ConstantFilter + | FlagReferenceFilter + | SegmentReferenceFilter >; export type NormalizedContextValue = string | string[]; export type FlattenedContext = Record; -export type EvaluationError = +export type EvaluationError = ( | { code: "MISSING_CONTEXT_FIELD"; field: string; message: string } | { code: "UNSUPPORTED_ARRAY_OPERATOR"; @@ -72,7 +83,11 @@ export type EvaluationError = operator: ContextFilterOperator; message: string; } - | { code: "INVALID_FLAG_DEFINITION"; message: string }; + | { code: "INVALID_FLAG_DEFINITION"; message: string } +) & { + /** Origin of a diagnostic propagated through a reference. */ + source?: { type: "flag" | "segment"; key: string }; +}; export type VariantDestination = | { type: "variant"; variantKey: string } @@ -99,6 +114,11 @@ export type CompiledFlag = { variants: Record; rules: CompiledRule[]; }; +export type CompiledSegment = { key: string; filter: RuleFilter }; +export type CompiledCollection = { + flags: CompiledFlag[]; + segments?: CompiledSegment[]; +}; export type RuleEvaluationResult = { ruleId: string; @@ -183,7 +203,11 @@ export function hashInt(hashInput: string): number { // Prepared lookup structures never appear in the public/wire filter types. type PreparedContextFilter = ContextFilter & { valueSet?: Set }; type PreparedFilter = FilterTree< - PreparedContextFilter | PercentageRolloutFilter | ConstantFilter + | PreparedContextFilter + | PercentageRolloutFilter + | ConstantFilter + | FlagReferenceFilter + | SegmentReferenceFilter >; type Distribution = Extract; type PreparedDistribution = Omit & { @@ -198,6 +222,11 @@ type PreparedRule = { type PreparedFlag = Omit, "rules"> & { rules: PreparedRule[]; }; +type EvaluationState = { + flags: Map>; + segments: Map; + context: FlattenedContext; +}; function prepareFilter(filter: RuleFilter): PreparedFilter { if (filter.type === "group") @@ -211,7 +240,9 @@ function prepareFilter(filter: RuleFilter): PreparedFilter { // Keep only the Set's candidate references, not the source array as well. return { ...filter, values: [], valueSet: new Set(filter.values ?? []) }; } - return { ...filter }; + return filter.type === "context" + ? { ...filter, values: filter.values?.slice() } + : { ...filter }; } function prepareRule(rule: CompiledRule): PreparedRule { @@ -394,11 +425,27 @@ function compare( } } -function evaluateFilter( +function propagateErrors( + sourceErrors: Iterable, + source: NonNullable, + diagnostics: Map, + errors?: Map, +): void { + for (const error of sourceErrors) { + const diagnostic = error.source ? error : { ...error, source }; + const key = JSON.stringify(diagnostic); + diagnostics.set(key, diagnostic); + errors?.set(key, diagnostic); + } +} + +function evaluateFilter( filter: PreparedFilter, - context: FlattenedContext, + state: EvaluationState, errors: Map, + diagnostics: Map, ): boolean { + const { context } = state; switch (filter.type) { case "constant": return filter.value; @@ -428,44 +475,155 @@ function evaluateFilter( } return hashInt(`${filter.key}.${value}`) < filter.partialRolloutThreshold; } + case "flag": { + // The collection is validated before use, including unreachable references. + const result = evaluatePreparedFlag(state.flags.get(filter.key)!, state); + const matched = + result.resolved && result.variantKey === filter.variantKey; + // A valid selection wins over diagnostics in independent sibling rules. + // An uncertain nonmatch must not become a grant through negation. + propagateErrors( + result.errors, + { type: "flag", key: filter.key }, + diagnostics, + matched ? undefined : errors, + ); + return matched; + } + case "segment": { + const segmentErrors = new Map(); + const segmentDiagnostics = new Map(); + const matched = evaluateFilter( + state.segments.get(filter.key)!, + state, + segmentErrors, + segmentDiagnostics, + ); + const source = { type: "segment" as const, key: filter.key }; + propagateErrors(segmentDiagnostics.values(), source, diagnostics); + // Segments are predicates, not sets of independent enable rules. + propagateErrors(segmentErrors.values(), source, diagnostics, errors); + return matched && segmentErrors.size === 0; + } case "group": for (const child of filter.filters) { - const matched = evaluateFilter(child, context, errors); + const matched = evaluateFilter(child, state, errors, diagnostics); if (filter.operator === "and" && !matched) return false; if (filter.operator === "or" && matched) return true; } return filter.operator === "and"; case "negation": - return !evaluateFilter(filter.filter, context, errors); + return !evaluateFilter(filter.filter, state, errors, diagnostics); default: invalidDefinition(errors, "Unsupported filter type"); return false; } } -/** Prepare once on definition refresh, then reuse for all evaluation contexts. */ +/** Reject incomplete or cyclic snapshots, even in short-circuited branches. */ +function validateReferences( + flags: Map>, + segments: Map, +): void { + const visited = new Set | PreparedFilter>(); + const visiting = new Set | PreparedFilter>(); + + function visit(type: "flag" | "segment", key: string): void { + const definition = type === "flag" ? flags.get(key) : segments.get(key); + if (!definition) throw new Error(`Unknown ${type} reference "${key}"`); + if (visiting.has(definition)) + throw new Error(`Circular reference to ${type} "${key}"`); + if (visited.has(definition)) return; + visiting.add(definition); + if (type === "flag") { + for (const rule of flags.get(key)!.rules) visitFilter(rule.filter); + } else { + visitFilter(segments.get(key)!); + } + visiting.delete(definition); + visited.add(definition); + } + + function visitFilter(filter: PreparedFilter): void { + switch (filter.type) { + case "flag": { + visit("flag", filter.key); + if ( + !Object.prototype.hasOwnProperty.call( + flags.get(filter.key)!.variants, + filter.variantKey, + ) + ) { + throw new Error( + `Unknown variant "${filter.variantKey}" in flag reference "${filter.key}"`, + ); + } + break; + } + case "segment": + visit("segment", filter.key); + break; + case "group": + filter.filters.forEach(visitFilter); + break; + case "negation": + visitFilter(filter.filter); + break; + } + } + + for (const key of flags.keys()) visit("flag", key); + for (const key of segments.keys()) visit("segment", key); +} + +/** + * Prepare a complete snapshot once on definition refresh. References are resolved + * recursively for each call, without caching context-dependent results. + * Throws for duplicate keys, missing references, unknown referenced variants or + * cycles. Evaluating an unknown flag key also throws. + */ export function newEvaluator( - flag: CompiledFlag, -): (context: Record) => EvaluationResult { - const prepared: PreparedFlag = { - ...flag, - variants: { ...flag.variants }, - rules: flag.rules.map(prepareRule), + collection: CompiledCollection, +): (flagKey: string, context: Record) => EvaluationResult { + const flags = new Map>(); + const segments = new Map(); + for (const flag of collection.flags) { + if (flags.has(flag.key)) + throw new Error(`Duplicate flag key "${flag.key}"`); + flags.set(flag.key, { + ...flag, + variants: { ...flag.variants }, + rules: flag.rules.map(prepareRule), + }); + } + for (const segment of collection.segments ?? []) { + if (segments.has(segment.key)) + throw new Error(`Duplicate segment key "${segment.key}"`); + segments.set(segment.key, prepareFilter(segment.filter)); + } + validateReferences(flags, segments); + return (flagKey, context) => { + const flag = flags.get(flagKey); + if (!flag) throw new Error(`Unknown flag "${flagKey}"`); + return evaluatePreparedFlag(flag, { + flags, + segments, + context: flattenContext(context), + }); }; - return (context) => evaluatePreparedFlag(prepared, context); } function evaluatePreparedFlag( flag: PreparedFlag, - context: Record, + state: EvaluationState, ): EvaluationResult { - const flatContext = flattenContext(context); + const flatContext = state.context; const errors = new Map(); const filterErrors = new Map(); const ruleResults: RuleEvaluationResult[] = []; for (const rule of flag.rules) { filterErrors.clear(); - const matched = evaluateFilter(rule.filter, flatContext, filterErrors); + const matched = evaluateFilter(rule.filter, state, filterErrors, errors); for (const [key, error] of filterErrors) errors.set(key, error); const ruleResult: RuleEvaluationResult = { ruleId: rule.id, diff --git a/packages/flag-evaluation/test/helpers.ts b/packages/flag-evaluation/test/helpers.ts new file mode 100644 index 000000000..fc1a4a8be --- /dev/null +++ b/packages/flag-evaluation/test/helpers.ts @@ -0,0 +1,7 @@ +import { CompiledFlag, newEvaluator } from "../src"; + +/** Keep single-flag operator/allocation fixtures focused on their behavior. */ +export function prepareFlag(flag: CompiledFlag) { + const evaluate = newEvaluator({ flags: [flag] }); + return (context: Record) => evaluate(flag.key, context); +} diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index d64ea2d25..e9c66e9e8 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -4,12 +4,12 @@ import { ContextFilterOperator, flattenContext, hashInt, - newEvaluator, RuleFilter, } from "../src"; +import { prepareFlag } from "./helpers"; function evaluateFilter(filter: RuleFilter, context: Record) { - return newEvaluator({ + return prepareFlag({ key: "filter", sourceVersionId: "version-1", variants: { yes: true, no: false }, @@ -261,7 +261,7 @@ describe("invalid conditions", () => { values: ["x"], }, }; - const result = newEvaluator({ + const result = prepareFlag({ key: "test", sourceVersionId: "v1", variants: { yes: true, no: false }, diff --git a/packages/flag-evaluation/test/references.test.ts b/packages/flag-evaluation/test/references.test.ts new file mode 100644 index 000000000..4d2343f17 --- /dev/null +++ b/packages/flag-evaluation/test/references.test.ts @@ -0,0 +1,792 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + CompiledCollection, + CompiledFlag, + CompiledRule, + CompiledSegment, + ContextFilterOperator, + FlagReferenceFilter, + hashInt, + newEvaluator, + RuleFilter, +} from "../src"; + +const yes: RuleFilter = { type: "constant", value: true }; +const no: RuleFilter = { type: "constant", value: false }; +const grant: RuleFilter = { + type: "context", + field: "company.id", + operator: "ANY_OF", + values: ["acme"], +}; +const context = { + company: { id: "acme", numbers: [42], credits: "invalid", threshold: 10 }, +}; +const reference = (key: string, variantKey = "on"): FlagReferenceFilter => ({ + type: "flag", + key, + variantKey, +}); +const segment = (key: string): RuleFilter => ({ type: "segment", key }); +const negate = (filter: RuleFilter): RuleFilter => ({ + type: "negation", + filter, +}); +const group = ( + operator: "and" | "or", + ...filters: RuleFilter[] +): RuleFilter => ({ + type: "group", + operator, + filters, +}); +const rule = ( + id: string, + filter: RuleFilter, + variantKey = "on", +): CompiledRule => ({ + id, + filter, + result: { type: "variant", variantKey }, +}); +function flag(key: string, ...filters: RuleFilter[]): CompiledFlag { + return { + key, + sourceVersionId: `version-${key}`, + variants: { on: true, off: false }, + rules: [ + ...filters.map((filter, index) => rule(`rule-${index}`, filter)), + rule("default", yes, "off"), + ], + }; +} + +const invalidFilters: Array<{ + name: string; + filter: RuleFilter; + code: string; +}> = [ + { + name: "missing context", + filter: { + type: "context", + field: "other.missing", + operator: "IS", + values: ["yes"], + }, + code: "MISSING_CONTEXT_FIELD", + }, + { + name: "unsupported array operator", + filter: { + type: "context", + field: "company.numbers", + operator: "GT", + values: ["1"], + }, + code: "UNSUPPORTED_ARRAY_OPERATOR", + }, + { + name: "invalid context number", + filter: { + type: "context", + field: "company.credits", + operator: "GT", + values: ["1"], + }, + code: "INVALID_COMPARISON", + }, + { + name: "invalid targeting number", + filter: { + type: "context", + field: "company.threshold", + operator: "GT", + values: ["invalid"], + }, + code: "INVALID_COMPARISON", + }, + { + name: "invalid date", + filter: { + type: "context", + field: "company.credits", + operator: "DATE_AFTER", + values: ["2020-01-01"], + }, + code: "INVALID_COMPARISON", + }, + { + name: "unknown operator", + filter: { + type: "context", + field: "company.id", + operator: "UNKNOWN" as ContextFilterOperator, + }, + code: "INVALID_COMPARISON", + }, + { + name: "missing rollout attribute", + filter: { + type: "rolloutPercentage", + key: "b", + partialRolloutAttribute: "user.id", + partialRolloutThreshold: 50000, + }, + code: "MISSING_CONTEXT_FIELD", + }, + { + name: "array rollout attribute", + filter: { + type: "rolloutPercentage", + key: "b", + partialRolloutAttribute: "company.numbers", + partialRolloutThreshold: 50000, + }, + code: "UNSUPPORTED_ARRAY_OPERATOR", + }, +]; + +describe("collection evaluation", () => { + it("selects a flag by key and preserves its value type and metadata", () => { + const definition = flag("a", reference("b")); + const evaluate = newEvaluator({ flags: [definition, flag("b", grant)] }); + const result = evaluate("a", context); + expect(result).toMatchObject({ + resolved: true, + value: true, + flagKey: "a", + sourceVersionId: "version-a", + variantKey: "on", + matchedRuleId: "rule-0", + errors: [], + }); + expectTypeOf(result.value).toEqualTypeOf(); + if (!result.resolved) throw new Error("Expected a selected variant"); + expectTypeOf(result.value).toEqualTypeOf(); + expect(evaluate("b", context).value).toBe(true); + expect(evaluate("a", { company: { id: "other" } }).value).toBe(false); + expect(() => evaluate("missing", {})).toThrow('Unknown flag "missing"'); + }); + + it("matches multivariate variant keys, not value truthiness or object identity", () => { + for (const value of [undefined, null, false, 0, "", { model: "large" }]) { + const b: CompiledFlag = { + ...flag("b"), + variants: { quality: value, control: true }, + rules: [rule("select", yes, "quality")], + }; + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b", "quality")), + flag("c", reference("b", "control")), + b, + ], + }); + expect(evaluate("a", {}).value).toBe(true); + expect(evaluate("c", {}).value).toBe(false); + expect(evaluate("b", {}).value).toBe(value); + } + }); + + it("supports collections with different variant value types", () => { + const collection: CompiledCollection = { + flags: [ + flag("a", reference("b", "quality")), + { + ...flag("b"), + variants: { quality: "large" }, + rules: [rule("select", yes, "quality")], + }, + ], + }; + const result = newEvaluator(collection)("b", {}); + expectTypeOf(result.value).toEqualTypeOf(); + expect(result.value).toBe("large"); + }); + + it("resolves nested flags, nested segments and flag references in segments", () => { + const segments: CompiledSegment[] = [ + { key: "outer", filter: segment("inner") }, + { key: "inner", filter: group("and", grant, reference("c")) }, + ]; + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b")), + flag("b", segment("outer")), + flag("c", yes), + ], + segments, + }); + expect(evaluate("a", context).value).toBe(true); + expect(evaluate("a", { company: { id: "other" } }).value).toBe(false); + }); + + it("allows flag and segment keys to share the same name", () => { + const evaluate = newEvaluator({ + flags: [flag("a", reference("same")), flag("same", segment("same"))], + segments: [{ key: "same", filter: yes }], + }); + expect(evaluate("a", {}).value).toBe(true); + }); + + it.each(["__proto__", "constructor", "toString"])( + "handles own definition key %s safely", + (key) => { + const evaluate = newEvaluator({ + flags: [ + flag("a", reference(key, key)), + { + ...flag(key), + variants: { [key]: true }, + rules: [rule("selected", segment(key), key)], + }, + ], + segments: [{ key, filter: yes }], + }); + expect(evaluate("a", {}).value).toBe(true); + expect(evaluate(key, {}).value).toBe(true); + }, + ); + + it("preserves the referenced rollout's salt, context and threshold", () => { + const threshold = 50000; + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b")), + flag("b", { + type: "rolloutPercentage", + key: "original-b-key", + partialRolloutAttribute: "company.id", + partialRolloutThreshold: threshold, + }), + ], + }); + for (let i = 0; i < 100; i++) { + const id = `company-${i}`; + expect(evaluate("a", { company: { id } }).value).toBe( + hashInt(`original-b-key.${id}`) < threshold, + ); + } + }); + + it("references the variant selected by a percentage distribution", () => { + const b = flag("b"); + b.rules.unshift({ + id: "split", + filter: yes, + result: { + type: "percentageDistribution", + key: "b-split", + attribute: "company.id", + allocations: [ + { + percentage: 50, + destination: { type: "variant", variantKey: "on" }, + }, + { percentage: 50, destination: { type: "nextRule" } }, + ], + }, + }); + const evaluate = newEvaluator({ flags: [flag("a", reference("b")), b] }); + for (let i = 0; i < 100; i++) { + const id = `company-${i}`; + expect(evaluate("a", { company: { id } }).value).toBe( + Math.min(hashInt(`b-split.${id}`), 99999) < 50000, + ); + } + }); +}); + +describe("reference error boundaries", () => { + describe.each([false, true])("valid grant first: %s", (grantFirst) => { + it.each(invalidFilters)( + "keeps a valid grant despite $name in a sibling rule", + ({ filter, code }) => { + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b")), + flag("b", ...(grantFirst ? [grant, filter] : [filter, grant])), + ], + }); + const result = evaluate("a", context); + expect(result.value).toBe(true); + expect(result.errors).toEqual( + grantFirst + ? [] + : [ + expect.objectContaining({ + code, + source: { type: "flag", key: "b" }, + }), + ], + ); + }, + ); + }); + + it.each(invalidFilters)( + "does not negate an uncertain nonmatch into a grant: $name", + ({ filter, code }) => { + const evaluate = newEvaluator({ + flags: [flag("a", negate(reference("b"))), flag("b", filter, no)], + }); + const result = evaluate("a", context); + expect(result.value).toBe(false); + expect(result.errors).toEqual([ + expect.objectContaining({ code, source: { type: "flag", key: "b" } }), + ]); + }, + ); + + it("allows a clean negative reference and does not negate a matching reference into a grant", () => { + const evaluate = newEvaluator({ + flags: [flag("a", negate(reference("b"))), flag("b", grant)], + }); + expect(evaluate("a", { company: { id: "other" } }).value).toBe(true); + expect(evaluate("a", context).value).toBe(false); + }); + + it("distinguishes an explicit fallback selection from negating an uncertain nonmatch", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b", "off")), + flag("c", negate(reference("b"))), + flag("b", invalidFilters[0].filter), + ], + }); + expect(evaluate("a", context).value).toBe(true); + expect(evaluate("c", context).value).toBe(false); + expect(evaluate("a", context).errors).toHaveLength(1); + expect(evaluate("c", context).errors).toHaveLength(1); + }); + + it("does not negate a matching selection with nonfatal diagnostics into a grant", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", negate(reference("b"))), + flag("b", invalidFilters[0].filter, grant), + ], + }); + expect(evaluate("a", context)).toMatchObject({ + value: false, + errors: [ + { code: "MISSING_CONTEXT_FIELD", source: { type: "flag", key: "b" } }, + ], + }); + }); + + it("does not treat an invalid negated dependency rule as a valid selection", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b")), + flag("b", negate(invalidFilters[0].filter)), + ], + }); + expect(evaluate("a", context).value).toBe(false); + }); + + it("keeps authored OR errors local to a rule, not the entire flag", () => { + const authored = group("or", reference("b"), grant); + const b = flag("b", invalidFilters[0].filter); + const evaluate = newEvaluator({ + flags: [flag("a", authored), flag("c", authored, grant), b], + }); + expect(evaluate("a", context).value).toBe(false); + expect(evaluate("c", context).value).toBe(true); + expect(evaluate("c", context).errors).toHaveLength(1); + }); + + it("keeps errors from distinct dependency definitions with the same missing field", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b"), reference("c")), + flag("b", invalidFilters[0].filter), + flag("c", invalidFilters[0].filter), + ], + }); + expect(evaluate("a", context).errors.map(({ source }) => source)).toEqual([ + { type: "flag", key: "b" }, + { type: "flag", key: "c" }, + ]); + }); + + it("preserves diagnostics through nested references without invalidating a valid grant", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b")), + flag("b", reference("c")), + flag("c", invalidFilters[0].filter, grant), + ], + }); + expect(evaluate("a", context)).toMatchObject({ + value: true, + errors: [ + { code: "MISSING_CONTEXT_FIELD", source: { type: "flag", key: "c" } }, + ], + }); + }); + + it("propagates nested uncertain nonmatches through negation", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", negate(reference("b"))), + flag("b", reference("c")), + flag("c", invalidFilters[0].filter), + ], + }); + expect(evaluate("a", context)).toMatchObject({ + value: false, + errors: [ + { code: "MISSING_CONTEXT_FIELD", source: { type: "flag", key: "c" } }, + ], + }); + }); + + it.each(["and", "or"] as const)( + "does not change authored %s error handling in a referenced flag", + (operator) => { + const evaluate = newEvaluator({ + flags: [ + flag("a", reference("b")), + flag("b", group(operator, invalidFilters[0].filter, grant)), + ], + }); + expect(evaluate("a", context).value).toBe(false); + }, + ); + + it("does not clear errors elsewhere in the containing rule", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", group("and", reference("b"), invalidFilters[1].filter)), + flag("b", invalidFilters[0].filter, grant), + ], + }); + const result = evaluate("a", context); + expect(result.value).toBe(false); + expect(result.errors.map(({ code }) => code)).toEqual([ + "MISSING_CONTEXT_FIELD", + "UNSUPPORTED_ARRAY_OPERATOR", + ]); + }); + + it.each([false, true])( + "keeps invalid segments fail-closed, negated: %s", + (negated) => { + const filter = segment("s"); + const evaluate = newEvaluator({ + flags: [flag("a", negated ? negate(filter) : filter)], + segments: [ + { key: "s", filter: group("or", invalidFilters[0].filter, grant) }, + ], + }); + expect(evaluate("a", context)).toMatchObject({ + value: false, + errors: [ + { + code: "MISSING_CONTEXT_FIELD", + source: { type: "segment", key: "s" }, + }, + ], + }); + }, + ); + + it("preserves the original source through nested invalid segment references", () => { + const evaluate = newEvaluator({ + flags: [flag("a", negate(segment("outer")))], + segments: [ + { key: "outer", filter: segment("inner") }, + { key: "inner", filter: invalidFilters[0].filter }, + ], + }); + expect(evaluate("a", context)).toMatchObject({ + value: false, + errors: [{ source: { type: "segment", key: "inner" } }], + }); + }); + + it("allows negation of a clean segment nonmatch", () => { + const evaluate = newEvaluator({ + flags: [flag("a", negate(segment("s")))], + segments: [{ key: "s", filter: no }], + }); + expect(evaluate("a", {})).toMatchObject({ value: true, errors: [] }); + }); + + it("allows a segment to reference a valid flag with nonfatal diagnostics", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", segment("s")), + flag("b", invalidFilters[0].filter, grant), + ], + segments: [{ key: "s", filter: reference("b") }], + }); + expect(evaluate("a", context)).toMatchObject({ + value: true, + errors: [ + { code: "MISSING_CONTEXT_FIELD", source: { type: "flag", key: "b" } }, + ], + }); + }); + + it("deduplicates repeated diagnostics but still invalidates every containing rule", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", negate(reference("b")), negate(reference("b"))), + flag("b", invalidFilters[0].filter), + ], + }); + const result = evaluate("a", context); + expect(result.value).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.ruleResults.map(({ matched }) => matched)).toEqual([ + false, + false, + true, + ]); + }); + + it("does not negate an unresolved dependency into a grant", () => { + const b = { ...flag("b"), rules: [rule("invalid-result", yes, "unknown")] }; + const evaluate = newEvaluator({ + flags: [flag("a", negate(reference("b"))), b], + }); + expect(evaluate("a", {})).toMatchObject({ + value: false, + errors: [ + { code: "INVALID_FLAG_DEFINITION", source: { type: "flag", key: "b" } }, + ], + }); + }); + + it.each([{}, { company: { id: ["acme"] } }])( + "fails closed on invalid distribution context: %j", + (requestContext) => { + const b = flag("b"); + b.rules.unshift({ + id: "split", + filter: yes, + result: { + type: "percentageDistribution", + key: "b", + attribute: "company.id", + allocations: [ + { + percentage: 100, + destination: { type: "variant", variantKey: "on" }, + }, + ], + }, + }); + const evaluate = newEvaluator({ + flags: [flag("a", negate(reference("b"))), b], + }); + expect(evaluate("a", requestContext).value).toBe(false); + expect(evaluate("a", requestContext).errors).toHaveLength(1); + }, + ); + + it("fails closed on invalid referenced percentage allocations", () => { + const b = flag("b"); + b.rules.unshift({ + id: "split", + filter: yes, + result: { + type: "percentageDistribution", + key: "b", + attribute: "company.id", + allocations: [ + { + percentage: 99, + destination: { type: "variant", variantKey: "on" }, + }, + ], + }, + }); + const evaluate = newEvaluator({ + flags: [flag("a", negate(reference("b"))), b], + }); + expect(evaluate("a", context)).toMatchObject({ + value: false, + errors: [{ code: "INVALID_FLAG_DEFINITION" }], + }); + }); + + it("treats a missing default in a dependency as unresolved", () => { + const evaluate = newEvaluator({ + flags: [flag("a", negate(reference("b"))), { ...flag("b"), rules: [] }], + }); + expect(evaluate("a", {}).value).toBe(false); + expect(evaluate("a", {}).errors).toMatchObject([ + { code: "INVALID_FLAG_DEFINITION", source: { type: "flag", key: "b" } }, + ]); + }); +}); + +describe("collection validation", () => { + it("accepts an empty collection", () => { + expect(() => newEvaluator({ flags: [] })).not.toThrow(); + }); + + it("rejects duplicate flag and segment keys", () => { + expect(() => newEvaluator({ flags: [flag("a"), flag("a")] })).toThrow( + 'Duplicate flag key "a"', + ); + expect(() => + newEvaluator({ + flags: [], + segments: [ + { key: "s", filter: yes }, + { key: "s", filter: no }, + ], + }), + ).toThrow('Duplicate segment key "s"'); + }); + + it.each([ + reference("missing"), + negate(reference("missing")), + group("or", yes, reference("missing")), + ])("rejects missing flag references even if unreachable: %j", (filter) => { + expect(() => newEvaluator({ flags: [flag("a", filter)] })).toThrow( + 'Unknown flag reference "missing"', + ); + }); + + it("rejects missing segments, including in unused segments", () => { + expect(() => + newEvaluator({ flags: [flag("a", segment("missing"))] }), + ).toThrow('Unknown segment reference "missing"'); + expect(() => + newEvaluator({ + flags: [], + segments: [{ key: "unused", filter: segment("missing") }], + }), + ).toThrow('Unknown segment reference "missing"'); + }); + + it.each(["unknown", "constructor", "__proto__"])( + "rejects unknown or inherited referenced variant %s", + (variantKey) => { + expect(() => + newEvaluator({ + flags: [flag("a", reference("b", variantKey)), flag("b")], + }), + ).toThrow(`Unknown variant "${variantKey}"`); + }, + ); + + it("rejects direct, indirect and negated flag cycles", () => { + for (const filter of [reference("a"), negate(reference("a"))]) { + expect(() => newEvaluator({ flags: [flag("a", filter)] })).toThrow( + "Circular reference", + ); + } + expect(() => + newEvaluator({ + flags: [flag("a", reference("b")), flag("b", reference("a"))], + }), + ).toThrow("Circular reference"); + }); + + it("rejects segment and mixed flag/segment cycles", () => { + expect(() => + newEvaluator({ + flags: [], + segments: [{ key: "s", filter: segment("s") }], + }), + ).toThrow("Circular reference"); + expect(() => + newEvaluator({ + flags: [], + segments: [ + { key: "s", filter: segment("t") }, + { key: "t", filter: negate(segment("s")) }, + ], + }), + ).toThrow("Circular reference"); + expect(() => + newEvaluator({ + flags: [flag("a", segment("s"))], + segments: [{ key: "s", filter: reference("a") }], + }), + ).toThrow("Circular reference"); + }); + + it("allows shared acyclic dependencies without treating them as cycles", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", group("and", reference("b"), reference("c"))), + flag("b", reference("d")), + flag("c", reference("d")), + flag("d", yes), + ], + }); + expect(evaluate("a", {}).value).toBe(true); + }); +}); + +describe("recursive evaluation without result caching", () => { + it("evaluates repeated dependencies independently and never reuses results across contexts", () => { + const evaluate = newEvaluator({ + flags: [ + flag("a", group("and", reference("b"), reference("b"))), + flag("b", grant), + ], + }); + const lookups = vi.spyOn(Set.prototype, "has"); + try { + expect(evaluate("a", context).value).toBe(true); + expect( + lookups.mock.calls.filter(([value]) => value === "acme"), + ).toHaveLength(2); + expect(evaluate("a", { company: { id: "other" } }).value).toBe(false); + expect(evaluate("a", context).value).toBe(true); + expect( + lookups.mock.calls.filter(([value]) => value === "acme"), + ).toHaveLength(4); + } finally { + lookups.mockRestore(); + } + }); + + it("does not retain mutations to segment definitions or scalar comparison candidates", () => { + const candidates = ["acme"]; + const predicate: RuleFilter = { + type: "context", + field: "company.id", + operator: "IS", + values: candidates, + }; + const s: CompiledSegment = { key: "s", filter: predicate }; + const evaluate = newEvaluator({ + flags: [flag("a", segment("s"))], + segments: [s], + }); + candidates[0] = "other"; + s.filter = no; + expect(evaluate("a", context).value).toBe(true); + }); + + it("isolates diagnostics and definition snapshots between calls", () => { + const b = flag("b", invalidFilters[0].filter, grant); + const a = flag("a", reference("b")); + const evaluate = newEvaluator({ flags: [a, b] }); + const first = evaluate("a", context); + expect(first.value).toBe(true); + first.errors[0].source!.key = "modified"; + first.ruleResults.length = 0; + (a.rules[0].filter as FlagReferenceFilter).key = "missing"; + (b.rules[1].filter as typeof grant).values![0] = "other"; + b.rules.length = 0; + const second = evaluate("a", context); + expect(second).toMatchObject({ + value: true, + errors: [{ source: { type: "flag", key: "b" } }], + }); + expect(second.ruleResults).toHaveLength(1); + }); +}); diff --git a/packages/flag-evaluation/test/variants.bench.ts b/packages/flag-evaluation/test/variants.bench.ts index fa53bf325..6c52c9503 100644 --- a/packages/flag-evaluation/test/variants.bench.ts +++ b/packages/flag-evaluation/test/variants.bench.ts @@ -2,7 +2,8 @@ import { bench, describe } from "vitest"; import { newEvaluator as newLegacyEvaluator } from "@reflag/flag-evaluation-v1"; -import { CompiledFlag, newEvaluator, RuleFilter } from "../src"; +import { CompiledFlag, RuleFilter } from "../src"; +import { prepareFlag } from "./helpers"; // Preparation is deliberately outside the timed callback, just as it should be // outside the SDK's getFlag hot path (rebuild only when definitions refresh). @@ -20,7 +21,7 @@ for (const size of [10, 1000, 100000]) { { filter, value: true }, { filter: { type: "constant", value: true }, value: false }, ]); - const prepared = newEvaluator({ + const prepared = prepareFlag({ key: "boolean", sourceVersionId: "v1", variants: { true: true, false: false }, @@ -70,7 +71,7 @@ for (const size of [1, 10, 50]) { }, ], }; - const prepared = newEvaluator(flag); + const prepared = prepareFlag(flag); bench("prepared v2", () => { prepared(context); }); diff --git a/packages/flag-evaluation/test/variants.test.ts b/packages/flag-evaluation/test/variants.test.ts index 780d5c022..417f6ca8c 100644 --- a/packages/flag-evaluation/test/variants.test.ts +++ b/packages/flag-evaluation/test/variants.test.ts @@ -3,13 +3,8 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { evaluateFlagRules } from "@reflag/flag-evaluation-v1"; import * as api from "../src"; -import { - CompiledFlag, - ContextFilter, - newEvaluator, - hashInt, - RuleResult, -} from "../src"; +import { CompiledFlag, ContextFilter, hashInt, RuleResult } from "../src"; +import { prepareFlag } from "./helpers"; const defaultRule = { id: "default", @@ -67,7 +62,7 @@ describe("v2 public API", () => { ...flag({ type: "variant", variantKey: "quality" }), variants: { quality: true }, }; - const result = newEvaluator(definition)({}); + const result = prepareFlag(definition)({}); expectTypeOf(result.value).toEqualTypeOf(); expect(result.resolved).toBe(true); if (!result.resolved) throw new Error("Expected a selected variant"); @@ -79,7 +74,7 @@ describe("v2 public API", () => { ...definition, variants: { quality: null }, }; - const selected = newEvaluator(nullable)({}); + const selected = prepareFlag(nullable)({}); if (!selected.resolved) throw new Error("Expected null variant"); expectTypeOf(selected.value).toEqualTypeOf(); expect(selected.value).toBeNull(); @@ -91,12 +86,12 @@ describe("v2 public API", () => { ...flag({ type: "variant", variantKey: "quality" }), variants: { quality: value }, }; - const result = newEvaluator(definition)({}); + const result = prepareFlag(definition)({}); if (!result.resolved) throw new Error("Expected a selected variant"); expectTypeOf(result.value).toEqualTypeOf(); expect(result.value).toBe(value); const callback = () => "hello"; - const selected = newEvaluator({ + const selected = prepareFlag({ ...definition, variants: { quality: callback }, })({}); @@ -109,12 +104,12 @@ describe("v2 public API", () => { ...flag({ type: "variant", variantKey: "quality" }), variants: { quality: undefined }, }; - expect(newEvaluator(definition)({})).toMatchObject({ + expect(prepareFlag(definition)({})).toMatchObject({ resolved: true, value: undefined, variantKey: "quality", }); - const failed = newEvaluator({ ...definition, rules: [] })({}); + const failed = prepareFlag({ ...definition, rules: [] })({}); expect(failed).toMatchObject({ resolved: false, value: undefined }); if (failed.resolved) throw new Error("Expected failure"); expectTypeOf(failed.value).toEqualTypeOf(); @@ -130,7 +125,7 @@ describe("variant selection", () => { )("selects $value", ({ value }) => { const definition = flag({ type: "variant", variantKey: "quality" }); definition.variants.quality = value; - expect(newEvaluator(definition)({})).toMatchObject({ + expect(prepareFlag(definition)({})).toMatchObject({ resolved: true, value, variantKey: "quality", @@ -144,7 +139,7 @@ describe("variant selection", () => { "assigns %s at hash %i to allocation %i", (id, bucket, allocationIndex, variantKey) => { expect(hashInt(`split.${id}`)).toBe(bucket); - const result = newEvaluator(flag(split()))({ company: { id } }); + const result = prepareFlag(flag(split()))({ company: { id } }); expect(result).toMatchObject({ resolved: true, allocationIndex, @@ -180,7 +175,7 @@ describe("variant selection", () => { result: { type: "variant", variantKey: "control" }, }, ); - const evaluate = newEvaluator(definition); + const evaluate = prepareFlag(definition); expect( evaluate({ user: { id: "alice" }, company: { id: "acme" } }) .matchedRuleId, @@ -203,7 +198,7 @@ describe("variant selection", () => { filter: { type: "constant", value: true }, result: { type: "variant", variantKey: "list" }, }); - const result = newEvaluator(definition)({ company: { id: "acme" } }); + const result = prepareFlag(definition)({ company: { id: "acme" } }); expect(result).toMatchObject({ resolved: true, value: [1, false], @@ -233,13 +228,13 @@ describe("variant selection", () => { operator: "IS", values: ["x"], }; - const result = newEvaluator(definition)({}); + const result = prepareFlag(definition)({}); expect(result.errors).toEqual([]); expect(result.ruleResults).toHaveLength(1); }); it("continues on missing/scalar-invalid bucketing context", () => { - const evaluate = newEvaluator(flag(split())); + const evaluate = prepareFlag(flag(split())); expect(evaluate({})).toMatchObject({ resolved: true, variantKey: "control", @@ -271,7 +266,7 @@ describe("variant selection", () => { ], }); expect( - newEvaluator(definition)({ company: { id: "acme" } }), + prepareFlag(definition)({ company: { id: "acme" } }), ).toMatchObject({ resolved: false, errors: [{ code: "INVALID_FLAG_DEFINITION" }], @@ -297,14 +292,14 @@ describe("variant selection", () => { }, ], }); - expect( - newEvaluator(definition)({ company: { id: "acme" } }).errors, - ).toEqual([]); + expect(prepareFlag(definition)({ company: { id: "acme" } }).errors).toEqual( + [], + ); }); it("rejects inherited property names as variants", () => { expect( - newEvaluator(flag({ type: "variant", variantKey: "constructor" }))({}), + prepareFlag(flag({ type: "variant", variantKey: "constructor" }))({}), ).toMatchObject({ resolved: false, errors: [{ code: "INVALID_FLAG_DEFINITION" }], @@ -325,7 +320,7 @@ describe("variant selection", () => { context, rules: [{ filter, value: true }], }); - const result = newEvaluator({ + const result = prepareFlag({ key: "boolean", sourceVersionId: "v1", variants: { true: true, false: false }, @@ -371,7 +366,7 @@ describe("prepared evaluator", () => { type: "negation", filter: { type: "group", operator: "and", filters: [leaf] }, }; - const evaluate = newEvaluator(definition); + const evaluate = prepareFlag(definition); expect(readCandidate).toHaveBeenCalled(); readCandidate.mockClear(); const lookups = vi.spyOn(Set.prototype, "has"); @@ -411,7 +406,7 @@ describe("prepared evaluator", () => { Object.defineProperty(distribution.allocations[0], "percentage", { get: reads, }); - const evaluate = newEvaluator(flag(distribution)); + const evaluate = prepareFlag(flag(distribution)); reads.mockClear(); for (let i = 0; i < 10; i++) { expect( @@ -434,7 +429,7 @@ describe("prepared evaluator", () => { distribution.allocations.splice(2, 0, zero); distribution.allocations.push(zero); expect( - newEvaluator(flag(distribution))({ company: { id } }).allocationIndex, + prepareFlag(flag(distribution))({ company: { id } }).allocationIndex, ).toBe(bucket < 60000 ? 1 : 3); }); @@ -459,7 +454,7 @@ describe("prepared evaluator", () => { }, ], }; - const evaluate = newEvaluator(definition); + const evaluate = prepareFlag(definition); for (let i = 0; i < 200; i++) { const id = `company-${i}`; const index = Math.min(Math.floor(hashInt(`split.${id}`) / 2000), 49); @@ -474,7 +469,7 @@ describe("prepared evaluator", () => { }); it("isolates returned diagnostics and destinations between calls", () => { - const evaluate = newEvaluator(flag(split())); + const evaluate = prepareFlag(flag(split())); const missing = evaluate({}); const selected = evaluate({ company: { id: "company-6295" } }); expect(missing.errors).toHaveLength(1); From ad0d702c07344eaa295d249400d9a9521a822ab3 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 29 Oct 2026 10:03:07 +0000 Subject: [PATCH 12/18] feat(flag-evaluation): expose batch and single evaluation methods --- .changeset/multivariate-evaluation.md | 2 +- packages/flag-evaluation/README.md | 41 ++- packages/flag-evaluation/src/index.ts | 56 +++- .../flag-evaluation/test/collection.test.ts | 300 ++++++++++++++++++ packages/flag-evaluation/test/helpers.ts | 5 +- .../flag-evaluation/test/references.test.ts | 117 ++++--- 6 files changed, 441 insertions(+), 80 deletions(-) create mode 100644 packages/flag-evaluation/test/collection.test.ts diff --git a/.changeset/multivariate-evaluation.md b/.changeset/multivariate-evaluation.md index 465148ed9..b65d2150c 100644 --- a/.changeset/multivariate-evaluation.md +++ b/.changeset/multivariate-evaluation.md @@ -4,7 +4,7 @@ Introduce a v2-only compiled-variant evaluator: -- `newEvaluator({ flags, segments })` prepares a complete collection and returns a reusable `(flagKey, context)` evaluator. Remove the v1 value-rule APIs and the one-shot `evaluateFlag` wrapper. +- `newEvaluator({ flags, segments })` prepares a complete collection and returns `evaluateAll(context)` and `evaluateFlag(context, flagKey)` methods. The batch result contains shared normalized context and per-flag results without repeated context. Single-flag evaluation visits only the requested flag and its dependencies. Unresolved batch entries do not prevent other flags from being evaluated. Remove the v1 value-rule APIs and the one-shot `evaluateFlag` wrapper. - Preserve flag and segment reference nodes instead of inlining their filters. Flag references explicitly match variant keys; segments remain reusable predicates. Resolve references recursively without dependency-result caching or memoization. - Preserve diagnostics from unsuccessful referenced rules without suppressing a valid matching selection. Invalid nonmatches remain fail-closed through negation. Propagated diagnostics identify their source flag or segment. - Reject duplicate keys, missing references, unknown referenced variant keys and cycles at preparation, including mixed flag/segment cycles. Evaluating an unknown flag key throws. diff --git a/packages/flag-evaluation/README.md b/packages/flag-evaluation/README.md index 2c488a8cf..01f55645a 100644 --- a/packages/flag-evaluation/README.md +++ b/packages/flag-evaluation/README.md @@ -1,19 +1,23 @@ # Flag evaluation v2 Prepare a complete collection of compiled flags and segments when definitions -are loaded or refreshed, then select a flag by key for each context: +are loaded or refreshed, then evaluate all flags or select one flag for each context: ```ts import { newEvaluator } from "@reflag/flag-evaluation"; -const evaluate = newEvaluator({ +const evaluator = newEvaluator({ flags: compiledFlags, segments: compiledSegments, }); -const result = evaluate("my-flag", { - company: { id: "acme" }, - user: { id: "alice" }, -}); +const context = { company: { id: "acme" }, user: { id: "alice" } }; + +// getFlags() / getFlagsForBootstrap(): all flags, one normalized context. +const batch = evaluator.evaluateAll(context); +const selected = batch.flags["my-flag"]; + +// getFlag(): just the requested flag and its dependencies. +const result = evaluator.evaluateFlag(context, "my-flag"); // Diagnostics can accompany either a successful result or a failure. if (result.errors.length) console.warn(result.errors); @@ -28,13 +32,25 @@ if (result.resolved) { } ``` -`CompiledFlag`, `CompiledCollection` and -`EvaluationResult` preserve the caller's value type. A heterogeneous -collection can use a union for `T`. Non-fatal diagnostics may also accompany a -successful default/fallthrough result. +`CompiledFlag`, `CompiledCollection`, +`FlagEvaluationResult` and `EvaluationResult` preserve the +caller's value type. A heterogeneous collection can use a union for `T`. +Non-fatal diagnostics may also accompany a successful default/fallthrough result. + +`evaluateAll(context)` returns `CollectionEvaluationResult`: a shared normalized +`context` and a `flags` record keyed by flag key. Each entry is a +`FlagEvaluationResult` with its own resolution status, value, metadata and +errors, without a repeated `context`. Unresolved flags do not prevent other flags +from being evaluated; segments are not returned as outputs. + +`evaluateFlag(context, flagKey)` returns `EvaluationResult`, which adds normalized +`context` to one flag result. It evaluates dependencies but not unrelated flags. +Both operations use the complete collection for resolving references. Rebuild the evaluator when definitions change. Treat definitions and returned -values as immutable. Each evaluation has independent diagnostics. +values as immutable. Each operation has independent diagnostics. SDK-specific +tracking, fallbacks, overrides and bootstrap snapshot versions remain outside the +evaluator. ## References @@ -79,7 +95,8 @@ are prepared ahead of time. V2 exports `newEvaluator(collection)`, `flattenContext`, and `hashInt`, plus their public types. `newEvaluator` accepts a complete compiled collection, not value -rules or a single flag. The returned function accepts `(flagKey, context)`. +rules or a single flag. It returns an object with `evaluateAll(context)` and +`evaluateFlag(context, flagKey)` methods, rather than a callable evaluator. Publishers and consumers must adopt the collection/reference protocol together; the unchanged Node SDK remains on the published v1 evaluator. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 9c4375093..3c004a566 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -128,10 +128,9 @@ export type RuleEvaluationResult = { }; /** A resolved variant can contain any T, including undefined. */ -export type EvaluationResult = { +export type FlagEvaluationResult = { flagKey: string; sourceVersionId: string; - context: FlattenedContext; ruleResults: RuleEvaluationResult[]; errors: EvaluationError[]; } & ( @@ -151,6 +150,23 @@ export type EvaluationResult = { } ); +export type EvaluationResult = FlagEvaluationResult & { + context: FlattenedContext; +}; + +export type CollectionEvaluationResult = { + context: FlattenedContext; + flags: Record>; +}; + +export type Evaluator = { + evaluateAll(context: Record): CollectionEvaluationResult; + evaluateFlag( + context: Record, + flagKey: string, + ): EvaluationResult; +}; + function normalizeArrayElement(value: unknown): string | undefined { if (value === undefined) return undefined; if (value === null) return ""; @@ -584,7 +600,7 @@ function validateReferences( */ export function newEvaluator( collection: CompiledCollection, -): (flagKey: string, context: Record) => EvaluationResult { +): Evaluator { const flags = new Map>(); const segments = new Map(); for (const flag of collection.flags) { @@ -602,21 +618,35 @@ export function newEvaluator( segments.set(segment.key, prepareFilter(segment.filter)); } validateReferences(flags, segments); - return (flagKey, context) => { - const flag = flags.get(flagKey); - if (!flag) throw new Error(`Unknown flag "${flagKey}"`); - return evaluatePreparedFlag(flag, { - flags, - segments, - context: flattenContext(context), - }); + function createState(context: Record): EvaluationState { + return { flags, segments, context: flattenContext(context) }; + } + + return { + evaluateAll(context) { + const state = createState(context); + const results = Object.create(null) as Record< + string, + FlagEvaluationResult + >; + for (const [key, flag] of flags) { + results[key] = evaluatePreparedFlag(flag, state); + } + return { context: state.context, flags: results }; + }, + evaluateFlag(context, flagKey) { + const flag = flags.get(flagKey); + if (!flag) throw new Error(`Unknown flag "${flagKey}"`); + const state = createState(context); + return { ...evaluatePreparedFlag(flag, state), context: state.context }; + }, }; } function evaluatePreparedFlag( flag: PreparedFlag, state: EvaluationState, -): EvaluationResult { +): FlagEvaluationResult { const flatContext = state.context; const errors = new Map(); const filterErrors = new Map(); @@ -684,7 +714,6 @@ function evaluatePreparedFlag( resolved: true, flagKey: flag.key, sourceVersionId: flag.sourceVersionId, - context: flatContext, ruleResults, errors: Array.from(errors.values()), value: flag.variants[destination.variantKey], @@ -703,7 +732,6 @@ function evaluatePreparedFlag( value: undefined, flagKey: flag.key, sourceVersionId: flag.sourceVersionId, - context: flatContext, ruleResults, errors: Array.from(errors.values()), }; diff --git a/packages/flag-evaluation/test/collection.test.ts b/packages/flag-evaluation/test/collection.test.ts new file mode 100644 index 000000000..01d600adb --- /dev/null +++ b/packages/flag-evaluation/test/collection.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + CollectionEvaluationResult, + CompiledFlag, + EvaluationResult, + FlagEvaluationResult, + newEvaluator, + RuleFilter, +} from "../src"; + +const always: RuleFilter = { type: "constant", value: true }; +const grant: RuleFilter = { + type: "context", + field: "company.id", + operator: "ANY_OF", + values: ["acme"], +}; +const missing: RuleFilter = { + type: "context", + field: "missing", + operator: "IS", + values: ["yes"], +}; +const reference: RuleFilter = { + type: "flag", + key: "dependency", + variantKey: "on", +}; +const context = { company: { id: "acme" } }; + +function flag(key: string, filter: RuleFilter = always): CompiledFlag { + return { + key, + sourceVersionId: `version-${key}`, + variants: { on: true, off: false }, + rules: [ + { id: "target", filter, result: { type: "variant", variantKey: "on" } }, + { + id: "default", + filter: always, + result: { type: "variant", variantKey: "off" }, + }, + ], + }; +} + +describe("batch and single-flag API", () => { + it("exposes explicit operations and preserves generic result types", () => { + const evaluator = newEvaluator({ flags: [flag("a")] }); + expect(Object.keys(evaluator).sort()).toEqual([ + "evaluateAll", + "evaluateFlag", + ]); + const batch = evaluator.evaluateAll(context); + const single = evaluator.evaluateFlag(context, "a"); + expectTypeOf(batch).toEqualTypeOf>(); + expectTypeOf(batch.flags.a).toEqualTypeOf>(); + expectTypeOf(single).toEqualTypeOf>(); + if (!batch.flags.a.resolved || !single.resolved) + throw new Error("Expected selections"); + expectTypeOf(batch.flags.a.value).toEqualTypeOf(); + expectTypeOf(single.value).toEqualTypeOf(); + }); + + it("evaluates every flag but keeps segments out of the outputs", () => { + const evaluator = newEvaluator({ + flags: [ + flag("a", { type: "segment", key: "enterprise" }), + flag("dependency", grant), + flag("b", reference), + ], + segments: [{ key: "enterprise", filter: reference }], + }); + const result = evaluator.evaluateAll(context); + expect(Object.keys(result.flags)).toEqual(["a", "dependency", "b"]); + for (const [key, selected] of Object.entries(result.flags)) { + expect(selected).toMatchObject({ + flagKey: key, + sourceVersionId: `version-${key}`, + resolved: true, + value: true, + variantKey: "on", + matchedRuleId: "target", + errors: [], + }); + expect(selected).not.toHaveProperty("context"); + } + expect(result.context).toEqual({ "company.id": "acme" }); + }); + + it("normalizes context once for the entire batch, including dependencies", () => { + const readId = vi.fn(() => "acme"); + const request = { + company: { + get id() { + return readId(); + }, + }, + }; + const evaluator = newEvaluator({ + flags: [ + flag("a", reference), + flag("b", reference), + flag("dependency", grant), + ], + }); + expect(evaluator.evaluateAll(request).flags.a.value).toBe(true); + expect(readId).toHaveBeenCalledTimes(1); + expect(evaluator.evaluateFlag(request, "a").value).toBe(true); + expect(readId).toHaveBeenCalledTimes(2); + }); + + it("returns the same per-flag results from both operations", () => { + const evaluator = newEvaluator({ + flags: [ + flag("a", reference), + { + ...flag("dependency", missing), + rules: [ + ...flag("dependency", missing) + .rules.slice(0, 1) + .map((rule) => ({ ...rule, id: "invalid" })), + ...flag("dependency", grant).rules, + ], + }, + flag("negative", { type: "negation", filter: reference }), + ], + }); + for (const request of [context, { company: { id: "other" } }]) { + const batch = evaluator.evaluateAll(request); + for (const [key, result] of Object.entries(batch.flags)) { + const { context: singleContext, ...single } = evaluator.evaluateFlag( + request, + key, + ); + expect(result).toEqual(single); + expect(singleContext).toEqual(batch.context); + } + } + }); + + it("does not let one unresolved flag prevent other outputs", () => { + const evaluator = newEvaluator({ + flags: [ + { ...flag("broken"), rules: [] }, + flag("a"), + flag("dependent", { type: "flag", key: "broken", variantKey: "on" }), + flag("last"), + ], + }); + const { flags } = evaluator.evaluateAll({}); + expect(Object.keys(flags)).toEqual(["broken", "a", "dependent", "last"]); + expect(flags.broken).toMatchObject({ + resolved: false, + errors: [{ code: "INVALID_FLAG_DEFINITION" }], + }); + expect(flags.a).toMatchObject({ resolved: true, value: true, errors: [] }); + expect(flags.dependent).toMatchObject({ + resolved: true, + value: false, + errors: [{ source: { type: "flag", key: "broken" } }], + }); + expect(flags.last).toMatchObject({ + resolved: true, + value: true, + errors: [], + }); + }); + + it("does not evaluate unrelated flags during evaluateFlag", () => { + const evaluator = newEvaluator({ + flags: [ + flag("a", reference), + flag("dependency", grant), + flag("unrelated", grant), + ], + }); + const lookups = vi.spyOn(Set.prototype, "has"); + try { + expect(evaluator.evaluateFlag(context, "a").value).toBe(true); + expect( + lookups.mock.calls.filter(([value]) => value === "acme"), + ).toHaveLength(1); + } finally { + lookups.mockRestore(); + } + }); + + it.each([false, true])( + "re-evaluates dependencies without memoization, reversed: %s", + (reversed) => { + const definitions = [ + flag("a", { + type: "group", + operator: "and", + filters: [reference, reference], + }), + flag("dependency", grant), + ]; + const evaluator = newEvaluator({ + flags: reversed ? definitions.reverse() : definitions, + }); + const lookups = vi.spyOn(Set.prototype, "has"); + try { + expect(evaluator.evaluateAll(context).flags.a.value).toBe(true); + // Twice through a, once as a root output, regardless of definition order. + expect( + lookups.mock.calls.filter(([value]) => value === "acme"), + ).toHaveLength(3); + expect( + evaluator.evaluateAll({ company: { id: "other" } }).flags.a.value, + ).toBe(false); + expect(evaluator.evaluateAll(context).flags.a.value).toBe(true); + expect( + lookups.mock.calls.filter(([value]) => value === "acme"), + ).toHaveLength(6); + } finally { + lookups.mockRestore(); + } + }, + ); + + it("keeps diagnostics local to each flag and each call", () => { + const evaluator = newEvaluator({ + flags: [flag("a", reference), flag("dependency", missing), flag("clean")], + }); + const first = evaluator.evaluateAll(context); + expect(first.flags.a.errors).toMatchObject([ + { source: { type: "flag", key: "dependency" } }, + ]); + expect(first.flags.dependency.errors).toHaveLength(1); + expect(first.flags.clean.errors).toEqual([]); + first.flags.a.errors[0].source!.key = "changed"; + first.flags.a.ruleResults.length = 0; + first.context["company.id"] = "changed"; + expect(first.flags.dependency.errors[0]).not.toHaveProperty("source"); + const second = evaluator.evaluateAll(context); + expect(second.context["company.id"]).toBe("acme"); + expect(second.flags.a.ruleResults.length).toBeGreaterThan(0); + expect(second.flags.a.errors).toMatchObject([ + { source: { type: "flag", key: "dependency" } }, + ]); + expect(evaluator.evaluateFlag(context, "a").errors).toEqual( + second.flags.a.errors, + ); + }); + + it("distinguishes selected undefined from unresolved in batch results", () => { + const definition: CompiledFlag = { + ...flag("selected"), + variants: { on: undefined }, + }; + const evaluator = newEvaluator({ + flags: [definition, { ...definition, key: "failed", rules: [] }], + }); + const { flags } = evaluator.evaluateAll({}); + expect(flags.selected).toMatchObject({ + resolved: true, + value: undefined, + variantKey: "on", + }); + expect(flags.failed).toMatchObject({ resolved: false, value: undefined }); + if (!flags.selected.resolved) throw new Error("Expected selection"); + expectTypeOf(flags.selected.value).toEqualTypeOf(); + expectTypeOf(flags.selected.variantKey).toEqualTypeOf(); + }); + + it("returns an empty batch for an empty collection", () => { + const evaluator = newEvaluator({ + flags: [], + segments: [{ key: "unused", filter: always }], + }); + expect(evaluator.evaluateAll(context)).toEqual({ + context: { "company.id": "acme" }, + flags: {}, + }); + expect(() => evaluator.evaluateFlag(context, "missing")).toThrow( + 'Unknown flag "missing"', + ); + }); + + it.each(["__proto__", "constructor", "toString"])( + "returns own flag key %s safely", + (key) => { + const result = newEvaluator({ flags: [flag(key)] }).evaluateAll({}); + expect(Object.getPrototypeOf(result.flags)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(result.flags, key)).toBe( + true, + ); + expect(Object.keys(result.flags)).toEqual([key]); + expect(result.flags[key]).toMatchObject({ + flagKey: key, + resolved: true, + value: true, + }); + expect(JSON.parse(JSON.stringify(result)).flags[key].flagKey).toBe(key); + }, + ); +}); diff --git a/packages/flag-evaluation/test/helpers.ts b/packages/flag-evaluation/test/helpers.ts index fc1a4a8be..6e3ce6c62 100644 --- a/packages/flag-evaluation/test/helpers.ts +++ b/packages/flag-evaluation/test/helpers.ts @@ -2,6 +2,7 @@ import { CompiledFlag, newEvaluator } from "../src"; /** Keep single-flag operator/allocation fixtures focused on their behavior. */ export function prepareFlag(flag: CompiledFlag) { - const evaluate = newEvaluator({ flags: [flag] }); - return (context: Record) => evaluate(flag.key, context); + const evaluator = newEvaluator({ flags: [flag] }); + return (context: Record) => + evaluator.evaluateFlag(context, flag.key); } diff --git a/packages/flag-evaluation/test/references.test.ts b/packages/flag-evaluation/test/references.test.ts index 4d2343f17..6d7fa0ad9 100644 --- a/packages/flag-evaluation/test/references.test.ts +++ b/packages/flag-evaluation/test/references.test.ts @@ -152,7 +152,7 @@ describe("collection evaluation", () => { it("selects a flag by key and preserves its value type and metadata", () => { const definition = flag("a", reference("b")); const evaluate = newEvaluator({ flags: [definition, flag("b", grant)] }); - const result = evaluate("a", context); + const result = evaluate.evaluateFlag(context, "a"); expect(result).toMatchObject({ resolved: true, value: true, @@ -165,9 +165,13 @@ describe("collection evaluation", () => { expectTypeOf(result.value).toEqualTypeOf(); if (!result.resolved) throw new Error("Expected a selected variant"); expectTypeOf(result.value).toEqualTypeOf(); - expect(evaluate("b", context).value).toBe(true); - expect(evaluate("a", { company: { id: "other" } }).value).toBe(false); - expect(() => evaluate("missing", {})).toThrow('Unknown flag "missing"'); + expect(evaluate.evaluateFlag(context, "b").value).toBe(true); + expect(evaluate.evaluateFlag({ company: { id: "other" } }, "a").value).toBe( + false, + ); + expect(() => evaluate.evaluateFlag({}, "missing")).toThrow( + 'Unknown flag "missing"', + ); }); it("matches multivariate variant keys, not value truthiness or object identity", () => { @@ -184,9 +188,9 @@ describe("collection evaluation", () => { b, ], }); - expect(evaluate("a", {}).value).toBe(true); - expect(evaluate("c", {}).value).toBe(false); - expect(evaluate("b", {}).value).toBe(value); + expect(evaluate.evaluateFlag({}, "a").value).toBe(true); + expect(evaluate.evaluateFlag({}, "c").value).toBe(false); + expect(evaluate.evaluateFlag({}, "b").value).toBe(value); } }); @@ -201,7 +205,7 @@ describe("collection evaluation", () => { }, ], }; - const result = newEvaluator(collection)("b", {}); + const result = newEvaluator(collection).evaluateFlag({}, "b"); expectTypeOf(result.value).toEqualTypeOf(); expect(result.value).toBe("large"); }); @@ -219,8 +223,10 @@ describe("collection evaluation", () => { ], segments, }); - expect(evaluate("a", context).value).toBe(true); - expect(evaluate("a", { company: { id: "other" } }).value).toBe(false); + expect(evaluate.evaluateFlag(context, "a").value).toBe(true); + expect(evaluate.evaluateFlag({ company: { id: "other" } }, "a").value).toBe( + false, + ); }); it("allows flag and segment keys to share the same name", () => { @@ -228,7 +234,7 @@ describe("collection evaluation", () => { flags: [flag("a", reference("same")), flag("same", segment("same"))], segments: [{ key: "same", filter: yes }], }); - expect(evaluate("a", {}).value).toBe(true); + expect(evaluate.evaluateFlag({}, "a").value).toBe(true); }); it.each(["__proto__", "constructor", "toString"])( @@ -245,8 +251,8 @@ describe("collection evaluation", () => { ], segments: [{ key, filter: yes }], }); - expect(evaluate("a", {}).value).toBe(true); - expect(evaluate(key, {}).value).toBe(true); + expect(evaluate.evaluateFlag({}, "a").value).toBe(true); + expect(evaluate.evaluateFlag({}, key).value).toBe(true); }, ); @@ -265,7 +271,7 @@ describe("collection evaluation", () => { }); for (let i = 0; i < 100; i++) { const id = `company-${i}`; - expect(evaluate("a", { company: { id } }).value).toBe( + expect(evaluate.evaluateFlag({ company: { id } }, "a").value).toBe( hashInt(`original-b-key.${id}`) < threshold, ); } @@ -292,7 +298,7 @@ describe("collection evaluation", () => { const evaluate = newEvaluator({ flags: [flag("a", reference("b")), b] }); for (let i = 0; i < 100; i++) { const id = `company-${i}`; - expect(evaluate("a", { company: { id } }).value).toBe( + expect(evaluate.evaluateFlag({ company: { id } }, "a").value).toBe( Math.min(hashInt(`b-split.${id}`), 99999) < 50000, ); } @@ -310,7 +316,7 @@ describe("reference error boundaries", () => { flag("b", ...(grantFirst ? [grant, filter] : [filter, grant])), ], }); - const result = evaluate("a", context); + const result = evaluate.evaluateFlag(context, "a"); expect(result.value).toBe(true); expect(result.errors).toEqual( grantFirst @@ -332,7 +338,7 @@ describe("reference error boundaries", () => { const evaluate = newEvaluator({ flags: [flag("a", negate(reference("b"))), flag("b", filter, no)], }); - const result = evaluate("a", context); + const result = evaluate.evaluateFlag(context, "a"); expect(result.value).toBe(false); expect(result.errors).toEqual([ expect.objectContaining({ code, source: { type: "flag", key: "b" } }), @@ -344,8 +350,10 @@ describe("reference error boundaries", () => { const evaluate = newEvaluator({ flags: [flag("a", negate(reference("b"))), flag("b", grant)], }); - expect(evaluate("a", { company: { id: "other" } }).value).toBe(true); - expect(evaluate("a", context).value).toBe(false); + expect(evaluate.evaluateFlag({ company: { id: "other" } }, "a").value).toBe( + true, + ); + expect(evaluate.evaluateFlag(context, "a").value).toBe(false); }); it("distinguishes an explicit fallback selection from negating an uncertain nonmatch", () => { @@ -356,10 +364,10 @@ describe("reference error boundaries", () => { flag("b", invalidFilters[0].filter), ], }); - expect(evaluate("a", context).value).toBe(true); - expect(evaluate("c", context).value).toBe(false); - expect(evaluate("a", context).errors).toHaveLength(1); - expect(evaluate("c", context).errors).toHaveLength(1); + expect(evaluate.evaluateFlag(context, "a").value).toBe(true); + expect(evaluate.evaluateFlag(context, "c").value).toBe(false); + expect(evaluate.evaluateFlag(context, "a").errors).toHaveLength(1); + expect(evaluate.evaluateFlag(context, "c").errors).toHaveLength(1); }); it("does not negate a matching selection with nonfatal diagnostics into a grant", () => { @@ -369,7 +377,7 @@ describe("reference error boundaries", () => { flag("b", invalidFilters[0].filter, grant), ], }); - expect(evaluate("a", context)).toMatchObject({ + expect(evaluate.evaluateFlag(context, "a")).toMatchObject({ value: false, errors: [ { code: "MISSING_CONTEXT_FIELD", source: { type: "flag", key: "b" } }, @@ -384,7 +392,7 @@ describe("reference error boundaries", () => { flag("b", negate(invalidFilters[0].filter)), ], }); - expect(evaluate("a", context).value).toBe(false); + expect(evaluate.evaluateFlag(context, "a").value).toBe(false); }); it("keeps authored OR errors local to a rule, not the entire flag", () => { @@ -393,9 +401,9 @@ describe("reference error boundaries", () => { const evaluate = newEvaluator({ flags: [flag("a", authored), flag("c", authored, grant), b], }); - expect(evaluate("a", context).value).toBe(false); - expect(evaluate("c", context).value).toBe(true); - expect(evaluate("c", context).errors).toHaveLength(1); + expect(evaluate.evaluateFlag(context, "a").value).toBe(false); + expect(evaluate.evaluateFlag(context, "c").value).toBe(true); + expect(evaluate.evaluateFlag(context, "c").errors).toHaveLength(1); }); it("keeps errors from distinct dependency definitions with the same missing field", () => { @@ -406,7 +414,9 @@ describe("reference error boundaries", () => { flag("c", invalidFilters[0].filter), ], }); - expect(evaluate("a", context).errors.map(({ source }) => source)).toEqual([ + expect( + evaluate.evaluateFlag(context, "a").errors.map(({ source }) => source), + ).toEqual([ { type: "flag", key: "b" }, { type: "flag", key: "c" }, ]); @@ -420,7 +430,7 @@ describe("reference error boundaries", () => { flag("c", invalidFilters[0].filter, grant), ], }); - expect(evaluate("a", context)).toMatchObject({ + expect(evaluate.evaluateFlag(context, "a")).toMatchObject({ value: true, errors: [ { code: "MISSING_CONTEXT_FIELD", source: { type: "flag", key: "c" } }, @@ -436,7 +446,7 @@ describe("reference error boundaries", () => { flag("c", invalidFilters[0].filter), ], }); - expect(evaluate("a", context)).toMatchObject({ + expect(evaluate.evaluateFlag(context, "a")).toMatchObject({ value: false, errors: [ { code: "MISSING_CONTEXT_FIELD", source: { type: "flag", key: "c" } }, @@ -453,7 +463,7 @@ describe("reference error boundaries", () => { flag("b", group(operator, invalidFilters[0].filter, grant)), ], }); - expect(evaluate("a", context).value).toBe(false); + expect(evaluate.evaluateFlag(context, "a").value).toBe(false); }, ); @@ -464,7 +474,7 @@ describe("reference error boundaries", () => { flag("b", invalidFilters[0].filter, grant), ], }); - const result = evaluate("a", context); + const result = evaluate.evaluateFlag(context, "a"); expect(result.value).toBe(false); expect(result.errors.map(({ code }) => code)).toEqual([ "MISSING_CONTEXT_FIELD", @@ -482,7 +492,7 @@ describe("reference error boundaries", () => { { key: "s", filter: group("or", invalidFilters[0].filter, grant) }, ], }); - expect(evaluate("a", context)).toMatchObject({ + expect(evaluate.evaluateFlag(context, "a")).toMatchObject({ value: false, errors: [ { @@ -502,7 +512,7 @@ describe("reference error boundaries", () => { { key: "inner", filter: invalidFilters[0].filter }, ], }); - expect(evaluate("a", context)).toMatchObject({ + expect(evaluate.evaluateFlag(context, "a")).toMatchObject({ value: false, errors: [{ source: { type: "segment", key: "inner" } }], }); @@ -513,7 +523,10 @@ describe("reference error boundaries", () => { flags: [flag("a", negate(segment("s")))], segments: [{ key: "s", filter: no }], }); - expect(evaluate("a", {})).toMatchObject({ value: true, errors: [] }); + expect(evaluate.evaluateFlag({}, "a")).toMatchObject({ + value: true, + errors: [], + }); }); it("allows a segment to reference a valid flag with nonfatal diagnostics", () => { @@ -524,7 +537,7 @@ describe("reference error boundaries", () => { ], segments: [{ key: "s", filter: reference("b") }], }); - expect(evaluate("a", context)).toMatchObject({ + expect(evaluate.evaluateFlag(context, "a")).toMatchObject({ value: true, errors: [ { code: "MISSING_CONTEXT_FIELD", source: { type: "flag", key: "b" } }, @@ -539,7 +552,7 @@ describe("reference error boundaries", () => { flag("b", invalidFilters[0].filter), ], }); - const result = evaluate("a", context); + const result = evaluate.evaluateFlag(context, "a"); expect(result.value).toBe(false); expect(result.errors).toHaveLength(1); expect(result.ruleResults.map(({ matched }) => matched)).toEqual([ @@ -554,7 +567,7 @@ describe("reference error boundaries", () => { const evaluate = newEvaluator({ flags: [flag("a", negate(reference("b"))), b], }); - expect(evaluate("a", {})).toMatchObject({ + expect(evaluate.evaluateFlag({}, "a")).toMatchObject({ value: false, errors: [ { code: "INVALID_FLAG_DEFINITION", source: { type: "flag", key: "b" } }, @@ -584,8 +597,8 @@ describe("reference error boundaries", () => { const evaluate = newEvaluator({ flags: [flag("a", negate(reference("b"))), b], }); - expect(evaluate("a", requestContext).value).toBe(false); - expect(evaluate("a", requestContext).errors).toHaveLength(1); + expect(evaluate.evaluateFlag(requestContext, "a").value).toBe(false); + expect(evaluate.evaluateFlag(requestContext, "a").errors).toHaveLength(1); }, ); @@ -609,7 +622,7 @@ describe("reference error boundaries", () => { const evaluate = newEvaluator({ flags: [flag("a", negate(reference("b"))), b], }); - expect(evaluate("a", context)).toMatchObject({ + expect(evaluate.evaluateFlag(context, "a")).toMatchObject({ value: false, errors: [{ code: "INVALID_FLAG_DEFINITION" }], }); @@ -619,8 +632,8 @@ describe("reference error boundaries", () => { const evaluate = newEvaluator({ flags: [flag("a", negate(reference("b"))), { ...flag("b"), rules: [] }], }); - expect(evaluate("a", {}).value).toBe(false); - expect(evaluate("a", {}).errors).toMatchObject([ + expect(evaluate.evaluateFlag({}, "a").value).toBe(false); + expect(evaluate.evaluateFlag({}, "a").errors).toMatchObject([ { code: "INVALID_FLAG_DEFINITION", source: { type: "flag", key: "b" } }, ]); }); @@ -725,7 +738,7 @@ describe("collection validation", () => { flag("d", yes), ], }); - expect(evaluate("a", {}).value).toBe(true); + expect(evaluate.evaluateFlag({}, "a").value).toBe(true); }); }); @@ -739,12 +752,14 @@ describe("recursive evaluation without result caching", () => { }); const lookups = vi.spyOn(Set.prototype, "has"); try { - expect(evaluate("a", context).value).toBe(true); + expect(evaluate.evaluateFlag(context, "a").value).toBe(true); expect( lookups.mock.calls.filter(([value]) => value === "acme"), ).toHaveLength(2); - expect(evaluate("a", { company: { id: "other" } }).value).toBe(false); - expect(evaluate("a", context).value).toBe(true); + expect( + evaluate.evaluateFlag({ company: { id: "other" } }, "a").value, + ).toBe(false); + expect(evaluate.evaluateFlag(context, "a").value).toBe(true); expect( lookups.mock.calls.filter(([value]) => value === "acme"), ).toHaveLength(4); @@ -768,21 +783,21 @@ describe("recursive evaluation without result caching", () => { }); candidates[0] = "other"; s.filter = no; - expect(evaluate("a", context).value).toBe(true); + expect(evaluate.evaluateFlag(context, "a").value).toBe(true); }); it("isolates diagnostics and definition snapshots between calls", () => { const b = flag("b", invalidFilters[0].filter, grant); const a = flag("a", reference("b")); const evaluate = newEvaluator({ flags: [a, b] }); - const first = evaluate("a", context); + const first = evaluate.evaluateFlag(context, "a"); expect(first.value).toBe(true); first.errors[0].source!.key = "modified"; first.ruleResults.length = 0; (a.rules[0].filter as FlagReferenceFilter).key = "missing"; (b.rules[1].filter as typeof grant).values![0] = "other"; b.rules.length = 0; - const second = evaluate("a", context); + const second = evaluate.evaluateFlag(context, "a"); expect(second).toMatchObject({ value: true, errors: [{ source: { type: "flag", key: "b" } }], From 2448fcabe7ca5fe9342ab5b8c1eb58dc2a509739 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 4 Nov 2026 08:16:39 +0000 Subject: [PATCH 13/18] feat(node-sdk): adopt collection values and Boolean-first reads --- .changeset/node-sdk-collection-values.md | 18 + .changeset/pin-legacy-node-evaluator.md | 5 - packages/flag-evaluation/test/release.test.ts | 16 +- packages/node-sdk/README.md | 1131 +---- packages/node-sdk/examples/express/app.ts | 33 +- packages/node-sdk/examples/express/reflag.ts | 16 +- packages/node-sdk/package.json | 7 +- packages/node-sdk/src/client.ts | 2498 +++------ packages/node-sdk/src/config.ts | 22 +- packages/node-sdk/src/flag-defaults.ts | 32 + packages/node-sdk/src/flag-definitions.ts | 208 + packages/node-sdk/src/flags-cache.ts | 11 +- .../node-sdk/src/flagsFallbackProvider.ts | 67 +- packages/node-sdk/src/index.ts | 17 +- packages/node-sdk/src/types.ts | 900 +--- packages/node-sdk/test/client-sync.test.ts | 153 + packages/node-sdk/test/client.test.ts | 4503 ++++------------- packages/node-sdk/test/fixtures.ts | 62 + .../node-sdk/test/flag-definitions.test.ts | 112 + .../test/flagsFallbackProvider.test.ts | 437 +- packages/node-sdk/tsconfig.test.json | 20 + .../tsconfig.type-tests-augmented.json | 4 + packages/node-sdk/tsconfig.type-tests.json | 8 + .../node-sdk/type-tests/augmented-defaults.ts | 36 + packages/node-sdk/type-tests/defaults.ts | 58 + .../openfeature-node-provider/package.json | 2 +- .../src/index.test.ts | 6 +- .../openfeature-node-provider/src/index.ts | 2 +- .../dev/nextjs-bootstrap-demo/app/client.ts | 2 +- .../dev/nextjs-bootstrap-demo/package.json | 2 +- yarn.lock | 24 +- 31 files changed, 2803 insertions(+), 7609 deletions(-) create mode 100644 .changeset/node-sdk-collection-values.md delete mode 100644 .changeset/pin-legacy-node-evaluator.md create mode 100644 packages/node-sdk/src/flag-defaults.ts create mode 100644 packages/node-sdk/src/flag-definitions.ts create mode 100644 packages/node-sdk/test/client-sync.test.ts create mode 100644 packages/node-sdk/test/fixtures.ts create mode 100644 packages/node-sdk/test/flag-definitions.test.ts create mode 100644 packages/node-sdk/tsconfig.test.json create mode 100644 packages/node-sdk/tsconfig.type-tests-augmented.json create mode 100644 packages/node-sdk/tsconfig.type-tests.json create mode 100644 packages/node-sdk/type-tests/augmented-defaults.ts create mode 100644 packages/node-sdk/type-tests/defaults.ts diff --git a/.changeset/node-sdk-collection-values.md b/.changeset/node-sdk-collection-values.md new file mode 100644 index 000000000..bcb78e94a --- /dev/null +++ b/.changeset/node-sdk-collection-values.md @@ -0,0 +1,18 @@ +--- +"@reflag/node-sdk": major +"@reflag/openfeature-node-provider": patch +--- + +Adopt evaluator v2 and the complete `GET /flags` collection protocol in the Node SDK. + +- `getFlag(context, key)` and bound `getFlag(key)` are direct Boolean reads with an implicit `false` default. Positional defaults infer the value type, widening literals, without generated declarations. Non-null defaults require the same top-level JSON category; missing/mismatched values use the default without authored-selection metadata. An explicit `null` default accepts arbitrary JSON, including authored null. Nested payload schemas are not validated. There is no flag wrapper, `.isEnabled`, `.config`, or flag `.track()` method. Batch `getFlags()` keeps raw JSON/`undefined` values. Ordinary client `track()` APIs remain separate. +- Singular remote reads share the default contract. Root `getFlagRemote(key, defaultValue, userId?, companyId?, context?)` puts the default second, moving old positional IDs one place right; pass `undefined` for a Boolean read with IDs. Bound `getFlagRemote(key, defaultValue?)` needs no explicit IDs. +- Reads immediately register checks for requested roots. Checks carry the selected variant key as `evalResult`, `sourceVersionId`, and the snapshot `flagStateVersion`. Boolean values send the keys `"true"` / `"false"`. Equal-valued variants and dependency-only version changes retain their distinct identities. Delivery remains buffered and rate-limited. +- Prepare complete flags/segments snapshots before atomically accepting them. Evaluate references recursively without dependency-result caching. +- Persist full graphs and snapshot versions in version-2 fallback snapshots. V1 snapshots are not converted. Built-in storage uses separate versioned keys/files so V1 and V2 clients cannot overwrite one another's snapshots. Local overrides and value-only fallbacks do not fabricate authored variant selections. +- Bootstrap state contains v2 selection results and context/version metadata, without registering browser checks. Existing v1 browser bootstrap consumers require a separate migration; this is not a backwards-compatible bootstrap shape. +- Preserve sync modes, ordinary tracking, bound contexts, and post-evaluation override layering. Update the Express example to consume direct values. + +The existing OpenFeature provider and v1 React bootstrap demo remain explicitly pinned to published Node SDK v1 through an npm alias. This prevents automatic workspace dependency upgrades from silently breaking their wrapper/bootstrap contracts; migrating those consumers to multivariate values is separate work. + +This release requires the collection-capable evaluator and compatible `/flags` and variant-key check ingestion. It does not migrate legacy remote config into multivariate flag values. diff --git a/.changeset/pin-legacy-node-evaluator.md b/.changeset/pin-legacy-node-evaluator.md deleted file mode 100644 index 523bc86f6..000000000 --- a/.changeset/pin-legacy-node-evaluator.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@reflag/node-sdk": patch ---- - -Keep the existing Node SDK on evaluator 1.1.1 through the `@reflag/flag-evaluation-v1` npm alias. This preserves its legacy protocol and prevents the evaluator's v2 workspace release from automatically upgrading its dependency before SDK migration. diff --git a/packages/flag-evaluation/test/release.test.ts b/packages/flag-evaluation/test/release.test.ts index bbac83b49..360ff0023 100644 --- a/packages/flag-evaluation/test/release.test.ts +++ b/packages/flag-evaluation/test/release.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import * as legacy from "@reflag/flag-evaluation-v1"; import nodeSdk from "../../node-sdk/package.json"; +import openfeature from "../../openfeature-node-provider/package.json"; describe("v1/v2 release isolation", () => { it("resolves the legacy alias to the published package, not this workspace", () => { @@ -15,10 +16,17 @@ describe("v1/v2 release isolation", () => { expect(typeof legacy.evaluateFlagRules).toBe("function"); }); - it("keeps the unchanged Node SDK off the v2 workspace dependency", () => { - expect(nodeSdk.dependencies).not.toHaveProperty("@reflag/flag-evaluation"); - expect(nodeSdk.dependencies["@reflag/flag-evaluation-v1"]).toBe( - "npm:@reflag/flag-evaluation@1.1.1", + it("moves the v2 Node SDK onto the collection evaluator", () => { + expect(nodeSdk.dependencies["@reflag/flag-evaluation"]).toBe("workspace:*"); + expect(nodeSdk.dependencies).not.toHaveProperty( + "@reflag/flag-evaluation-v1", ); }); + + it("keeps the unchanged OpenFeature provider on published Node SDK v1", () => { + expect(openfeature.dependencies["@reflag/node-sdk-v1"]).toBe( + "npm:@reflag/node-sdk@1.8.0", + ); + expect(openfeature.dependencies).not.toHaveProperty("@reflag/node-sdk"); + }); }); diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index a06e82c0d..fc95460d2 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -1,1108 +1,221 @@ # Reflag Node.js SDK -Node.js, JavaScript/TypeScript client for [Reflag.com](https://reflag.com). +Server-side Boolean and multivariate flag values for [Reflag](https://reflag.com). -Reflag supports flag toggling, tracking flag usage, collecting feedback on features, and [remotely configuring flags](#remote-config). +> This branch implements the breaking v2 API. It requires the collection-capable evaluator, the v2 `/flags` endpoint, and compatible variant-key check ingestion. It has not been published or deployed. -## Installation - -Install using your favorite package manager: - -{% tabs %} -{% tab title="npm" %} - -```sh -npm i @reflag/node-sdk -``` - -{% endtab %} - -{% tab title="yarn" %} +## Installation and initialization ```sh yarn add @reflag/node-sdk ``` -{% endtab %} - -{% tab title="bun" %} - -```sh -bun add @reflag/node-sdk -``` - -{% endtab %} - -{% tab title="pnpm" %} - -```sh -pnpm add @reflag/node-sdk -``` - -{% endtab %} - -{% tab title="deno" %} - -```sh -deno add npm:@reflag/node-sdk -``` - -{% endtab %} -{% endtabs %} - -Other supported languages/frameworks are in the [Supported languages](https://docs.reflag.com/quickstart/supported-languages) documentation pages. - -You can also [use the HTTP API directly](https://docs.reflag.com/api/http-api) - -## Basic usage - -To get started you need to obtain your secret key from the [environment settings](https://app.reflag.com/env-current/settings/app-environments) -in Reflag. - -Reflag will load settings through the various environment variables automatically (see [Configuring](#configuring) below). +Create one client per process and initialize it before reading flags. Obtain your environment's secret key from Reflag's environment settings; complete flag definitions must remain server-side. -1. Find the Reflag secret key for your development environment under [environment settings](https://app.reflag.com/env-current/settings/app-environments) in Reflag. -2. Set `REFLAG_SECRET_KEY` in your `.env` file -3. Create a `reflag.ts` file containing the following: - -```typescript +```ts import { ReflagClient } from "@reflag/node-sdk"; -// Create a new instance of the client with the secret key. Additional options -// are available, such as supplying a logger and other custom properties. -// -// We recommend that only one global instance of `client` should be created -// to avoid multiple round-trips to our servers. -export const reflagClient = new ReflagClient(); - -// Initialize the client and begin fetching flag targeting definitions. -// You must call this method prior to any calls to `getFlags()`, -// otherwise an empty object will be returned. -reflagClient.initialize().then(() => { - console.log("Reflag initialized!"); +export const reflag = new ReflagClient({ + secretKey: process.env.REFLAG_SECRET_KEY, }); +await reflag.initialize(); ``` -Once the client is initialized, you can obtain flags along with the `isEnabled` -status to indicate whether the flag is targeted for this user/company: - -> [!IMPORTANT] -> If `user.id` is not given, the whole `user` object is ignore. Similarly, without `company.id` the `company` object is ignored. - -```typescript -// configure the client -const boundClient = reflagClient.bindClient({ - user: { - id: "john_doe", - name: "John Doe", - email: "john@acme.com", - avatar: "https://example.com/users/jdoe", - }, - company: { - id: "acme_inc", - name: "Acme, Inc.", - avatar: "https://example.com/companies/acme", - }, -}); - -// get the huddle flag using company, user and custom context to -// evaluate the targeting. -const { isEnabled, track, config } = boundClient.getFlag("huddle"); - -if (isEnabled) { - // this is your flag gated code ... - // send an event when the flag is used: - track(); - - if (config?.key === "zoom") { - // this code will run if a given remote configuration - // is set up. - } - - // CAUTION: if you plan to use the event for automated feedback surveys - // call `flush` immediately after `track`. It can optionally be awaited - // to guarantee the sent happened. - boundClient.flush(); -} -``` +## Direct values, immediate checks -You can also use the `getFlags()` method which returns a map of all flags: - -```typescript -// get the current flags (uses company, user and custom context to -// evaluate the flags). -const flags = boundClient.getFlags(); -const bothEnabled = flags.huddle?.isEnabled && flags.voiceHuddle?.isEnabled; -``` - -## High performance flag targeting - -The SDK contacts the Reflag servers when you call `initialize()` -and downloads the flags with their targeting rules. -These rules are then matched against the user/company information you provide -to `getFlags()` (or through `bindClient(..).getFlags()`). That means the -`getFlags()` call does not need to contact the Reflag servers once -`initialize()` has completed. By default, `ReflagClient` uses -`flagsSyncMode: "push"`, which keeps targeting rules up to date via live SSE -updates. You can switch `flagsSyncMode` to `polling` for periodic background -refreshes or `in-request` for request-driven refreshes instead. - -### Batch Operations - -The SDK automatically batches operations like user/company updates and flag tracking events to minimize API calls. -The batch buffer is configurable through the client options: - -```typescript -const client = new ReflagClient({ - batchOptions: { - maxSize: 100, // Maximum number of events to batch - intervalMs: 10000, // Flush interval in milliseconds (default: 10000) - }, -}); -``` - -You can manually flush the batch buffer at any time: +```ts +const context = { + user: { id: "user-1" }, + company: { id: "acme", plan: "enterprise" }, +}; -```typescript -await client.flush(); +const enabled = reflag.getFlag(context, "new-editor"); // boolean; implicit false +const model = reflag.getFlag(context, "model", "quick"); // string +const limits = reflag.getFlag(context, "limits", { maxItems: 10 }); // { maxItems: number } ``` -> [!TIP] -> It's recommended to call `flush()` before your application shuts down to ensure all events are sent. - -### Rate Limiting - -The SDK includes automatic rate limiting for flag events to prevent overwhelming the API. -Rate limiting is applied per unique combination of flag key and evaluation context. -This behavior is built in and does not currently require configuration. - -### Flag definitions +**`getFlag()` returns the value itself**, not a flag object. There is no `.value`, `.isEnabled`, `.config`, or flag `.track()` wrapper. Without a default it is a Boolean read: unavailable or non-Boolean values return `false`, never a truthy object. With a positional default, missing values use that default. Valid `false`, `0`, empty strings, arrays, and objects are preserved rather than replaced by truthiness checks. -Flag definitions include the rules needed to determine which flags should be enabled and which config values should be applied to any given user/company. -Flag definitions are automatically fetched when calling `initialize()`. -They are then cached and refreshed in the background. -It's also possible to get the currently in use flag definitions: +Non-null defaults specify the expected JSON category: Boolean, number, string, array, or object. A category mismatch logs a warning without the payload and returns the default. This is not nested payload validation: object properties and array elements must still match your authored schema. Use an explicit `null` default for arbitrary JSON or nullable/off selections, and narrow the result before accessing object fields: -```typescript -import fs from "fs"; - -const client = new ReflagClient(); - -const flagDefs = await client.getFlagDefinitions(); -// [{ -// key: "huddle", -// description: "Live voice conversations with colleagues." -// flag: { ... } -// config: { ... } -// }] +```ts +const payload = reflag.getFlag(context, "nullable-settings", null); // FlagValue +// Authored null is preserved, as are any other valid JSON values. Missing uses null. ``` -### Fallback provider - -`flagsFallbackProvider` is a reliability feature that lets the SDK persist the latest successfully fetched raw flag definitions to fallback storage such as a local file, Redis, S3, GCS, or a custom backend. - -> [!NOTE] -> -> `fallbackFlags` is deprecated. Prefer `flagsFallbackProvider` for startup fallback and outage recovery. -> `flagsFallbackProvider` is not used in offline mode. +Defaults infer useful types without generated declarations, including widening primitive literals. Readonly defaults are accepted; results and defaults are returned as independent copies. Defaults are ordinary JSON payloads, with no reserved property names. -#### How it works +Each call immediately registers a check for the requested root, including when you do not subsequently use its return value. Dependencies traversed internally do not register extra checks. Delivery is buffered and rate-limited. A check records the selected **variant key**, never the JSON payload: -Reflag servers remain the primary source of truth. On `initialize()`, the SDK always tries to fetch a live copy of the flag definitions first, and it continues refreshing those definitions from the Reflag servers over time. - -If that initial live fetch fails, the SDK can call `flagsFallbackProvider.load()` and start with the last saved snapshot instead. This is mainly useful for cold starts in the exceedingly rare case that Reflag has an outage. - -If Reflag becomes unavailable after the SDK has already initialized successfully, the SDK keeps using the last successfully fetched definitions it already has in memory. In other words, the fallback provider is mainly what helps future processes start, not what keeps an already running process alive. - -After successfully fetching updated flag definitions, the SDK calls `flagsFallbackProvider.save()` to keep the stored snapshot up to date. - -Typical reliability flow: - -1. The SDK starts and tries to fetch live flag definitions from Reflag. -2. If that succeeds, those definitions are used immediately and the SDK continues operating normally. -3. After successfully fetching updated flag definitions, the SDK saves the latest snapshot through the fallback provider so a recent copy is available if needed later. -4. If a future process starts while Reflag is unavailable, it can load the last saved snapshot from the fallback provider and still initialize. -5. Once Reflag becomes available again, the SDK resumes using live data and refreshes the fallback snapshot. - -Most deployments run multiple SDK processes, so more than one process may save identical flag definitions to the fallback storage at roughly the same time. This is expected and generally harmless for backends like a local file, Redis, S3, or GCS because the operation is cheap. In practice, this only becomes worth thinking about once you have many thousands of SDK processes writing to the same fallback storage. - -> [!TIP] -> If you are building a web or client-side application and want the most resilient setup, combine `flagsFallbackProvider` on the server with bootstrapped flags on the client. -> -> `flagsFallbackProvider` helps new server processes start if they cannot reach Reflag during initialization. Bootstrapping helps clients render from server-provided flags instead of depending on an initial client-side fetch from the Reflag servers. -> -> This applies to React (`getFlagsForBootstrap()` + `ReflagBootstrappedProvider`), React Native, the Browser SDK (`bootstrappedState`), and the Vue SDK (bootstrapped flags via the provider). - -#### Built-in providers - -You can access the built-in providers through the `fallbackProviders` namespace: - -- `fallbackProviders.static(...)` -- `fallbackProviders.file(...)` -- `fallbackProviders.redis(...)` -- `fallbackProviders.s3(...)` -- `fallbackProviders.gcs(...)` - -##### Static provider - -If you just want a fixed fallback copy of simple enabled/disabled flags, you can provide a static map: - -```typescript -import { ReflagClient, fallbackProviders } from "@reflag/node-sdk"; - -const client = new ReflagClient({ - secretKey: process.env.REFLAG_SECRET_KEY, - flagsFallbackProvider: fallbackProviders.static({ - flags: { - huddle: true, - "smart-summaries": false, - }, - }), -}); - -await client.initialize(); +```ts +{ + type: "feature-flag-event", + action: "check", + key: "model", + evalResult: "quality", + sourceVersionId: "version-id", + flagStateVersion: 42, + evalContext: { company: { id: "acme", plan: "enterprise" } }, +} ``` -##### File provider +Boolean selections send `"true"` / `"false"` while returning Boolean values. Caller defaults, including category-mismatch defaults, do not claim an authored selection: their checks omit `evalResult` and `sourceVersionId`. Check deduplication includes normalized context, selection identity, root version, and snapshot version. Equal-valued variants are not interchangeable selections. Batch `getFlags()` remains a raw value record, without per-root caller defaults; unavailable batch values may be `undefined`. -```typescript -import { ReflagClient, fallbackProviders } from "@reflag/node-sdk"; - -const client = new ReflagClient({ - secretKey: process.env.REFLAG_SECRET_KEY, - flagsFallbackProvider: fallbackProviders.file({ - directory: ".reflag", - }), -}); +```ts +const values = reflag.getFlags(context); // value record; checks each returned root +const bound = reflag.bindClient(context); +const value = bound.getFlag("model", "quick"); +const allValues = bound.getFlags(); -await client.initialize(); +// Ordinary usage tracking is separate from flag checks. +await bound.track("Document created", { attributes: { source: "editor" } }); +await reflag.flush(); ``` -The file provider stores one snapshot file per environment in the configured -`directory`, using the filename -`flags-fallback-.json`. +`enableTracking: false` disables checks, context synchronization, and bound-client usage tracking. Explicit calls to the root client's `track()`, `updateUser()`, and `updateCompany()` remain separate operations. -##### Redis provider +## Evaluation and synchronization -The built-in Redis provider creates a Redis client automatically when omitted and uses `REDIS_URL` from the environment. It stores snapshots under the configured `keyPrefix` and appends the first 16 characters of the secret key hash to that prefix. +The SDK fetches a complete `{ flags, segments }` graph from secret-key-only `GET /flags`. Definitions are validated and prepared before the flags, segments, and snapshot version replace the current cache together. Malformed graphs and stale responses cannot replace last-known-good state. -Without a `keyPrefix` set, it will default to the key `reflag:flags-fallback:`. When you provide a custom `keyPrefix`, any trailing `:` is trimmed before the hash suffix is appended. +`getFlag()` evaluates only its root and dependencies. `getFlags()` evaluates the collection. References are reevaluated on every traversal: there is **no context/dependency-result caching**. Only immutable definition preparation is retained. Returned objects are independent copies, so changing a value cannot mutate the saved definitions. -```typescript -import { ReflagClient, fallbackProviders } from "@reflag/node-sdk"; +Synchronization modes: -const client = new ReflagClient({ - secretKey: process.env.REFLAG_SECRET_KEY, - flagsFallbackProvider: fallbackProviders.redis(), -}); - -await client.initialize(); -``` - -##### S3 provider - -The built-in S3 provider works out of the box using the AWS SDK's default credential chain and region resolution. It stores the snapshot object under the configured `keyPrefix` and uses the filename `flags-fallback-.json`. +- `push` (default): authenticated SSE updates with reconnect handling. +- `polling`: periodic background refreshes. +- `in-request`: request-driven refreshes, suitable for constrained runtimes. -Without a `keyPrefix` set, it will default to the object key `reflag/flags-fallback/flags-fallback-.json`. When you provide a custom `keyPrefix`, any trailing `/` is trimmed before the filename is appended. - -```typescript -import { ReflagClient, fallbackProviders } from "@reflag/node-sdk"; - -const client = new ReflagClient({ - secretKey: process.env.REFLAG_SECRET_KEY, - flagsFallbackProvider: fallbackProviders.s3({ - bucket: "reflag-fallback-bucket", - }), -}); - -await client.initialize(); +```ts +await reflag.refreshFlags(); +await reflag.refreshFlags(42); // request at least this snapshot version +const definitions = reflag.getFlagDefinitions(); // { flags, segments }, not an array ``` -##### GCS provider - -The built-in GCS provider works out of the box using Google Cloud's default application credentials. It stores the snapshot object under the configured `keyPrefix` and uses the filename `flags-fallback-.json`. +Refreshes are throttled. In-request mode can queue a refresh during the throttle window until a later access; a refresh call is not an unconditional synchronization barrier. -Without a `keyPrefix` set, it will default to the object key `reflag/flags-fallback/flags-fallback-.json`. When you provide a custom `keyPrefix`, any trailing `/` is trimmed before the filename is appended. +## Fallback providers -```typescript +```ts import { ReflagClient, fallbackProviders } from "@reflag/node-sdk"; const client = new ReflagClient({ secretKey: process.env.REFLAG_SECRET_KEY, - flagsFallbackProvider: fallbackProviders.gcs({ - bucket: "reflag-fallback-bucket", - }), + flagsFallbackProvider: fallbackProviders.file({ directory: ".reflag" }), }); - -await client.initialize(); -``` - -#### Testing fallback startup locally - -To test fallback startup in your own app, first run it once with a working Reflag connection so a snapshot is saved. Then restart it with the same secret key and fallback provider configuration, but set `apiBaseUrl` (or set the `REFLAG_API_BASE_URL` environment variable) to `http://127.0.0.1:65535`. That forces the live fetch to fail and lets you verify that the SDK initializes from the saved snapshot instead. - -#### Writing a custom provider - -If you just store definitions in your database or similar, a custom provider can be very small: - -```typescript -import type { - FlagsFallbackProvider, - FlagsFallbackSnapshot, -} from "@reflag/node-sdk"; - -export const customFallbackProvider: FlagsFallbackProvider = { - async load(context) { - // load snapshot from database - // optionally, look up the snapshot using the context.secretKeyHash as a key - return snapshot; - }, - - async save(context, snapshot) { - const serialized = JSON.stringify(snapshot); - // write serialized snapshot to database, optionally using context.secretKeyHash as a key - }, -}; -``` - -## Bootstrapping client-side applications - -The `getFlagsForBootstrap()` method is useful whenever you need to pass flag data to another runtime or serialize it without wrapper functions. Server-side rendering (SSR) is a common example, but it is also useful for other bootstrapping and hydration flows. - -```typescript -const client = new ReflagClient(); await client.initialize(); - -// Get bootstrapped state with full context -const bootstrappedState = client.getFlagsForBootstrap({ - user: { - id: "user123", - name: "John Doe", - email: "john@acme.com", - }, - company: { - id: "company456", - name: "Acme Inc", - plan: "enterprise", - }, - other: { - source: "web", - platform: "desktop", - }, -}); - -// Pass this data to your client-side application -console.log(bootstrappedState); -// { -// context: { ... }, -// flags: { -// "huddle": { -// "key": "huddle", -// "isEnabled": true, -// "config": { -// "key": "enhanced", -// "payload": { "maxParticipants": 50, "videoQuality": "hd" }, -// } -// } -// }, -// flagStateVersion: 42 -// } ``` -You can also use a bound client for simpler API: - -```typescript -const boundClient = client.bindClient({ - user: { id: "user123", name: "John Doe", email: "john@acme.com" }, - company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, -}); - -const bootstrappedState = boundClient.getFlagsForBootstrap(); -``` +Live state is primary. When the initial live fetch fails, the SDK can load a saved complete collection. Later outages retain the in-memory graph rather than reloading older fallback state. Accepted live snapshots are saved in order; `flush()` also waits for pending saves. -### Key differences from `getFlags()` - -- **Raw data**: Returns plain objects without `track()` functions, making them JSON serializable -- **Context included**: Returns both the evaluated flags and the context used for evaluation -- **Version included**: Returns `flagStateVersion` when known so bootstrapped clients can avoid redundant live-update refreshes -- **Bootstrapping focus**: Designed specifically for passing data to client-side applications - -## Edge-runtimes like Cloudflare Workers - -To use the Reflag NodeSDK with Cloudflare workers, set the `node_compat` flag [in your wrangler file](https://developers.cloudflare.com/workers/runtime-apis/nodejs/#get-started). - -Instead of using `ReflagClient`, use `EdgeClient` and make sure you call `ctx.waitUntil(reflag.flush());` before returning from your worker function. - -```typescript -import { EdgeClient } from "@reflag/node-sdk"; - -// set the REFLAG_SECRET_KEY environment variable or pass the secret key in the constructor -const reflag = new EdgeClient(); - -export default { - async fetch(request, _env, ctx): Promise { - // initialize the client and wait for it to complete - // if the client was initialized on a previous invocation, this is a no-op. - await reflag.initialize(); - const flags = reflag.getFlags({ - user: { id: "userId" }, - company: { id: "companyId" }, - }); - - // ensure all events are flushed and any requests to refresh the flag cache - // have completed after the response is sent - ctx.waitUntil(reflag.flush()); - - return new Response( - `Flags for user ${userId} and company ${companyId}: ${JSON.stringify(flags, null, 2)}`, - ); - }, -}; -``` - -See [examples/cloudflare-worker](https://github.com/reflagcom/javascript/tree/main/packages/node-sdk/examples/cloudflare-worker/src/index.ts) for a deployable example. - -Reflag maintains a cached set of flag definitions in the memory of your worker which it uses to decide which flags to turn on for which users/companies. - -The SDK caches flag definitions in memory for fast performance. The first request to a new worker instance fetches definitions from Reflag's servers, while subsequent requests use the cache. When the cache expires, it's updated in the background. `ctx.waitUntil(reflag.flush())` ensures completion of the background work, so response times are not affected. This background work may increase wall-clock time for your worker, but it will not measurably increase billable CPU time on platforms like Cloudflare. - -`EdgeClient` uses `flagsSyncMode: "in-request"`. Refresh fetch starts are throttled to at most once per second, and Cloudflare Workers cannot rely on delayed timer callbacks to run follow-up refreshes later. That means `refreshFlags()` calls made during the throttle window only mark a refresh as pending, so the call itself may resolve before the fetch runs. The queued refresh runs on the next request/access or `refreshFlags()` call after the throttle window expires. - -## Error Handling - -The SDK is designed to fail gracefully and never throw exceptions to the caller. Instead, it logs errors and provides -fallback behavior: - -1. **Flag Evaluation Failures**: - - ```typescript - const { isEnabled } = client.getFlag("my-flag"); - // If flag evaluation fails, isEnabled will be false - ``` - -2. **Network Errors**: - - ```typescript - // Network errors during tracking are logged but don't affect your application - const { isEnabled, track } = client.getFlag("my-flag"); - if (isEnabled) { - // network errors are caught internally and logged and never bubbled up to your application - // no need to try/catch around "track" or "getFlag" - await track(); - } - ``` - -3. **Missing Context**: - - ```typescript - // The SDK tracks missing context fields but continues operation - const flags = client.getFlags({ - user: { id: "user123" }, - // Missing company context will be logged but won't cause errors - }); - ``` - -4. **Offline Mode**: - - ```typescript - // In offline mode, the SDK uses explicit local configuration only. - // It does not fetch from Reflag or use flagsFallbackProvider. - const client = new ReflagClient({ - offline: true, - flagOverrides: () => ({ - "my-flag": true, - }), - }); - ``` - -The SDK logs all errors with appropriate severity levels. You can customize logging by providing your own logger: - -```typescript -const client = new ReflagClient({ - logger: { - debug: (msg) => console.debug(msg), - info: (msg) => console.info(msg), - warn: (msg) => console.warn(msg), - error: (msg, error) => { - console.error(msg, error); - // Send to your error tracking service - errorTracker.capture(error); - }, - }, -}); -``` - -## Remote config - -Remote config is a dynamic and flexible approach to configuring flag behavior outside of your app – without needing to re-deploy it. - -Similar to `isEnabled`, each flag has a `config` property. This configuration is managed from within Reflag. -It is managed similar to the way access to flags is managed, but instead of the binary `isEnabled` you can have -multiple configuration values which are given to different user/companies. +Available providers: `static`, `file`, `redis`, `s3`, and `gcs`, plus custom `FlagsFallbackProvider` implementations. A v2 snapshot contains: ```ts -const flags = reflagClient.getFlags(); -// { -// huddle: { -// isEnabled: true, -// targetingVersion: 42, -// config: { -// key: "gpt-3.5", -// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } -// } -// } -// } -``` - -`key` is mandatory for a config, but if a flag has no config or no config value was matched against the context, the `key` will be `undefined`. Make sure to check against this case when trying to use the configuration in your application. `payload` is an optional JSON value for arbitrary configuration needs. - -Just as `isEnabled`, accessing `config` on the object returned by `getFlags` does not automatically -generate a `check` event, contrary to the `config` property on the object returned by `getFlag`. - -## Configuring - -The Reflag `Node.js` SDK can be configured through environment variables, -a configuration file on disk or by passing options to the `ReflagClient` -constructor. By default, the SDK searches for `reflag.config.json` in the -current working directory. - -| Option | Type | Description | Env Var | -| ----------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `secretKey` | string | The secret key used for authentication with Reflag's servers. | REFLAG_SECRET_KEY | -| `logLevel` | string | The log level for the SDK (e.g., `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`). Default: `INFO` | REFLAG_LOG_LEVEL | -| `offline` | boolean | Operate in offline mode. Default: `false`, except in tests it will default to `true` based off of the `TEST` env. var. In offline mode the SDK does not fetch from Reflag and does not use `flagsFallbackProvider`. | REFLAG_OFFLINE | -| `apiBaseUrl` | string | The base API URL for the Reflag servers. | REFLAG_API_BASE_URL | -| `flagOverrides` | Record | An object specifying flag overrides for testing or local development. See [examples/express/app.test.ts](https://github.com/reflagcom/javascript/tree/main/packages/node-sdk/examples/express/app.test.ts) for how to use `flagOverrides` in tests. | REFLAG_FLAGS_ENABLED, REFLAG_FLAGS_DISABLED | -| `flagsFallbackProvider` | `FlagsFallbackProvider` | Optional provider used to load and save raw flag definitions for fallback startup when the initial live fetch fails. Available only through the constructor. Ignored in offline mode. | - | -| `flagsSyncMode` | `"polling" \| "in-request" \| "push"` | Flag-definition sync mode. `push` subscribes to live updates, `polling` uses periodic background refresh, and `in-request` refreshes stale flags during request handling. Default: `"push"`. | - | -| `flagsPushUrl` | string | Push endpoint used when `flagsSyncMode: "push"`. The SDK opens it as an authenticated streaming GET. Default: `https://front.reflag.com/sse/server`. | - | -| `configFile` | string | Load this config file from disk. Default: `reflag.config.json` | REFLAG_CONFIG_FILE | - -> [!NOTE] -> -> `REFLAG_FLAGS_ENABLED` and `REFLAG_FLAGS_DISABLED` are comma separated lists of flags which will be enabled or disabled respectively. - -`reflag.config.json` example: - -```json { - "secretKey": "...", - "logLevel": "warn", - "offline": true, - "apiBaseUrl": "https://proxy.slick-demo.com", - "flagOverrides": { - "huddles": true, - "voiceChat": { "isEnabled": false }, - "aiAssist": { - "isEnabled": true, - "config": { - "key": "gpt-4.0", - "payload": { - "maxTokens": 50000 - } - } - } - } + version: 2, + savedAt: "2026-01-01T00:00:00.000Z", + flagStateVersion: 42, + flags: [/* compiled flags */], + segments: [/* compiled segment predicates */], } ``` -When using a `reflag.config.json` for local development, make sure you add it to your -`.gitignore` file. You can also set these options directly in the `ReflagClient` -constructor. The precedence for configuration options is as follows, listed in the -order of importance: - -1. Options passed along to the constructor directly, -2. Environment variable, -3. The config file. - -## Type safe flags - -To get type checked flags, install the Reflag CLI: - -```sh -npm i --save-dev @reflag/cli -``` - -then generate the types: - -```sh -npx reflag flags types -``` - -This will generate a `reflag.d.ts` containing all your flags. -Any flag look ups will now be checked against the flags that exist in Reflag. - -Here's an example of a failed type check: - -```typescript -import { ReflagClient } from "@reflag/node-sdk"; - -export const reflagClient = new ReflagClient(); - -reflagClient.initialize().then(() => { - console.log("Reflag initialized!"); - - // TypeScript will catch this error: "invalid-flag" doesn't exist - reflagClient.getFlag("invalid-flag"); - - const { - isEnabled, - config: { payload }, - } = reflagClient.getFlag("create-todos"); -}); -``` - -![Type check failed](docs/type-check-failed.png "Type check failed") - -This is an example of a failed config payload check: - -```typescript -reflagClient.initialize().then(() => { - // TypeScript will catch this error as well: "minLength" is not part of the payload. - if (isEnabled && todo.length > config.payload.minLength) { - // ... - } -}); -``` - -![Config type check failed](docs/type-check-payload-failed.png "Remote config type check failed") - -## Testing with flag overrides +V1 flag-only snapshots are incompatible and are not automatically expanded or converted. Built-in files/S3/GCS objects use `flags-fallback-v2-.json`; Redis uses `:v2:`. This keeps v1 and v2 processes from overwriting each other's fallback state. -When writing tests that cover code with flags, you can toggle flags on/off programmatically to test different behavior. For tests, you will often want to run the client in offline mode: +A static fallback can contain any JSON values: -`reflag.ts`: - -```typescript -import { ReflagClient } from "@reflag/node-sdk"; - -export const reflag = new ReflagClient({ - offline: true, +```ts +fallbackProviders.static({ + flags: { "new-editor": false, model: "quick", limits: null }, }); ``` -There are a few ways to programmatically manipulate the overrides which are appropriate when testing: +Static values, `fallbackFlags`, and overrides do not identify authored variants. Their checks omit `evalResult` and `sourceVersionId`; they must not claim exposure to an authored selection. Restored real collection snapshots retain their real selection/version metadata. Providers are not loaded in offline mode. -### Base overrides +## Overrides and tests -You can set base overrides for a test run by passing `flagOverrides` in the constructor, replacing them later with `setFlagOverrides()` and clearing them with `clearFlagOverrides()`: - -```typescript -// pass directly in the constructor +```ts const client = new ReflagClient({ offline: true, - flagOverrides: { myFlag: true }, + flagOverrides: { "new-editor": true, limits: { maxItems: 5 } }, }); - -// or replace the base overrides at a later time -client.setFlagOverrides({ myFlag: false }); - -// clear only the base overrides -client.clearFlagOverrides(); -``` - -`app.test.ts`: - -```typescript -import { reflag } from "./reflag.ts"; - -beforeAll(async () => await reflag.initialize()); -afterEach(() => { - reflag.clearFlagOverrides(); -}); - -describe("API Tests", () => { - it("should return 200 for the root endpoint", async () => { - reflag.setFlagOverrides({ - "show-todo": true, - }); - - const response = await request(app).get("/"); - expect(response.status).toBe(200); - expect(response.body).toEqual({ message: "Ready to manage some TODOs!" }); - }); -}); -``` - -### Layering overrides - -`pushFlagOverrides()` serves a different purpose: it adds a temporary layer on top of the base overrides and returns a remove function that removes only that layer. This is useful for nested tests: - -```typescript -export const flag = function (name: string, enabled: boolean): void { - let remove: (() => void) | undefined; - - beforeEach(function () { - remove = reflagClient.pushFlagOverrides({ [name]: enabled }); - }); - - afterEach(function () { - remove?.(); - remove = undefined; - }); -}; - -describe("foo", () => { - describe("with new search ranking enabled", () => { - flag("search-ranking-v2", true); - - describe("with summaries enabled", () => { - flag("smart-summaries", true); - - // ... - }); - }); -}); -``` - -The precedence is: - -1. Base overrides from the constructor or `setFlagOverrides()` -2. Temporary layers added by `pushFlagOverrides()` - -If the same flag is set in both places, the pushed override wins until its remove function is called. - -### Context dependent overrides - -`setFlagOverrides()` and `pushFlagOverrides()` also accept a function if the override depends on the evaluation context: - -```typescript +await client.initialize(); +client.setFlagOverrides({ model: "quick" }); const remove = client.pushFlagOverrides((context) => ({ - "smart-summaries": context.user?.id === "qa-user", + model: context.company?.id === "qa" ? "quality" : "quick", })); - -// ... - remove(); +client.clearFlagOverrides(); ``` -### Additional ways to provide flag overrides - -You also have these additional ways to provide overrides, which can be helpful when testing out locally: - -1. Through environment variables: - -```bash -REFLAG_FLAGS_ENABLED=flag1,flag2 -REFLAG_FLAGS_DISABLED=flag3,flag4 -``` - -1. Through `reflag.config.json`: - -```json -{ - "flagOverrides": { - "delete-todos": { - "isEnabled": true, - "config": { - "key": "dev-config", - "payload": { - "requireConfirmation": true, - "maxDeletionsPerDay": 5 - } - } - } - } -} -``` - -## Remote Flag Evaluation - -In addition to local flag evaluation, Reflag supports remote evaluation using stored context. This is useful when you want to evaluate flags using user/company attributes that were previously sent to Reflag: - -```typescript -// First, update user and company attributes -await client.updateUser("user123", { - attributes: { - role: "admin", - subscription: "premium", - }, -}); - -await client.updateCompany("company456", { - attributes: { - tier: "enterprise", - employees: 1000, - }, -}); - -// Later, evaluate flags remotely using stored context. -// Note: the argument order is (userId, companyId, additionalContext?) -const userId = "user123"; -const companyId = "company456"; - -const flags = await client.getFlagsRemote(userId, companyId); -// Or evaluate a single flag -const flag = await client.getFlagRemote("create-todos", userId, companyId); - -// You can also provide additional context -const flagsWithContext = await client.getFlagsRemote(userId, companyId, { - other: { - location: "US", - platform: "mobile", - }, -}); -``` - -Remote evaluation is particularly useful when: - -- You want to use the most up-to-date user/company attributes stored in Reflag -- You don't want to pass all context attributes with every evaluation -- You need to ensure consistent flag evaluation across different services - -## Using with Express - -A popular way to integrate the Reflag Node.js SDK is through an express middleware. - -```typescript -import reflag from "./reflag"; -import express from "express"; -import { BoundReflagClient } from "@reflag/node-sdk"; - -// Augment the Express types to include a `boundReflagClient` property on the -// `res.locals` object. -// This will allow us to access the ReflagClient instance in our route handlers -// without having to pass it around manually -declare global { - namespace Express { - interface Locals { - boundReflagClient: BoundReflagClient; - } - } -} - -// Add express middleware -app.use((req, res, next) => { - // Extract the user and company IDs from the request - // You'll want to use a proper authentication and identification - // mechanism in a real-world application - const user = { - id: req.user?.id, - name: req.user?.name, - email: req.user?.email - } - - const company = { - id: req.user?.companyId, - name: req.user?.companyName - } +Overrides are direct JSON values. Pushed layers win over base overrides and can be removed independently. They apply **after evaluation**, so overriding a dependency's returned value does not rewrite dependent flag evaluations. Offline mode does not fetch, load a provider, or send tracking events. - // Create a new BoundReflagClient instance by calling the `bindClient` - // method on a `ReflagClient` instance - // This will create a new instance that is bound to the user/company given. - const boundReflagClient = reflag.bindClient({ user, company }); +## Typed flags - // Store the BoundReflagClient instance in the `res.locals` object so we - // can access it in our route handlers - res.locals.boundReflagClient = boundReflagClient; - next(); -}); +Generated declarations are optional. Singular reads infer their return type from the default, or return Boolean when no default is supplied. Optionally augment `Flags` to constrain keys and type batch records: -// Now use res.locals.boundReflagClient in your handlers -app.get("/todos", async (_req, res) => { - const { track, isEnabled } = res.locals.boundReflagClient.getFlag("show-todos"); - - if (!isEnabled) { - res.status(403).send({"error": "flag inaccessible"}) - return +```ts +declare module "@reflag/node-sdk" { + interface Flags { + "new-editor": boolean; + model: "quick" | "quality"; + limits: { maxItems: number } | null; } - - ... -} -``` - -See [examples/express/app.ts](https://github.com/reflagcom/javascript/tree/main/packages/node-sdk/example/express/app.ts) for a full example. - -## Remote flag evaluation with stored context - -If you don't want to provide context each time when evaluating flags but -rather you would like to utilize the attributes you sent to Reflag previously -(by calling `updateCompany` and `updateUser`) you can do so by calling `getFlagsRemote` -(or `getFlagRemote` for a specific flag) with just `userId` and `companyId` -in that order. -These methods will call Reflag's servers and flags will be evaluated remotely -using the stored attributes. - -```typescript -// Update user and company attributes -client.updateUser("john_doe", { - attributes: { - name: "John O.", - role: "admin", - }, -}); - -client.updateCompany("acme_inc", { - attributes: { - name: "Acme, Inc", - tier: "premium" - }, -}); -... - -// This will evaluate flags using the stored attributes for the user/company pair -const flags = await client.getFlagsRemote("john_doe", "acme_inc"); -``` - -> [!IMPORTANT] -> User and company attribute updates are processed asynchronously, so there might -> be a small delay between when attributes are updated and when they are available -> for evaluation. - -## Opting out of tracking - -There are use cases in which you not want to be sending `user`, `company` and -`track` events to [Reflag.com](https://reflag.com). These are usually cases where you could be impersonating -another user in the system and do not want to interfere with the data being -collected by Reflag. - -To disable tracking, bind the client using `bindClient()` as follows: - -```typescript -// binds the client to a given user and company and set `enableTracking` to `false`. -const boundClient = client.bindClient({ user, company, enableTracking: false }); - -boundClient.track("some event"); // this will not actually send the event to Reflag. - -// the following code will not update the `user` nor `company` in Reflag and will -// not send `track` events either. -const { isEnabled, track } = boundClient.getFlag("user-menu"); -if (isEnabled) { - track(); } -``` - -Another way way to disable tracking without employing a bound client is to call `getFlag()` -or `getFlags()` by supplying `enableTracking: false` in the arguments passed to -these functions. -> [!IMPORTANT] -> Note, however, that calling `track()`, `updateCompany()` or `updateUser()` in the `ReflagClient` -> will still send tracking data. As such, it is always recommended to use `bindClient()` -> when using this SDK. - -## Flushing - -ReflagClient employs a batching technique to minimize the number of calls that are sent to -Reflag's servers. - -By default, the SDK automatically subscribes to process exit signals and attempts to flush -any pending events. This behavior is controlled by the `flushOnExit` option in the client configuration: - -```typescript -const client = new ReflagClient({ - batchOptions: { - flushOnExit: false, // disable automatic flushing on exit - }, -}); -``` - -## Tracking custom events and setting custom attributes - -Tracking allows events and updating user/company attributes in Reflag. -For example, if a customer changes their plan, you'll want Reflag to know about it, -in order to continue to provide up-do-date targeting information in the Reflag interface. - -The following example shows how to register a new user, associate it with a company -and finally update the plan they are on. - -```typescript -// registers the user with Reflag using the provided unique ID, and -// providing a set of custom attributes (can be anything) -client.updateUser("user_id", { - attributes: { longTimeUser: true, payingCustomer: false }, -}); -client.updateCompany("company_id", { userId: "user_id" }); - -// the user started a voice huddle -client.track("user_id", "huddle", { attributes: { voice: true } }); +const model = reflag.getFlag(context, "model", "quick"); // string +const models = reflag.getFlags(context); // models.model: "quick" | "quality" | undefined ``` -It's also possible to achieve the same through a bound client in the following manner: +Augmentation does not change the bare-key Boolean contract or validate JSON payloads. The legacy CLI-generated config-wrapper types are not a v2 value schema. Update declarations when migrating. Both destructuring `.isEnabled` and accessing it through an assigned singular value fail type checking, including without generated declarations. See the Express example for a typed direct-value integration. -```typescript -const boundClient = client.bindClient({ - user: { id: "user_id", longTimeUser: true, payingCustomer: false }, - company: { id: "company_id" }, -}); +## Bootstrap state -boundClient.track("huddle", { attributes: { voice: true } }); +```ts +const bootstrap = reflag.getFlagsForBootstrap(context); +// { protocolVersion: 2, context, flags: { model: { resolved, value, +// variantKey, sourceVersionId, matchedRuleId, ruleResults, errors, ... } }, +// flagStateVersion } ``` -Some attributes are used by Reflag to improve the UI, and are recommended -to provide for easier navigation: +Bootstrap uses batch evaluation and preserves selection metadata and raw context. It does **not** register browser checks. Local overrides/value-only fallbacks carry their values without fabricated authored-selection metadata. -- `name` -- display name for `user`/`company`, -- `email` -- the email of the user, -- `avatar` -- the URL for `user`/`company` avatar image. +**This is a v2 bootstrap contract, not the existing v1 Browser/React/Vue bootstrap shape.** Those consumers require a coordinated migration before accepting this state. Likewise, the current OpenFeature provider deliberately remains on published Node SDK v1 until its separate migration. -Attributes cannot be nested (multiple levels) and must be either strings, -integers or booleans. +## Remote evaluation -## Managing `Last seen` - -By default `updateUser`/`updateCompany` calls automatically update the given -user/company `Last seen` property on Reflag servers. - -You can control if `Last seen` should be updated when the events are sent by setting -`meta.active = false`. This is often useful if you -have a background job that goes through a set of companies just to update their -attributes but not their activity. - -Example: - -```typescript -client.updateUser("john_doe", { - attributes: { name: "John O." }, - meta: { active: true }, -}); - -client.updateCompany("acme_inc", { - attributes: { name: "Acme, Inc" }, - meta: { active: false }, +```ts +const enabled = await reflag.getFlagRemote( + "new-editor", + undefined, + "user-1", + "acme", +); +const model = await reflag.getFlagRemote("model", "quick", "user-1", "acme"); +const boundModel = await reflag + .bindClient(context) + .getFlagRemote("model", "quick"); +const values = await reflag.getFlagsRemote("user-1", "acme", { + other: { region: "eu" }, }); ``` -`bindClient()` updates attributes on the Reflag servers but does not automatically -update `Last seen`. - -## Zero PII +These methods use `/flags/evaluated` and return direct values. Singular remote reads use the same defaults, category checks, and JSON validation as local reads. The root `getFlagRemote(key, defaultValue, userId?, companyId?, context?)` places the default second; use `undefined` for an implicit Boolean read when passing explicit IDs. This moves the old positional IDs one place to the right. `getFlagsRemote()` keeps its existing argument order and returns a raw value record. -The Reflag SDK doesn't collect any metadata and HTTP IP addresses are _not_ being -stored. For tracking individual users, we recommend using something like database -ID as userId, as it's unique and doesn't include any PII (personal identifiable -information). If, however, you're using e.g. email address as userId, but prefer -not to send any PII to Reflag, you can hash the sensitive data before sending -it to Reflag: +Each requested singular root registers a check, including a default after a failed/missing remote evaluation, unless tracking is disabled. Only accepted authored selections retain the server's selection/version metadata. Explicit IDs, including numeric zero, take precedence over IDs in additional context. Stored attribute updates are asynchronous; remote evaluation need not immediately observe a just-sent update. -```typescript -import { sha256 } from 'crypto-hash'; - -client.updateUser({ userId: await sha256("john_doe"), ... }); -``` +## Edge runtimes -## Migrating from Bucket SDK +Use `EdgeClient` with Node compatibility enabled. It selects in-request synchronization. Call `ctx.waitUntil(client.flush())` before returning from a Worker so queued events and refresh work can complete. -If you have been using the Bucket SDKs previously, the following list will help you migrate to Reflag SDK: +## Configuration -- `Bucket*` classes, and types have been renamed to `Reflag*` (e.g. `BucketClient` is now `ReflagClient`) -- `Feature*` classes, and types have been renamed to `Flag*` (e.g. `Feature` is now `Flag`, `RawFeatures` is now `RawFlags`) -- When using strongly-typed flags, the new `Flags` interface replaced `Features` interface -- All methods that contained `feature` in the name have been renamed to use the `flag` terminology (e.g. `getFeature` is `getFlag`) -- All environment variables that were prefixed with `BUCKET_` are now prefixed with `REFLAG_` -- The `BUCKET_HOST` environment variable and `host` option have been removed from `ReflagClient` constructor, use `REFLAG_API_BASE_URL` instead -- The `BUCKET_FEATURES_ENABLED` and `BUCKET_FEATURES_DISABLED` have been renamed to `REFLAG_FLAGS_ENABLED` and `REFLAG_FLAGS_DISABLED` -- The default configuration file has been renamed from `bucketConfig.json` to `reflag.config.json` -- The `fallbackFeatures` property in client constructor and configuration files has been renamed to `fallbackFlags` -- `featureKey` has been renamed to `flagKey` in all methods that accepts that argument -- The SDKs will not emit `evaluate` and `evaluate-config` events anymore +Configuration precedence is constructor options, environment variables, then `reflag.config.json`. Supported environment variables include `REFLAG_SECRET_KEY`, `REFLAG_API_BASE_URL`, `REFLAG_LOG_LEVEL`, `REFLAG_OFFLINE`, `REFLAG_CONFIG_FILE`, and Boolean override lists `REFLAG_FLAGS_ENABLED` / `REFLAG_FLAGS_DISABLED`. -## Typescript +Constructor options also include a custom `logger` / `httpClient`, `flagsSyncMode`, `flagsPushUrl`, `fetchTimeoutMs`, `flagsFetchRetries`, `flagsFallbackProvider`, direct-value `flagOverrides`, and `batchOptions` (`maxSize`, `intervalMs`, `flushOnExit`). `emitEvaluationEvents` is deprecated and has no effect. -Types are bundled together with the library and exposed automatically when importing -through a package manager. +Invalid options/context or non-JSON defaults can throw. Network or definition-refresh failures are logged and retain usable state. Singular reads use their caller default (implicit `false`) without fabricating an authored false selection; batch reads retain raw values/`undefined`. Call `await client.flush()` before shutting down, then `client.destroy()` to stop background work. ## License -> MIT License -> Copyright (c) 2025 Bucket ApS +MIT License. Copyright (c) 2025 Bucket ApS. diff --git a/packages/node-sdk/examples/express/app.ts b/packages/node-sdk/examples/express/app.ts index eaf438515..c938e644b 100644 --- a/packages/node-sdk/examples/express/app.ts +++ b/packages/node-sdk/examples/express/app.ts @@ -45,11 +45,9 @@ app.get("/todos", async (_req, res) => { // We use the `getFlag` method to check if the user has the "show-todos" feature enabled. // Note that "show-todos" is a feature that we defined in the `Flags` interface in the `reflag.ts` file. // and that the indexing for feature name below is type-checked at compile time. - const { isEnabled, track } = res.locals.reflagUser.getFlag("show-todos"); - - if (isEnabled) { - track(); + const showTodos = res.locals.reflagUser.getFlag("show-todos"); + if (showTodos) { // You can instead also send any custom event if you prefer, including attributes. // res.locals.reflagUser.track("Todo's viewed", { attributes: { access: "api" } }); @@ -67,20 +65,24 @@ app.post("/todos", (req, res) => { return res.status(400).json({ error: "Invalid todo" }); } - const { track, isEnabled, config } = - res.locals.reflagUser.getFlag("create-todos"); + const settings = res.locals.reflagUser.getFlag("create-todos", null); - // Check if the user has the "create-todos" feature enabled - if (isEnabled) { + // Null/missing settings disable creation. Validate the arbitrary JSON payload. + if ( + settings !== null && + typeof settings === "object" && + !Array.isArray(settings) && + typeof settings.minimumLength === "number" + ) { // Check if the todo is at least N characters long - if (todo.length < config.payload.minimumLength) { + if (todo.length < settings.minimumLength) { return res .status(400) .json({ error: "Todo must be at least 5 characters long" }); } - // Track the feature usage - track(); + // Ordinary usage events are separate from the automatically registered check. + void res.locals.reflagUser.track("Todo created"); todos.push(todo); return res.status(201).json({ todo }); @@ -98,13 +100,12 @@ app.delete("/todos/:idx", (req, res) => { return res.status(400).json({ error: "Invalid index" }); } - const { track, isEnabled } = res.locals.reflagUser.getFlag("delete-todos"); + const canDelete = res.locals.reflagUser.getFlag("delete-todos"); - if (isEnabled) { + if (canDelete) { todos.splice(idx, 1); - - track(); - res.json({}); + void res.locals.reflagUser.track("Todo deleted"); + return res.json({}); } res diff --git a/packages/node-sdk/examples/express/reflag.ts b/packages/node-sdk/examples/express/reflag.ts index f754bbe96..4043cf6de 100644 --- a/packages/node-sdk/examples/express/reflag.ts +++ b/packages/node-sdk/examples/express/reflag.ts @@ -8,11 +8,7 @@ type CreateConfigPayload = { declare module "@reflag/node-sdk" { interface Flags { "show-todos": boolean; - "create-todos": { - config: { - payload: CreateConfigPayload; - }; - }; + "create-todos": CreateConfigPayload | null; "delete-todos": boolean; "some-else": {}; } @@ -20,15 +16,7 @@ declare module "@reflag/node-sdk" { const flagOverrides = (_: Context): FlagOverrides => { return { - "create-todos": { - isEnabled: true, - config: { - key: "short", - payload: { - minimumLength: 10, - }, - }, - }, + "create-todos": { minimumLength: 10 }, }; // feature keys checked at compile time }; diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 2276b1df7..fa2ca4b96 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/node-sdk", - "version": "1.8.0", + "version": "2.0.0-next.0", "license": "MIT", "repository": { "type": "git", @@ -18,7 +18,8 @@ "dev": "vite", "start": "vite", "build": "tsc --project tsconfig.build.json", - "test": "vitest run", + "test": "yarn test:types && vitest run", + "test:types": "tsc --project tsconfig.type-tests.json && tsc --project tsconfig.type-tests-augmented.json", "test:watch": "vitest", "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml", "coverage": "yarn test --coverage", @@ -34,7 +35,7 @@ "@aws-sdk/client-s3": "^3.888.0", "@googleapis/storage": "21.2.0", "@redis/client": "^5.11.0", - "@reflag/flag-evaluation-v1": "npm:@reflag/flag-evaluation@1.1.1" + "@reflag/flag-evaluation": "workspace:*" }, "devDependencies": { "@babel/core": "~7.29.6", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index de2068173..1d4647aa5 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1,11 +1,6 @@ import fs from "fs"; -import { - EvaluationError, - EvaluationResult, - flattenJSON, - newEvaluator, -} from "@reflag/flag-evaluation-v1"; +import { flattenContext } from "@reflag/flag-evaluation"; import BatchBuffer from "./batch-buffer"; import { @@ -20,47 +15,48 @@ import { SDK_VERSION_HEADER_NAME, } from "./config"; import fetchClient, { withRetry } from "./fetch-http-client"; +import { + applyFlagDefault, + checkFlagDefault, + flagValueType, +} from "./flag-defaults"; +import { isFlagValue, prepareFlagCollection } from "./flag-definitions"; import { FlagsCache } from "./flags-cache"; import { createFlagsSyncController, - FlagsSyncController, + type FlagsSyncController, } from "./flags-refresher"; import { isFlagsFallbackSnapshot } from "./flagsFallbackProvider"; import { subscribe as triggerOnExit } from "./flusher"; import { newRateLimiter } from "./rate-limiter"; import type { BootstrappedFlags, - CachedFlagDefinition, - EvaluatedFlagsAPIResponse, - FlagAPIResponse, - FlagDefinition, - FlagOverrides, - FlagOverridesFn, - FlagsFallbackProvider, - FlagsFallbackProviderContext, - FlagsFallbackSnapshot, - FlagsSyncMode, - IdType, - RawFlag, - RawFlags, - TypedFlagKey, -} from "./types"; -import { - Attributes, ClientOptions, Context, ContextWithTracking, + EvaluatedFlagsAPIResponse, + FlagCollection, + FlagDefaultValue, FlagEvent, + FlagOverrides, + FlagOverridesFn, FlagsAPIResponse, + FlagsFallbackSnapshot, + FlagValue, + FlagValueFromDefault, HttpClient, + IdType, Logger, - TrackingMeta, + RawFlag, + RawFlags, TrackOptions, + TypedFlagKey, TypedFlags, } from "./types"; import { applyLogLevel, canonicalContextJSONStringify, + canonicalJSONStringify, decorateLogger, hashObject, hashString, @@ -71,549 +67,288 @@ import { once, } from "./utils"; -const reflagConfigDefaultFile = "reflag.config.json"; - -function evaluationErrorsRateLimitKey(errors: EvaluationError[]): string { - return errors - .map((error) => - JSON.stringify([ - error.code, - error.field, - "operator" in error ? error.operator : "", - ]), - ) - .sort() - .join("\n"); -} - -type PartialBy = Omit & Partial>; -type FlagOverrideLayer = { - id: number; - overrides: FlagOverridesFn; -}; - -function scheduleTrailingRefreshWithTimeout( - delayMs: number, - callback: () => void, -) { - const timer = setTimeout(callback, delayMs); - timer.unref?.(); - return () => { - clearTimeout(timer); - }; -} - -function normalizeFlagOverrides( - overrides: FlagOverridesFn | FlagOverrides | undefined, -): FlagOverridesFn { - if (typeof overrides === "function") { - return overrides; - } - - return () => overrides ?? {}; -} - -function composeFlagOverrides( - baseOverrides: FlagOverridesFn, - layers: FlagOverrideLayer[], -): FlagOverridesFn { - if (layers.length === 0) { - return baseOverrides; - } - - return (context) => - layers.reduce( - (acc, layer) => ({ - ...acc, - ...layer.overrides(context), - }), - baseOverrides(context), - ); -} - type BulkEvent = + | ({ type: "feature-flag-event" } & FlagEvent) | { type: "company"; companyId: IdType; userId?: IdType; - attributes?: Attributes; - context?: TrackingMeta; + attributes?: TrackOptions["attributes"]; + context?: TrackOptions["meta"]; } | { type: "user"; userId: IdType; - attributes?: Attributes; - context?: TrackingMeta; - } - | { - type: "feature-flag-event"; - action: "check" | "check-config"; - key: string; - targetingVersion?: number; - evalResult: - | boolean - | { key: string; payload: any } - | { key: undefined; payload: undefined }; - evalContext?: Record; - evalRuleResults?: boolean[]; - evalMissingFields?: string[]; + attributes?: TrackOptions["attributes"]; + context?: TrackOptions["meta"]; } | { type: "event"; event: string; - companyId?: IdType; userId: IdType; - attributes?: Attributes; - context?: TrackingMeta; + companyId?: IdType; + attributes?: TrackOptions["attributes"]; + context?: TrackOptions["meta"]; }; -function compileFlagDefinitions( - flags: FlagAPIResponse[], -): CachedFlagDefinition[] { - return flags.map((flagDef) => { - return { - ...flagDef, - enabledEvaluator: newEvaluator( - flagDef.targeting.rules.map((rule) => ({ - filter: rule.filter, - value: true, - })), - ), - configEvaluator: flagDef.config - ? newEvaluator( - flagDef.config.variants.map((variant) => ({ - filter: variant.filter, - value: { - key: variant.key, - payload: variant.payload, - }, - })), - ) - : undefined, - } satisfies CachedFlagDefinition; - }); -} - -function createFlagsFallbackSnapshot( - flags: FlagAPIResponse[], -): FlagsFallbackSnapshot { - return { - version: 1, - savedAt: new Date().toISOString(), - flags, +function normalizeOverrides( + overrides?: FlagOverrides | FlagOverridesFn, +): FlagOverridesFn { + ok( + overrides === undefined || + typeof overrides === "function" || + (isObject(overrides) && isFlagValue(overrides)), + "flagOverrides must be a JSON value map or function", + ); + if (typeof overrides !== "function") return () => overrides ?? {}; + return (context) => { + const result = overrides(context); + ok( + isObject(result) && isFlagValue(result), + "flagOverrides must return a JSON value map", + ); + return result; }; } - -function formatFlagsFallbackAge(savedAt: string): string | undefined { - const savedAtMs = Date.parse(savedAt); - if (!Number.isFinite(savedAtMs)) { - return undefined; - } - - const ageMs = Math.max(0, Date.now() - savedAtMs); - const minuteMs = 60_000; - const hourMs = 60 * minuteMs; - const dayMs = 24 * hourMs; - - if (ageMs < minuteMs) { - return "<1m"; - } - - if (ageMs < hourMs) { - return `${Math.floor(ageMs / minuteMs)}m`; - } - - if (ageMs < dayMs) { - return `${Math.floor(ageMs / hourMs)}h`; - } - - return `${Math.floor(ageMs / dayMs)}d`; +function scheduleRefresh(delayMs: number, callback: () => void) { + const timer = setTimeout(callback, delayMs); + timer.unref?.(); + return () => clearTimeout(timer); } - -function createErrorWithCause(message: string, cause: unknown): Error { - const error = new Error(message) as Error & { cause?: unknown }; - error.cause = cause; - return error; +function copyValue(value: RawFlag["value"]): RawFlag["value"] { + return value === undefined ? undefined : structuredClone(value); +} +function localResult(key: string, value: RawFlag["value"]): RawFlag { + return { flagKey: key, resolved: false, value, ruleResults: [], errors: [] }; +} +function contextOnly({ + enableTracking: _, + meta: __, + ...context +}: ContextWithTracking): Context { + return context; } -/** - * The SDK client. - * - * @remarks - * This is the main class for interacting with Reflag. - * It is used to evaluate flags, update user and company contexts, and track events. - * - * @example - * ```ts - * // set the REFLAG_SECRET_KEY environment variable or pass the secret key to the constructor - * const client = new ReflagClient(); - * - * // evaluate a flag - * const isFlagEnabled = client.getFlag("flag-key", { - * user: { id: "user-id" }, - * company: { id: "company-id" }, - * }); - * ``` - **/ +/** V2 client: flag reads return values and immediately register checks. */ export class ReflagClient { - private _config: { - apiBaseUrl: string; - refetchInterval: number; - headers: Record; - fallbackFlags?: RawFlags; - flagsFallbackProvider?: FlagsFallbackProvider; - flagsFallbackProviderContext: FlagsFallbackProviderContext; - flagOverrides: FlagOverridesFn; - offline: boolean; - configFile?: string; - flagsFetchRetries: number; - fetchTimeoutMs: number; - flagsSyncMode: FlagsSyncMode; - flagsPushUrl: string; - }; - httpClient: HttpClient; - - private flagsCache: FlagsCache; - private batchBuffer: BatchBuffer; - private rateLimiter: ReturnType; - private baseFlagOverrides: FlagOverridesFn = () => ({}); - private flagOverrideLayers: FlagOverrideLayer[] = []; - private nextFlagOverrideLayerId = 0; - - /** - * Gets the logger associated with the client. - */ public readonly logger: Logger; - - private flagsSyncController: FlagsSyncController; - private initializationFinished = false; - private canLoadFlagsFallbackProvider = true; - private _initialize = once(async () => { - const start = Date.now(); - if (!this._config.offline) { + public readonly httpClient: HttpClient; + private readonly config; + private readonly flagsCache: FlagsCache; + private readonly flagsSyncController: FlagsSyncController; + private readonly batchBuffer: BatchBuffer; + private readonly rateLimiter = newRateLimiter( + FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, + ); + private baseOverrides: FlagOverridesFn; + private overrideLayers: { overrides: FlagOverridesFn }[] = []; + private initialized = false; + private canLoadFallback = true; + private fallbackSave = Promise.resolve(); + private readonly initializeOnce = once(async () => { + if (!this.config.offline) { await this.flagsSyncController.start(); await this.flagsCache.refresh(); } + this.initialized = true; this.logger.info( - "Reflag initialized in " + - Math.round(Date.now() - start) + - "ms" + - (this._config.offline ? " (offline mode)" : ""), + `Reflag initialized${this.config.offline ? " (offline mode)" : ""}`, ); - this.initializationFinished = true; }); - /** - * Creates a new SDK client. - * See README for configuration options. - * - * @param options - The options for the client or an existing client to clone. - * @param options.secretKey - The secret key to use for the client. - * @param options.apiBaseUrl - The base URL to send requests to (optional). - * @param options.logger - The logger to use for logging (optional). - * @param options.httpClient - The HTTP client to use for sending requests (optional). - * @param options.logLevel - The log level to use for logging (optional). - * @param options.offline - Whether to run in offline mode (optional). - * @param options.fallbackFlags - The fallback flags to use if the flag is not found (optional). - * @param options.batchOptions - The options for the batch buffer (optional). - * @param options.flagOverrides - The flag overrides to use for the client (optional). - * @param options.configFile - The path to the config file (optional). - * @param options.flagsFetchRetries - Number of retries for fetching flags (optional, defaults to 3). - * @param options.fetchTimeoutMs - Timeout for fetching flags (optional, defaults to 10000ms). - * @param options.flagsSyncMode - How flag definitions are synchronized (optional, defaults to "push"). - * - * @throws An error if the options are invalid. - **/ constructor(options: ClientOptions = {}) { ok(isObject(options), "options must be an object"); - - ok( - options.host === undefined || - (typeof options.host === "string" && options.host.length > 0), - "host must be a string", - ); - ok( - options.apiBaseUrl === undefined || - (typeof options.apiBaseUrl === "string" && - options.apiBaseUrl.length > 0), - "apiBaseUrl must be a string", - ); + for (const key of [ + "host", + "apiBaseUrl", + "flagsPushUrl", + "secretKey", + ] as const) { + ok( + options[key] === undefined || + (typeof options[key] === "string" && options[key].length > 0), + `${key} must be a non-empty string`, + ); + } + for (const key of ["logger", "httpClient", "batchOptions"] as const) + ok( + options[key] === undefined || isObject(options[key]), + `${key} must be an object`, + ); + for (const key of ["flagsFetchRetries", "fetchTimeoutMs"] as const) + ok( + options[key] === undefined || + (Number.isInteger(options[key]) && options[key] >= 0), + `${key} must be a non-negative integer`, + ); ok( - options.logger === undefined || isObject(options.logger), - "logger must be an object", + options.offline === undefined || typeof options.offline === "boolean", + "offline must be a boolean", ); ok( - options.httpClient === undefined || isObject(options.httpClient), - "httpClient must be an object", + options.configFile === undefined || + typeof options.configFile === "string", + "configFile must be a string", ); ok( options.flagsFallbackProvider === undefined || (isObject(options.flagsFallbackProvider) && typeof options.flagsFallbackProvider.load === "function" && typeof options.flagsFallbackProvider.save === "function"), - "flagsFallbackProvider must be an object with load/save functions", - ); - ok( - options.fallbackFlags === undefined || - Array.isArray(options.fallbackFlags) || - isObject(options.fallbackFlags), - "fallbackFlags must be an array or object", - ); - ok( - options.batchOptions === undefined || isObject(options.batchOptions), - "batchOptions must be an object", - ); - ok( - options.configFile === undefined || - typeof options.configFile === "string", - "configFile must be a string", - ); - - ok( - options.flagsFetchRetries === undefined || - (Number.isInteger(options.flagsFetchRetries) && - options.flagsFetchRetries >= 0), - "flagsFetchRetries must be a non-negative integer", - ); - - ok( - options.fetchTimeoutMs === undefined || - (Number.isInteger(options.fetchTimeoutMs) && - options.fetchTimeoutMs >= 0), - "fetchTimeoutMs must be a non-negative integer", - ); - - ok( - options.flagsSyncMode === undefined || - options.flagsSyncMode === "polling" || - options.flagsSyncMode === "in-request" || - options.flagsSyncMode === "push", - 'flagsSyncMode must be one of "polling", "in-request", or "push"', - ); - - ok( - options.flagsPushUrl === undefined || - (typeof options.flagsPushUrl === "string" && - options.flagsPushUrl.length > 0), - "flagsPushUrl must be a non-empty string", + "flagsFallbackProvider must have load/save functions", ); - - const envConfigFile = - typeof process.env.REFLAG_CONFIG_FILE === "string" && - process.env.REFLAG_CONFIG_FILE.length > 0 - ? process.env.REFLAG_CONFIG_FILE - : undefined; - - if (!options.configFile) { - options.configFile = - envConfigFile ?? - (fs.existsSync(reflagConfigDefaultFile) - ? reflagConfigDefaultFile - : undefined); - } - - const externalConfig = loadConfig(options.configFile); - const config = mergeSkipUndefined(externalConfig, options); - + const configFile = + options.configFile ?? + process.env.REFLAG_CONFIG_FILE ?? + (fs.existsSync("reflag.config.json") ? "reflag.config.json" : undefined); + const config = mergeSkipUndefined(loadConfig(configFile), options); const offline = config.offline ?? process.env.NODE_ENV === "test"; - if (!offline) { - ok( - typeof config.secretKey === "string", - "secretKey must be a string, or set offline=true", + ok( + offline || + (typeof config.secretKey === "string" && config.secretKey.length > 22), + "a valid secretKey is required unless offline=true", + ); + this.logger = + options.logger ?? + applyLogLevel( + decorateLogger(REFLAG_LOG_PREFIX, console), + config.logLevel ?? "INFO", ); - ok(config.secretKey.length > 22, "invalid secretKey specified"); - } - - // use the supplied logger or apply the log level to the console logger - const logLevel = options.logLevel ?? config?.logLevel ?? "INFO"; - - this.logger = options.logger - ? options.logger - : applyLogLevel(decorateLogger(REFLAG_LOG_PREFIX, console), logLevel); - - const baseFlagOverrides = normalizeFlagOverrides( - typeof config.flagOverrides === "function" || - isObject(config.flagOverrides) - ? config.flagOverrides - : {}, - ); - + this.httpClient = options.httpClient ?? fetchClient; + this.baseOverrides = normalizeOverrides(config.flagOverrides); const fallbackFlags = Array.isArray(options.fallbackFlags) - ? options.fallbackFlags.reduce((acc, key) => { - acc[key as TypedFlagKey] = { - isEnabled: true, - key, - }; - return acc; - }, {} as RawFlags) - : isObject(options.fallbackFlags) - ? Object.entries(options.fallbackFlags).reduce( - (acc, [key, fallback]) => { - acc[key as TypedFlagKey] = { - isEnabled: - typeof fallback === "object" - ? fallback.isEnabled - : !!fallback, - key, - config: - typeof fallback === "object" && fallback.config - ? { - key: fallback.config.key, - payload: fallback.config.payload, - } - : undefined, - }; - return acc; - }, - {} as RawFlags, - ) - : undefined; - - this.rateLimiter = newRateLimiter(FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS); - this.httpClient = options.httpClient || fetchClient; - this.batchBuffer = new BatchBuffer({ - ...options?.batchOptions, - flushHandler: (items) => this.sendBulkEvents(items), - logger: this.logger, - }); - - const flagsSyncMode: FlagsSyncMode = + ? Object.fromEntries(options.fallbackFlags.map((key) => [key, true])) + : (options.fallbackFlags ?? {}); + ok( + isObject(fallbackFlags) && isFlagValue(fallbackFlags), + "fallbackFlags must contain JSON values", + ); + const flagsSyncMode = options.flagsSyncMode ?? (options.cacheStrategy === "in-request" ? "in-request" : options.cacheStrategy === "periodically-update" ? "polling" : "push"); - - const secretKeyHash = config.secretKey ? hashString(config.secretKey) : ""; - ok( - offline || flagsSyncMode !== "push" || secretKeyHash.length > 0, - 'flagsSyncMode="push" requires a valid secretKey', + ["polling", "push", "in-request"].includes(flagsSyncMode), + "invalid flagsSyncMode", ); - const pushUrl = new URL(options.flagsPushUrl ?? PUBSUB_SSE_URL); pushUrl.searchParams.delete("channels"); - - this._config = { + this.config = { offline, - apiBaseUrl: (config.apiBaseUrl ?? config.host) || API_BASE_URL, + fallbackFlags, + flagsFallbackProvider: options.flagsFallbackProvider, + fallbackContext: { + secretKeyHash: config.secretKey ? hashString(config.secretKey) : "", + }, + apiBaseUrl: (config.apiBaseUrl ?? config.host ?? API_BASE_URL).replace( + /\/?$/, + "/", + ), + fetchTimeoutMs: options.fetchTimeoutMs ?? API_TIMEOUT_MS, + flagsFetchRetries: options.flagsFetchRetries ?? 3, headers: { "Content-Type": "application/json", [SDK_VERSION_HEADER_NAME]: SDK_VERSION, - ["Authorization"]: `Bearer ${config.secretKey}`, + Authorization: `Bearer ${config.secretKey}`, }, - refetchInterval: FLAGS_REFETCH_MS, - fallbackFlags: fallbackFlags, - flagsFallbackProvider: options.flagsFallbackProvider, - flagsFallbackProviderContext: { - secretKeyHash, - }, - flagOverrides: baseFlagOverrides, - flagsFetchRetries: options.flagsFetchRetries ?? 3, - fetchTimeoutMs: options.fetchTimeoutMs ?? API_TIMEOUT_MS, - flagsSyncMode, - flagsPushUrl: pushUrl.toString(), }; - this.baseFlagOverrides = baseFlagOverrides; - - if ((config.batchOptions?.flushOnExit ?? true) && !this._config.offline) { + this.batchBuffer = new BatchBuffer({ + ...config.batchOptions, + logger: this.logger, + flushHandler: (events) => this.post("bulk", events), + }); + if (!offline && (config.batchOptions?.flushOnExit ?? true)) triggerOnExit(() => this.flush()); - } - - if (!new URL(this._config.apiBaseUrl).pathname.endsWith("/")) { - this._config.apiBaseUrl += "/"; - } - this.flagsCache = new FlagsCache( - async (waitForVersion?: number) => { - const path = - waitForVersion === undefined - ? "features" - : `features?waitForVersion=${encodeURIComponent(String(waitForVersion))}`; - - const res = await this.get( - path, - this._config.flagsFetchRetries, + async (waitForVersion) => { + const response = await this.get( + `flags${waitForVersion === undefined ? "" : `?waitForVersion=${encodeURIComponent(waitForVersion)}`}`, + this.config.flagsFetchRetries, ); - const flagStateVersion = res?.flagStateVersion; - if ( - !isObject(res) || - !Array.isArray(res?.features) || - typeof flagStateVersion !== "number" || - !Number.isInteger(flagStateVersion) || - flagStateVersion < 0 - ) { - const fallbackDefinitions = await this.loadFlagsFallbackDefinitions(); - return fallbackDefinitions - ? { definitions: fallbackDefinitions } - : undefined; + try { + if ( + !response || + response.protocolVersion !== 2 || + !Number.isSafeInteger(response.flagStateVersion) || + response.flagStateVersion < 0 + ) + throw new Error("invalid v2 flag response"); + return { + definitions: prepareFlagCollection(response), + flagStateVersion: response.flagStateVersion, + }; + } catch (error) { + this.logger.debug("flag collection unavailable or invalid", error); + return this.loadFallback(); } - - void this.saveFlagsFallbackDefinitions(res.features); - this.canLoadFlagsFallbackProvider = false; - return { - definitions: compileFlagDefinitions(res.features), - flagStateVersion, - }; }, { logger: this.logger, - // Edge runtimes use `in-request` mode and cannot rely on delayed timer - // callbacks to perform trailing refresh work after the current request. scheduleTrailingRefresh: - flagsSyncMode === "in-request" - ? undefined - : scheduleTrailingRefreshWithTimeout, + flagsSyncMode === "in-request" ? undefined : scheduleRefresh, + onApply: ({ definitions, flagStateVersion, fallback }) => { + if (fallback) return; + this.canLoadFallback = false; + if (!this.config.flagsFallbackProvider) return; + // Persist only accepted snapshots, and serialize saves to prevent an old + // slow write from overwriting a newer collection in external storage. + const snapshot: FlagsFallbackSnapshot = structuredClone({ + version: 2, + savedAt: new Date().toISOString(), + flagStateVersion, + flags: definitions.flags, + segments: definitions.segments, + }); + this.fallbackSave = this.fallbackSave.then(async () => { + try { + await this.config.flagsFallbackProvider?.save( + this.config.fallbackContext, + snapshot, + ); + } catch (error) { + this.logger.error( + "flagsFallbackProvider: failed to save flag definitions", + error, + ); + } + }); + }, }, ); - this.flagsSyncController = createFlagsSyncController({ - mode: this._config.flagsSyncMode, + mode: flagsSyncMode, cache: this.flagsCache, - intervalMs: this._config.refetchInterval, - pushUrl: this._config.flagsPushUrl, - headers: this._config.headers, + intervalMs: FLAGS_REFETCH_MS, + pushUrl: pushUrl.toString(), + headers: this.config.headers, logger: this.logger, }); } - private async loadFlagsFallbackDefinitions() { - if (!this.canLoadFlagsFallbackProvider) { - return undefined; - } - - this.canLoadFlagsFallbackProvider = false; - const provider = this._config.flagsFallbackProvider; - if (!provider) { - return undefined; - } - + private async loadFallback() { + if (!this.canLoadFallback) return undefined; + this.canLoadFallback = false; try { - const snapshot = await provider.load( - this._config.flagsFallbackProviderContext, + const snapshot = await this.config.flagsFallbackProvider?.load( + this.config.fallbackContext, + ); + if (!snapshot) return undefined; + ok(isFlagsFallbackSnapshot(snapshot), "invalid fallback snapshot"); + const definitions = prepareFlagCollection( + snapshot, + snapshot.source !== "static", ); - - if (!snapshot) { - this.logger.warn( - "remote flags unavailable, no fallback flags found in flagsFallbackProvider", - ); - return undefined; - } - - if (!isFlagsFallbackSnapshot(snapshot)) { - this.logger.warn("flagsFallbackProvider: invalid snapshot returned"); - return undefined; - } - - const fallbackAge = formatFlagsFallbackAge(snapshot.savedAt); this.logger.warn( - fallbackAge - ? `remote flags unavailable, using fallback flags fetched ${fallbackAge} ago (${snapshot.savedAt})` - : `remote flags unavailable, using fallback flags (${snapshot.savedAt})`, + `remote flags unavailable, using fallback flags (${snapshot.savedAt})`, ); - - return compileFlagDefinitions(snapshot.flags); + return { + definitions, + flagStateVersion: snapshot.flagStateVersion, + fallback: true, + }; } catch (error) { this.logger.error( "flagsFallbackProvider: failed to load flag definitions", @@ -623,584 +358,489 @@ export class ReflagClient { } } - private async saveFlagsFallbackDefinitions(flags: FlagAPIResponse[]) { - const provider = this._config.flagsFallbackProvider; - if (!provider) { - return; - } - - try { - await provider.save( - this._config.flagsFallbackProviderContext, - createFlagsFallbackSnapshot(flags), - ); - } catch (error) { - this.logger.error( - "flagsFallbackProvider: failed to save flag definitions", - error, - ); - } + public async initialize() { + await this.initializeOnce(); } - - /** - * Replaces the base flag overrides used by the client. - * - * @param overrides - The flag overrides. - * - * @remarks - * Base overrides are always applied before any temporary layers added through - * `pushFlagOverrides()`. - * - * @example - * ```ts - * client.setFlagOverrides({ - * "flag-1": true, - * "flag-2": false, - * }); - * ``` - **/ - setFlagOverrides(overrides: FlagOverridesFn | FlagOverrides) { - this.baseFlagOverrides = normalizeFlagOverrides(overrides); - this.syncFlagOverrides(); + public async flush() { + if (this.config.offline) return; + await this.batchBuffer.flush(); + await this.flagsCache.waitRefresh(); + await this.fallbackSave; } - - /** - * @deprecated - * Use `setFlagOverrides()` for replacing the base override set. - **/ - set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) { + public async refreshFlags(waitForVersion?: number) { + ok( + waitForVersion === undefined || + (Number.isSafeInteger(waitForVersion) && waitForVersion >= 0), + "waitForVersion must be a non-negative safe integer", + ); + if (!this.config.offline) await this.flagsCache.refresh(waitForVersion); + } + public destroy() { + this.flagsSyncController.destroy(); + this.flagsCache.destroy(); + this.batchBuffer.destroy(); + } + public setFlagOverrides(overrides: FlagOverrides | FlagOverridesFn) { + this.baseOverrides = normalizeOverrides(overrides); + } + /** @deprecated Use setFlagOverrides. */ + public set flagOverrides(overrides: FlagOverrides | FlagOverridesFn) { this.setFlagOverrides(overrides); } - - /** - * Temporarily layers flag overrides on top of the current overrides. - * - * @param overrides - The flag overrides to apply for the scoped period. - * - * @returns A remove function that removes only this override layer. - * - * @remarks - * This is intended for tests or other short-lived local overrides. The remove - * function is idempotent and can safely be called multiple times. - * - * @example - * ```ts - * let remove: (() => void) | undefined; - * - * beforeEach(() => { - * remove = client.pushFlagOverrides({ "flag-1": true }); - * }); - * - * afterEach(() => { - * remove?.(); - * remove = undefined; - * }); - * ``` - **/ - pushFlagOverrides(overrides: FlagOverridesFn | FlagOverrides): () => void { - const layer: FlagOverrideLayer = { - id: this.nextFlagOverrideLayerId++, - overrides: normalizeFlagOverrides(overrides), - }; - - this.flagOverrideLayers.push(layer); - this.syncFlagOverrides(); - - let restored = false; - + public clearFlagOverrides() { + this.baseOverrides = () => ({}); + } + public pushFlagOverrides(overrides: FlagOverrides | FlagOverridesFn) { + const layer = { overrides: normalizeOverrides(overrides) }; + this.overrideLayers.push(layer); return () => { - if (restored) { - return; - } - restored = true; - - const layerIndex = this.flagOverrideLayers.findIndex( - ({ id }) => id === layer.id, + this.overrideLayers = this.overrideLayers.filter( + (candidate) => candidate !== layer, ); - if (layerIndex === -1) { - return; - } - - this.flagOverrideLayers.splice(layerIndex, 1); - this.syncFlagOverrides(); }; } - - /** - * Clears the base flag overrides. - * - * @remarks - * This does not affect temporary layers added with `pushFlagOverrides()`. - * - * @example - * ```ts - * client.clearFlagOverrides(); - * ``` - **/ - clearFlagOverrides() { - this.baseFlagOverrides = () => ({}); - this.syncFlagOverrides(); + public bindClient(options: ContextWithTracking) { + return new BoundReflagClient(this, options); } - private syncFlagOverrides() { - this._config.flagOverrides = composeFlagOverrides( - this.baseFlagOverrides, - this.flagOverrideLayers, + /** Complete definitions, including segments needed for offline evaluation. */ + public getFlagDefinitions(): FlagCollection { + this.onAccess(); + const collection = this.flagsCache.get(); + return JSON.parse( + JSON.stringify({ + flags: collection?.flags ?? [], + segments: collection?.segments ?? [], + }), + ) as FlagCollection; + } + /** Read a Boolean with an implicit false default, or supply an explicit value default. */ + public getFlag( + options: ContextWithTracking, + key: Key, + defaultValue?: undefined, + ): boolean; + public getFlag( + options: ContextWithTracking, + key: Key, + defaultValue: Default, + ): FlagValueFromDefault; + public getFlag( + options: ContextWithTracking, + key: TypedFlagKey, + defaultValue: FlagDefaultValue = false, + ): FlagValue { + checkFlagDefault(defaultValue); + return this.readFlagValue( + key, + defaultValue, + { ...this.evaluate(options, key, false), context: contextOnly(options) }, + options.enableTracking !== false, + ); + } + + private readFlagValue( + key: string, + defaultValue: FlagValue, + evaluation: { + flags: RawFlags; + context: Context; + flagStateVersion?: number; + }, + enableTracking: boolean, + ): FlagValue { + const result = evaluation.flags[key] ?? localResult(key, undefined); + const selected = applyFlagDefault(result.value, defaultValue); + if (selected.typeMismatch) + this.logger.warn( + `flag "${key}" returned ${flagValueType(result.value!)}; expected ${flagValueType(defaultValue)}. Using the default value.`, + ); + if (enableTracking) { + const checkResult = selected.usedDefault + ? { + ...localResult(key, defaultValue), + ruleResults: result.ruleResults, + errors: result.errors, + } + : result; + this.registerCheck( + key, + checkResult, + evaluation.context, + evaluation.flagStateVersion, + ); + } + return structuredClone(selected.value); + } + /** Return all values and immediately register checks for the returned roots. */ + public getFlags(options: ContextWithTracking): TypedFlags { + const { flags } = this.evaluate(options, undefined, true); + return Object.fromEntries( + Object.entries(flags).map(([key, result]) => [ + key, + copyValue(result.value), + ]), + ) as TypedFlags; + } + /** Serialization is not a read by the browser; it must not emit check events. */ + public getFlagsForBootstrap(options: ContextWithTracking): BootstrappedFlags { + const { flags, flagStateVersion } = this.evaluate( + options, + undefined, + false, ); + return { + protocolVersion: 2, + context: JSON.parse( + canonicalJSONStringify(contextOnly(options)), + ) as Context, + flags: structuredClone(flags), + ...(flagStateVersion === undefined ? {} : { flagStateVersion }), + }; } - /** - * Returns a new BoundReflagClient with the user/company/otherContext - * set to be used in subsequent calls. - * For example, for evaluating flag targeting or tracking events. - * - * @param context - The context to bind the client to. - * @param context.enableTracking - Whether to enable tracking for the context. - * @param context.user - The user context. - * @param context.company - The company context. - * @param context.other - The other context. - * - * @returns A new client bound with the arguments given. - * - * @throws An error if the user/company is given but their ID is not a string. - * - * @remarks - * The `updateUser` / `updateCompany` methods will automatically be called when - * the user/company is set respectively. - **/ - public bindClient({ - enableTracking = true, - ...context - }: ContextWithTracking) { - return new BoundReflagClient(this, { enableTracking, ...context }); + private onAccess() { + if (!this.config.offline) this.flagsSyncController.onAccess?.(); } - - /** - * Updates the associated user in Reflag. - * - * @param userId - The userId of the user to update. - * @param options - The options for the user. - * @param options.attributes - The additional attributes of the user (optional). - * @param options.meta - The meta context associated with tracking (optional). - * - * @throws An error if the company is not set or the options are invalid. - * @remarks - * The company must be set using `withCompany` before calling this method. - * If the user is set, the company will be associated with the user. - **/ - public async updateUser(userId: IdType, options?: TrackOptions) { - idOk(userId, "userId"); - ok(options === undefined || isObject(options), "options must be an object"); - ok( - options?.attributes === undefined || isObject(options.attributes), - "attributes must be an object", + private evaluate( + options: ContextWithTracking, + key: string | undefined, + checks: boolean, + ) { + checkContext(options); + const { enableTracking = true } = options; + const context = contextOnly(options); + if (!this.initialized) + this.logger.error("getFlag(s): ReflagClient is not initialized yet."); + void this.syncContext({ ...options, enableTracking }).catch((error) => + this.logger.error("failed to synchronize context", error), ); - checkMeta(options?.meta); - - if (this._config.offline) { + this.onAccess(); + const collection = this.flagsCache.get(); + const flagStateVersion = this.flagsCache.getFlagStateVersion(); + const flags: RawFlags = Object.create(null); + if (collection) { + if (key === undefined) + Object.assign(flags, collection.evaluator.evaluateAll(context).flags); + else if (collection.flags.some((flag) => flag.key === key)) + flags[key] = collection.evaluator.evaluateFlag(context, key); + if (!collection.trackSelections) + for (const [flagKey, result] of Object.entries(flags)) + flags[flagKey] = localResult(flagKey, result.value); + } else { + for (const [flagKey, value] of Object.entries(this.config.fallbackFlags)) + if (key === undefined || key === flagKey) + flags[flagKey] = localResult(flagKey, value); + } + const overrides = this.overrideLayers.reduce( + (current, layer) => ({ ...current, ...layer.overrides(context) }), + this.baseOverrides(context), + ); + for (const [flagKey, value] of Object.entries(overrides)) { + if (key !== undefined && key !== flagKey) continue; + ok(isFlagValue(value), "flag overrides must contain JSON values"); + flags[flagKey] = localResult(flagKey, value); + } + if (key !== undefined && !Object.prototype.hasOwnProperty.call(flags, key)) + flags[key] = localResult(key, undefined); + if (checks && enableTracking) + for (const [flagKey, result] of Object.entries(flags)) + this.registerCheck(flagKey, result, context, flagStateVersion); + return { flags, flagStateVersion }; + } + + private registerCheck( + key: string, + result: RawFlag, + context: Context, + flagStateVersion?: number, + ) { + if (this.config.offline) return; + const event: FlagEvent = { + action: "check", + key, + ...(result.resolved + ? { + evalResult: result.variantKey, + sourceVersionId: result.sourceVersionId, + } + : {}), + flagStateVersion, + evalContext: JSON.parse(canonicalJSONStringify(context)) as Record< + string, + unknown + >, + evalRuleResults: result.ruleResults.map((rule) => rule.matched), + ...(result.errors.length ? { evalErrors: result.errors } : {}), + }; + // Include selection/version identity: equal JSON payloads are not the same variant. + const identity = { + ...event, + evalContext: flattenContext(event.evalContext ?? {}), + }; + if ( + !this.rateLimiter.isAllowed(hashString(canonicalJSONStringify(identity))) + ) return; - } + if (result.errors.length) + this.logger.warn("flag targeting rules could not be fully evaluated.", { + [key]: result.errors, + }); + void this.batchBuffer + .add({ type: "feature-flag-event", ...event }) + .catch((error) => + this.logger.error(`failed to send check event for "${key}"`, error), + ); + } - if (this.rateLimiter.isAllowed(hashObject({ ...options, userId }))) { - await this.batchBuffer.add({ - type: "user", - userId, - attributes: options?.attributes, - context: options?.meta, - }); + public async getFlagsRemote( + userId?: IdType, + companyId?: IdType, + context?: ContextWithTracking, + ): Promise { + const evaluation = await this.getRemote( + undefined, + userId, + companyId, + context, + ); + if (context?.enableTracking !== false) + for (const [key, result] of Object.entries(evaluation.flags)) + this.registerCheck( + key, + result, + evaluation.context, + evaluation.flagStateVersion, + ); + return Object.fromEntries( + Object.entries(evaluation.flags).map(([key, result]) => [ + key, + copyValue(result.value), + ]), + ) as TypedFlags; + } + public getFlagRemote( + key: Key, + defaultValue?: undefined, + userId?: IdType, + companyId?: IdType, + context?: ContextWithTracking, + ): Promise; + public getFlagRemote< + Key extends TypedFlagKey, + Default extends FlagDefaultValue, + >( + key: Key, + defaultValue: Default, + userId?: IdType, + companyId?: IdType, + context?: ContextWithTracking, + ): Promise>; + public async getFlagRemote( + key: TypedFlagKey, + defaultValue: FlagDefaultValue = false, + userId?: IdType, + companyId?: IdType, + context?: ContextWithTracking, + ): Promise { + checkFlagDefault(defaultValue); + const evaluation = await this.getRemote(key, userId, companyId, context); + return this.readFlagValue( + key, + defaultValue, + evaluation, + context?.enableTracking !== false, + ); + } + private async getRemote( + key: string | undefined, + userId?: IdType, + companyId?: IdType, + additionalContext: ContextWithTracking = {}, + ): Promise<{ flags: RawFlags; context: Context; flagStateVersion?: number }> { + const context = { + ...contextOnly(additionalContext), + ...(userId === undefined + ? {} + : { user: { ...additionalContext.user, id: userId } }), + ...(companyId === undefined + ? {} + : { company: { ...additionalContext.company, id: companyId } }), + }; + checkContext(additionalContext); + checkContext(context); + if (this.config.offline) + return { + ...this.evaluate({ ...context, enableTracking: false }, key, false), + context, + }; + const query = new URLSearchParams({ + contextJson: canonicalContextJSONStringify(context) ?? "{}", + }); + if (key !== undefined) query.set("key", key); + const response = await this.get( + `flags/evaluated?${query}`, + ); + const flags: RawFlags = Object.create(null); + if ( + !response || + response.protocolVersion !== 2 || + !Number.isSafeInteger(response.flagStateVersion) || + response.flagStateVersion < 0 || + !isObject(response.flags) + ) + return { flags, context }; + for (const [flagKey, result] of Object.entries(response.flags)) { + if (key !== undefined && key !== flagKey) continue; + // The endpoint returns only successful selections, with the same metadata as local evaluation. + if ( + !isObject(result) || + !isFlagValue(result.value) || + typeof result.variantKey !== "string" || + !result.variantKey || + typeof result.sourceVersionId !== "string" || + !result.sourceVersionId || + typeof result.matchedRuleId !== "string" || + !Array.isArray(result.ruleResults) || + !result.ruleResults.every( + (rule) => + isObject(rule) && + typeof rule.ruleId === "string" && + typeof rule.matched === "boolean", + ) || + !Array.isArray(result.errors) || + !result.errors.every( + (error: unknown) => + isObject(error) && + typeof error.code === "string" && + typeof error.message === "string" && + (error.field === undefined || typeof error.field === "string") && + (error.operator === undefined || + typeof error.operator === "string") && + (error.source === undefined || + (isObject(error.source) && + ["flag", "segment"].includes(error.source.type) && + typeof error.source.key === "string")), + ) + ) + continue; + flags[flagKey] = { ...result, flagKey, resolved: true }; } + return { flags, context, flagStateVersion: response.flagStateVersion }; } - /** - * Updates the associated company in Reflag. - * - * @param companyId - The companyId of the company to update. - * @param options - The options for the company. - * @param options.attributes - The additional attributes of the company (optional). - * @param options.meta - The meta context associated with tracking (optional). - * @param options.userId - The userId of the user to associate with the company (optional). - * - * @throws An error if the company is not set or the options are invalid. - * @remarks - * The company must be set using `withCompany` before calling this method. - * If the user is set, the company will be associated with the user. - **/ + public async updateUser(userId: IdType, options?: TrackOptions) { + idOk(userId, "userId"); + checkTrackOptions(options); + if ( + this.config.offline || + !this.rateLimiter.isAllowed(hashObject({ userId, ...options })) + ) + return; + await this.batchBuffer.add({ + type: "user", + userId, + attributes: options?.attributes, + context: options?.meta, + }); + } public async updateCompany( companyId: IdType, options?: TrackOptions & { userId?: IdType }, ) { idOk(companyId, "companyId"); - ok(options === undefined || isObject(options), "options must be an object"); - ok( - options?.attributes === undefined || isObject(options.attributes), - "attributes must be an object", - ); - checkMeta(options?.meta); - - if (typeof options?.userId !== "undefined") { - idOk(options?.userId, "userId"); - } - - if (this._config.offline) { + checkTrackOptions(options); + if (options?.userId !== undefined) idOk(options.userId, "userId"); + if ( + this.config.offline || + !this.rateLimiter.isAllowed(hashObject({ companyId, ...options })) + ) return; - } - - if (this.rateLimiter.isAllowed(hashObject({ ...options, companyId }))) { - await this.batchBuffer.add({ - type: "company", - companyId, - userId: options?.userId, - attributes: options?.attributes, - context: options?.meta, - }); - } + await this.batchBuffer.add({ + type: "company", + companyId, + userId: options?.userId, + attributes: options?.attributes, + context: options?.meta, + }); } - - /** - * Tracks an event in Reflag. - - * @param options.companyId - Optional company ID for the event (optional). - * - * @throws An error if the user is not set or the event is invalid or the options are invalid. - * @remarks - * If the company is set, the event will be associated with the company. - **/ public async track( userId: IdType, event: string, options?: TrackOptions & { companyId?: IdType }, ) { idOk(userId, "userId"); - ok(typeof event === "string" && event.length > 0, "event must be a string"); - ok(options === undefined || isObject(options), "options must be an object"); ok( - options?.attributes === undefined || isObject(options.attributes), - "attributes must be an object", - ); - ok( - options?.meta === undefined || isObject(options.meta), - "meta must be an object", - ); - if (options?.companyId !== undefined) { - idOk(options?.companyId, "companyId"); - } - - if (this._config.offline) { - return; - } - - await this.batchBuffer.add({ - type: "event", - event, - companyId: options?.companyId, - userId, - attributes: options?.attributes, - context: options?.meta, - }); - } - - /** - * Initializes the client by caching the flags definitions. - * - * @remarks - * Call this method before calling `getFlags` to ensure the flag definitions are cached. - * The client will ignore subsequent calls to this method. - **/ - public async initialize() { - await this._initialize(); - return; - } - - /** - * Flushes and completes any in-flight fetches in the flag cache. - * - * @remarks - * It is recommended to call this method when the application is shutting down to ensure all events are sent - * before the process exits. - * - * This method is automatically called when the process exits if `batchOptions.flushOnExit` is `true` in the options (default). - */ - public async flush() { - if (this._config.offline) { - return; - } - - await this.batchBuffer.flush(); - await this.flagsCache.waitRefresh(); - } - - /** - * Refreshes the flag definitions from the server. - * - * @remarks - * This triggers an on-demand refresh of the cached flag definitions. - * Useful when you know flags have changed and don't want to wait for the next automatic refresh cycle. - * - * Note: updated flag rules take a few seconds to propagate to all servers. - * - * Fetch starts are throttled to at most once per second. - * Outside `in-request` mode, throttling only delays when the next fetch - * begins: `refreshFlags(99)` still waits until the cache has applied flag - * definitions from version `99` or newer before the promise resolves. - * Concurrent callers are deduplicated and may share the same in-flight or - * scheduled follow-up refresh. - * - * In `in-request` mode, delayed follow-up refreshes are not scheduled. On edge - * runtimes like Cloudflare Workers, a call during the throttle window only - * records pending refresh work, and the promise may resolve before that fetch - * runs. The pending refresh is executed on the next request/access or - * `refreshFlags()` call after the throttle window expires. - * - * @param waitForVersion - Optional flag state version to wait for before returning updated definitions. - */ - public async refreshFlags(waitForVersion?: number) { - if (this._config.offline) { - return; - } - - await this.flagsCache.refresh(waitForVersion); - } - - /** - * Destroys the client and cleans up all resources including timers and background processes. - * - * @remarks - * After calling this method, the client should not be used anymore. - * This is particularly useful in development environments with hot reloading to prevent - * multiple background processes from running simultaneously. - */ - public destroy() { - this.flagsSyncController.destroy(); - this.flagsCache.destroy(); - this.batchBuffer.destroy(); - } - - /** - * Gets the flag definitions, including all config values. - * To evaluate which flags are enabled for a given user/company, use `getFlags`. - * - * @returns The flags definitions. - */ - public getFlagDefinitions(): FlagDefinition[] { - if (!this._config.offline) { - this.flagsSyncController.onAccess?.(); - } - const flags = this.flagsCache.get() || []; - return flags.map((f) => ({ - key: f.key, - description: f.description, - flag: f.targeting, - config: f.config, - })); - } - - /** - * Gets the evaluated flags for the current context which includes the user, company, and custom context. - * - * @param options - The options for the context. - * @param options.enableTracking - Whether to enable tracking for the context. - * @param options.meta - The meta context associated with the context. - * @param options.user - The user context. - * @param options.company - The company context. - * @param options.other - The other context. - * - * @returns The evaluated flags. - * - * @remarks - * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise. - **/ - public getFlags({ - enableTracking = true, - ...context - }: ContextWithTracking): TypedFlags { - const contextWithTracking = { enableTracking, ...context }; - const rawFlags = this._getFlags(contextWithTracking); - return Object.fromEntries( - Object.entries(rawFlags).map(([key, rawFlag]) => [ - key, - this._wrapRawFlag(contextWithTracking, rawFlag), - ]), - ); - } - - /** - * Gets the evaluated flag for the current context which includes the user, company, and custom context. - * Using the `isEnabled` property sends a `check` event to Reflag. - * - * @param key - The key of the flag to get. - * @returns The evaluated flag. - * - * @remarks - * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise. - **/ - public getFlag( - { enableTracking = true, ...context }: ContextWithTracking, - key: TKey, - ): TypedFlags[TKey] { - const contextWithTracking = { enableTracking, ...context }; - const rawFlag = this._getFlags(contextWithTracking, key); - return this._wrapRawFlag( - { enableChecks: true, ...contextWithTracking }, - rawFlag ?? { key }, + typeof event === "string" && event.length > 0, + "event must be a non-empty string", ); + checkTrackOptions(options); + if (options?.companyId !== undefined) idOk(options.companyId, "companyId"); + if (!this.config.offline) + await this.batchBuffer.add({ + type: "event", + userId, + event, + companyId: options?.companyId, + attributes: options?.attributes, + context: options?.meta, + }); } - - /** - * Gets the evaluated flags for the current context without wrapping them in getters. - * This method returns raw flag data suitable for bootstrapping client-side applications. - * - * @param options - The options for the context. - * @param options.enableTracking - Whether to enable tracking for the context. - * @param options.meta - The meta context associated with the context. - * @param options.user - The user context. - * @param options.company - The company context. - * @param options.other - The other context. - * - * @returns The evaluated raw flags and the context. - * - * @remarks - * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise. - * This method returns RawFlag objects without wrapping them in getters, making them suitable for serialization. - **/ - public getFlagsForBootstrap({ + private async syncContext({ enableTracking = true, - ...context - }: ContextWithTracking): BootstrappedFlags { - const contextWithTracking = { enableTracking, ...context }; - const flagStateVersion = this.flagsCache.getFlagStateVersion(); - - return { - context: contextWithTracking, - flags: this._getFlags(contextWithTracking), - ...(flagStateVersion !== undefined ? { flagStateVersion } : {}), - }; - } - - /** - * Gets evaluated flags with the usage of remote context. - * This method triggers a network request every time it's called. - * - * @param userId - The userId of the user to get the flags for. - * @param companyId - The companyId of the company to get the flags for. - * @param additionalContext - The additional context to get the flags for. - * - * @returns evaluated flags - */ - public async getFlagsRemote( - userId?: IdType, - companyId?: IdType, - additionalContext?: Context, - ): Promise { - return this._getFlagsRemote( - undefined, - userId, - companyId, - additionalContext, - ); - } - - /** - * Gets evaluated flag with the usage of remote context. - * This method triggers a network request every time it's called. - * - * @param key - The key of the flag to get. - * @param userId - The userId of the user to get the flag for. - * @param companyId - The companyId of the company to get the flag for. - * @param additionalContext - The additional context to get the flag for. - * - * @returns evaluated flag - */ - public async getFlagRemote( - key: TKey, - userId?: IdType, - companyId?: IdType, - additionalContext?: Context, - ): Promise { - return this._getFlagsRemote(key, userId, companyId, additionalContext); - } - - private buildUrl(path: string) { - if (path.startsWith("/")) { - path = path.slice(1); - } - - const url = new URL(path, this._config.apiBaseUrl); - return url.toString(); - } - - /** - * Sends a POST request to the specified path. - * - * @param path - The path to send the request to. - * @param body - The body of the request. - * - * @throws An error if the path or body is invalid. - **/ - private async post(path: string, body: TBody) { - ok(typeof path === "string" && path.length > 0, "path must be a string"); - ok(typeof body === "object", "body must be an object"); - - const url = this.buildUrl(path); - try { - const response = await this.httpClient.post( - url, - this._config.headers, - body, - ); - - this.logger.debug(`post request to "${url}"`, response); - - if (!response.ok || !isObject(response.body) || !response.body.success) { - throw createErrorWithCause( - `invalid response received from server for "${url}"`, - JSON.stringify(response), - ); - } - } catch (error) { - throw createErrorWithCause( - `post request to "${url}" failed with error`, - error, + user, + company, + meta, + }: ContextWithTracking) { + if (!enableTracking || this.config.offline) return; + const pending: Promise[] = []; + if (user?.id !== undefined) { + const { id, ...attributes } = user; + pending.push(this.updateUser(id, { attributes, meta })); + } + if (company?.id !== undefined) { + const { id, ...attributes } = company; + pending.push( + this.updateCompany(id, { userId: user?.id, attributes, meta }), ); } - } - - /** - * Sends a GET request to the specified path. - * - * @param path - The path to send the request to. - * @param retries - Optional number of retries for the request. - * - * @returns The response from the server. - * @throws An error if the path is invalid. - **/ - private async get(path: string, retries: number = 3) { - ok(typeof path === "string" && path.length > 0, "path must be a string"); - + await Promise.all(pending); + } + private async post(path: string, body: BulkEvent[]) { + const response = await this.httpClient.post< + BulkEvent[], + { success: boolean } + >( + new URL(path, this.config.apiBaseUrl).toString(), + this.config.headers, + body, + ); + if (!response.ok || !response.body?.success) + throw new Error("invalid response sending bulk events"); + } + private async get( + path: string, + retries = this.config.flagsFetchRetries, + ): Promise { + const url = new URL(path, this.config.apiBaseUrl).toString(); try { - const url = this.buildUrl(path); return await withRetry( async () => { const response = await this.httpClient.get< - TResponse & { success: boolean } - >(url, this._config.headers, this._config.fetchTimeoutMs); - - this.logger.debug(`get request to "${url}"`, response); - + Response & { success: boolean } + >(url, this.config.headers, this.config.fetchTimeoutMs); if ( !response.ok || !isObject(response.body) || - !response.body.success - ) { + response.body.success !== true + ) throw new Error( - `invalid response received from server for "${url}": ${JSON.stringify(response.body)}`, + `invalid response received from server for "${url}"`, ); - } - const { success: _, ...result } = response.body; - return result as TResponse; - }, - (error) => { - this.logger.warn("failed to fetch flags, will retry", error); + const { success: _, ...body } = response.body; + return body as Response; }, + (error) => this.logger.warn("failed to fetch flags, will retry", error), retries, 1000, 10000, @@ -1213,770 +853,140 @@ export class ReflagClient { return undefined; } } - - /** - * Sends a batch of events to the Reflag API. - * - * @param events - The events to send. - * - * @throws An error if the send fails. - **/ - private async sendBulkEvents(events: BulkEvent[]) { - ok( - Array.isArray(events) && events.length > 0, - "events must be a non-empty array", - ); - - try { - await this.post("bulk", events); - } catch (error) { - throw createErrorWithCause("failed to send bulk events", error); - } - } - - /** - * Sends a flag event to the Reflag API. - * - * Flag events are used to track the evaluation of flag targeting rules. - * "check" events are sent when a flag's `isEnabled` property is checked. - * "evaluate" events are sent when a flag's targeting rules are matched against - * the current context. - * - * @param event - The event to send. - * @param event.action - The action to send. - * @param event.key - The key of the flag to send. - * @param event.targetingVersion - The targeting version of the flag to send. - * @param event.evalResult - The evaluation result of the flag to send. - * @param event.evalContext - The evaluation context of the flag to send. - * @param event.evalRuleResults - The evaluation rule results of the flag to send. - * @param event.evalMissingFields - The evaluation missing fields of the flag to send. - * - * @throws An error if the event is invalid. - * - * @remarks - * This method is rate-limited to prevent too many events from being sent. - **/ - private async sendFlagEvent(event: FlagEvent) { - ok(typeof event === "object", "event must be an object"); - ok( - typeof event.action === "string" && - (event.action === "check" || event.action === "check-config"), - "event must have an action", - ); - ok( - typeof event.key === "string" && event.key.length > 0, - "event must have a flag key", - ); - ok( - typeof event.targetingVersion === "number" || - event.targetingVersion === undefined, - "event must have a targeting version", - ); - ok( - typeof event.evalResult === "boolean" || isObject(event.evalResult), - "event must have an evaluation result", - ); - ok( - event.evalContext === undefined || typeof event.evalContext === "object", - "event context must be an object", - ); - ok( - event.evalRuleResults === undefined || - Array.isArray(event.evalRuleResults), - "event rule results must be an array", - ); - ok( - event.evalMissingFields === undefined || - Array.isArray(event.evalMissingFields), - "event missing fields must be an array", - ); - - const contextKey = new URLSearchParams( - flattenJSON(event.evalContext || {}), - ).toString(); - - if (this._config.offline) { - return; - } - - if ( - !this.rateLimiter.isAllowed( - hashObject({ - action: event.action, - key: event.key, - targetingVersion: event.targetingVersion, - evalResult: event.evalResult, - contextKey, - }), - ) - ) { - return; - } - - await this.batchBuffer.add({ - type: "feature-flag-event", - action: event.action, - key: event.key, - targetingVersion: event.targetingVersion, - evalContext: event.evalContext, - evalResult: event.evalResult, - evalRuleResults: event.evalRuleResults, - evalMissingFields: event.evalMissingFields, - }); - } - - /** - * Updates the context in Reflag (if needed). - * This method should be used before requesting flags or binding a client. - * - * @param options - The options for the context. - * @param options.enableTracking - Whether to enable tracking for the context. - * @param options.meta - The meta context associated with the context. - * @param options.user - The user context. - * @param options.company - The company context. - * @param options.other - The other context. - */ - private async syncContext(options: ContextWithTracking) { - if (!options.enableTracking) { - this.logger.debug("tracking disabled, not updating user/company"); - - return; - } - - const promises: Promise[] = []; - if (typeof options.company?.id !== "undefined") { - const { id: _, ...attributes } = options.company; - promises.push( - this.updateCompany(options.company.id, { - userId: options.user?.id, - attributes, - meta: options.meta, - }), - ); - } - - if (typeof options.user?.id !== "undefined") { - const { id: _, ...attributes } = options.user; - promises.push( - this.updateUser(options.user.id, { - attributes, - meta: options.meta, - }), - ); - } - - if (promises.length > 0) { - await Promise.all(promises); - } - } - - /** - * Warns if a flag or config evaluation produced diagnostics. - * - * @param flag - The flag to check. - */ - private _warnFlagEvaluationDiagnostics(flag: { - key: string; - missingContextFields?: string[]; - evaluationErrors?: EvaluationError[]; - config?: { - key: string; - missingContextFields?: string[]; - evaluationErrors?: EvaluationError[]; - }; - }) { - const missingFieldsReport: Record = {}; - const errorReport: Record = {}; - const { config, ...flagData } = flag; - const evaluations = [ - { - reportKey: flagData.key, - rateLimitKey: { flagKey: flagData.key }, - missingContextFields: flagData.missingContextFields, - errors: flagData.evaluationErrors, - }, - ...(config - ? [ - { - reportKey: `${flagData.key}.config`, - rateLimitKey: { flagKey: flagData.key, configKey: config.key }, - missingContextFields: config.missingContextFields, - errors: config.evaluationErrors, - }, - ] - : []), - ]; - - for (const evaluation of evaluations) { - if ( - evaluation.missingContextFields?.length && - !evaluation.errors?.some( - ({ code }) => code === "MISSING_CONTEXT_FIELD", - ) && - this.rateLimiter.isAllowed( - hashObject({ - ...evaluation.rateLimitKey, - missingContextFields: JSON.stringify( - [...evaluation.missingContextFields].sort(), - ), - }), - ) - ) { - missingFieldsReport[evaluation.reportKey] = - evaluation.missingContextFields; - } - } - - if (Object.keys(missingFieldsReport).length > 0) { - this.logger.warn( - `flag targeting rules might not be correctly evaluated due to missing context fields.`, - missingFieldsReport, - ); - } - - for (const evaluation of evaluations) { - if ( - evaluation.errors?.length && - this.rateLimiter.isAllowed( - hashObject({ - ...evaluation.rateLimitKey, - evaluationErrors: evaluationErrorsRateLimitKey(evaluation.errors), - }), - ) - ) { - errorReport[evaluation.reportKey] = evaluation.errors; - } - } - - if (Object.keys(errorReport).length > 0) { - this.logger.warn( - "flag targeting rules could not be fully evaluated.", - errorReport, - ); - } - } - - private _getFlags(options: ContextWithTracking): RawFlags; - private _getFlags( - options: ContextWithTracking, - key: TKey, - ): RawFlag | undefined; - private _getFlags( - options: ContextWithTracking, - key?: TKey, - ): RawFlags | RawFlag | undefined { - checkContextWithTracking(options); - - if (!this.initializationFinished) { - this.logger.error("getFlag(s): ReflagClient is not initialized yet."); - } - - void this.syncContext(options); - let flagDefinitions: CachedFlagDefinition[] = []; - - if (!this._config.offline) { - this.flagsSyncController.onAccess?.(); - const flagDefs = this.flagsCache.get(); - if (!flagDefs) { - this.logger.warn( - "no flag definitions available, using fallback flags.", - ); - const fallbackFlags = this._config.fallbackFlags || {}; - if (key) { - return fallbackFlags[key]; - } - return fallbackFlags; - } - flagDefinitions = flagDefs; - } - - const { enableTracking: _, meta: __, ...context } = options; - - const evaluated = flagDefinitions - .filter(({ key: flagKey }) => (key ? key === flagKey : true)) - .map((flag) => ({ - flagKey: flag.key, - targetingVersion: flag.targeting.version, - configVersion: flag.config?.version, - enabledResult: flag.enabledEvaluator(context, flag.key), - configResult: - flag.configEvaluator?.(context, flag.key) ?? - ({ - flagKey: flag.key, - context, - value: undefined, - ruleEvaluationResults: [], - missingContextFields: [], - errors: undefined, - } satisfies EvaluationResult), - })); - - let evaluatedFlags = evaluated.reduce((acc, res) => { - acc[res.flagKey as TypedFlagKey] = { - key: res.flagKey, - isEnabled: res.enabledResult.value ?? false, - ruleEvaluationResults: res.enabledResult.ruleEvaluationResults, - missingContextFields: res.enabledResult.missingContextFields, - ...(res.enabledResult.errors?.length - ? { evaluationErrors: res.enabledResult.errors } - : {}), - 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, - ...(res.configResult?.errors?.length - ? { evaluationErrors: res.configResult.errors } - : {}), - }, - }; - return acc; - }, {} as RawFlags); - - const overrides = Object.entries(this._config.flagOverrides(context)) - .filter(([flagKey]) => (key ? key === flagKey : true)) - .map(([flagKey, override]) => [ - flagKey, - isObject(override) - ? { - key: flagKey, - isEnabled: override.isEnabled, - config: override.config, - } - : { - key: flagKey, - isEnabled: !!override, - config: undefined, - }, - ]); - - if (overrides.length > 0) { - // merge overrides into evaluated flags - evaluatedFlags = { - ...evaluatedFlags, - ...Object.fromEntries(overrides), - }; - } - - if (key) { - return evaluatedFlags[key]; - } - - return evaluatedFlags; - } - - private _wrapRawFlag( - { - enableTracking, - enableChecks = false, - ...context - }: { enableTracking: boolean; enableChecks?: boolean } & Context, - { config, ...flag }: PartialBy, - ): TypedFlags[TKey] { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const client = this; - - const simplifiedConfig = config - ? { key: config.key, payload: config.payload } - : { key: undefined, payload: undefined }; - - return { - get isEnabled() { - if (enableTracking && enableChecks) { - client._warnFlagEvaluationDiagnostics(flag); - - void client - .sendFlagEvent({ - action: "check", - key: flag.key, - targetingVersion: flag.targetingVersion, - evalResult: flag.isEnabled ?? false, - evalContext: context, - evalRuleResults: flag.ruleEvaluationResults, - evalMissingFields: flag.missingContextFields, - }) - .catch((err) => { - client.logger?.error( - `failed to send check event for "${flag.key}": ${err}`, - err, - ); - }); - } - return flag.isEnabled ?? false; - }, - get config() { - if (enableTracking && enableChecks) { - client._warnFlagEvaluationDiagnostics({ ...flag, config }); - - void client - .sendFlagEvent({ - action: "check-config", - key: flag.key, - targetingVersion: config?.targetingVersion, - evalResult: simplifiedConfig, - evalContext: context, - evalRuleResults: config?.ruleEvaluationResults, - evalMissingFields: config?.missingContextFields, - }) - .catch((err) => { - client.logger?.error( - `failed to send check event for "${flag.key}": ${err}`, - err, - ); - }); - } - return simplifiedConfig as TypedFlags[TKey]["config"]; - }, - key: flag.key, - track: async () => { - if (typeof context.user?.id === "undefined") { - this.logger.warn("no user set, cannot track event"); - return; - } - - if (enableTracking) { - await this.track(context.user.id, flag.key, { - companyId: context.company?.id, - }); - } else { - this.logger.debug("tracking disabled, not tracking event"); - } - }, - }; - } - - private async _getFlagsRemote( - key: undefined, - userId?: IdType, - companyId?: IdType, - additionalContext?: Context, - ): Promise; - private async _getFlagsRemote( - key: TKey, - userId?: IdType, - companyId?: IdType, - additionalContext?: Context, - ): Promise; - private async _getFlagsRemote( - key?: string, - userId?: IdType, - companyId?: IdType, - additionalContext?: Context, - ): Promise { - const context = additionalContext || {}; - if (userId) { - context.user = { id: userId }; - } - if (companyId) { - context.company = { id: companyId }; - } - - const contextWithTracking = { - ...context, - enableTracking: true, - }; - - checkContextWithTracking(contextWithTracking); - - const params = new URLSearchParams(); - const contextJson = canonicalContextJSONStringify(context); - if (contextJson) { - params.set("contextJson", contextJson); - } - - if (key) { - params.append("key", key); - } - - const query = params.toString(); - const res = await this.get( - `features/evaluated${query ? `?${query}` : ""}`, - ); - - if (key) { - const flag = res?.features[key]; - if (!flag) { - this.logger.error(`flag ${key} not found`); - } - return this._wrapRawFlag(contextWithTracking, { key, ...flag }); - } else { - return res?.features - ? Object.fromEntries( - Object.entries(res?.features).map(([flagKey, flag]) => [ - flagKey, - this._wrapRawFlag(contextWithTracking, flag), - ]), - ) - : {}; - } - } } -/** - * A client bound with a specific user, company, and other context. - */ +/** Context-bound reads have the same direct-value and immediate-check semantics. */ export class BoundReflagClient { - private readonly _client: ReflagClient; - private readonly _options: ContextWithTracking; - - /** - * (Internal) Creates a new BoundReflagClient. Use `bindClient` to create a new client bound with a specific context. - * - * @param client - The `ReflagClient` to use. - * @param options - The options for the client. - * @param options.enableTracking - Whether to enable tracking for the client. - * - * @internal - */ constructor( - client: ReflagClient, - { enableTracking = true, ...context }: ContextWithTracking, + private readonly client: ReflagClient, + private readonly options: ContextWithTracking, ) { - this._client = client; - - this._options = { enableTracking, ...context }; - - checkContextWithTracking(this._options); - void this._client["syncContext"](this._options); + checkContext(options); } - - /** - * Gets the "other" context associated with the client. - * - * @returns The "other" context or `undefined` if it is not set. - **/ - public get otherContext() { - return this._options.other; - } - - /** - * Gets the user associated with the client. - * - * @returns The user or `undefined` if it is not set. - **/ public get user() { - return this._options.user; + return this.options.user; } - - /** - * Gets the company associated with the client. - * - * @returns The company or `undefined` if it is not set. - **/ public get company() { - return this._options.company; + return this.options.company; } - - /** - * Get flags for the user/company/other context bound to this client. - * Meant for use in serialization of flags for transferring to the client-side/browser. - * - * @returns Flags for the given user/company and whether each one is enabled or not - */ - public getFlags(): TypedFlags { - return this._client.getFlags(this._options); - } - - /** - * Get raw flags for the user/company/other context bound to this client without wrapping them in getters. - * This method returns raw flag data suitable for bootstrapping client-side applications. - * - * @returns Raw flags for the given user/company and whether each one is enabled or not - */ - public getFlagsForBootstrap(): BootstrappedFlags { - return this._client.getFlagsForBootstrap({ ...this._options }); - } - - /** - * Get a specific flag for the user/company/other context bound to this client. - * Using the `isEnabled` property sends a `check` event to Reflag. - * - * @param key - The key of the flag to get. - * - * @returns Flags for the given user/company and whether each one is enabled or not - */ - public getFlag(key: TKey): TypedFlags[TKey] { - return this._client.getFlag(this._options, key); + public get otherContext() { + return this.options.other; + } + public getFlag( + key: Key, + defaultValue?: undefined, + ): boolean; + public getFlag( + key: Key, + defaultValue: Default, + ): FlagValueFromDefault; + public getFlag( + key: TypedFlagKey, + defaultValue: FlagDefaultValue = false, + ): FlagValue { + return this.client.getFlag(this.options, key, defaultValue); + } + public getFlags() { + return this.client.getFlags(this.options); + } + public getFlagsForBootstrap() { + return this.client.getFlagsForBootstrap(this.options); } - - /** - * Get remotely evaluated flag for the user/company/other context bound to this client. - * - * @returns Flags for the given user/company and whether each one is enabled or not - */ public async getFlagsRemote() { - const { enableTracking: _, meta: __, ...context } = this._options; - return await this._client.getFlagsRemote(undefined, undefined, context); + return this.client.getFlagsRemote(undefined, undefined, this.options); + } + public getFlagRemote( + key: Key, + defaultValue?: undefined, + ): Promise; + public getFlagRemote< + Key extends TypedFlagKey, + Default extends FlagDefaultValue, + >(key: Key, defaultValue: Default): Promise>; + public getFlagRemote( + key: TypedFlagKey, + defaultValue: FlagDefaultValue = false, + ): Promise { + return this.client.getFlagRemote( + key, + defaultValue, + undefined, + undefined, + this.options, + ); } - - /** - * Get remotely evaluated flag for the user/company/other context bound to this client. - * - * @param key - The key of the flag to get. - * - * @returns Flag for the given user/company and key and whether it's enabled or not - */ - public async getFlagRemote(key: string) { - const { enableTracking: _, meta: __, ...context } = this._options; - return await this._client.getFlagRemote(key, undefined, undefined, context); + public bindClient(next: ContextWithTracking) { + return new BoundReflagClient(this.client, { + ...this.options, + ...next, + enableTracking: next.enableTracking ?? this.options.enableTracking, + meta: next.meta ?? this.options.meta, + user: + next.user === undefined ? this.user : { ...this.user, ...next.user }, + company: + next.company === undefined + ? this.company + : { ...this.company, ...next.company }, + other: + next.other === undefined + ? this.otherContext + : { ...this.otherContext, ...next.other }, + }); } - - /** - * Track an event in Reflag. - * - * @param event - The event to track. - * @param options - The options for the event. - * @param options.attributes - The attributes of the event (optional). - * @param options.meta - The meta context associated with tracking (optional). - * @param options.companyId - Optional company ID for the event (optional). - * - * @throws An error if the event is invalid or the options are invalid. - */ public async track( event: string, - options?: TrackOptions & { companyId?: string }, + options?: TrackOptions & { companyId?: IdType }, ) { - ok(options === undefined || isObject(options), "options must be an object"); - checkMeta(options?.meta); - - const userId = this._options.user?.id; - - if (!userId) { - this._client.logger?.warn("no user set, cannot track event"); - return; - } - - if (!this._options.enableTracking) { - this._client.logger?.debug( - "tracking disabled for this bound client, not tracking event", - ); + if (this.user?.id === undefined) { + this.client.logger.warn("no user set, cannot track event"); return; } - - await this._client.track( - userId, - event, - options?.companyId - ? options - : { ...options, companyId: this._options.company?.id }, - ); - } - - /** - * Create a new client bound with the additional context. - * Note: This performs a shallow merge for user/company/other individually. - * - * @param context - The context to bind the client to. - * @param context.user - The user to bind the client to. - * @param context.company - The company to bind the client to. - * @param context.other - The other context to bind the client to. - * @param context.enableTracking - Whether to enable tracking for the client. - * @param context.meta - The meta context to bind the client to. - * - * @returns new client bound with the additional context - */ - public bindClient({ - user, - company, - other, - enableTracking, - meta, - }: ContextWithTracking) { - // merge new context into existing - const boundConfig = { - ...this._options, - user: - user === undefined - ? this._options.user - : { ...this._options.user, ...user }, - company: - company === undefined - ? this._options.company - : { ...this._options.company, ...company }, - other: - other === undefined - ? this._options.other - : { ...this._options.other, ...other }, - enableTracking: enableTracking ?? this._options.enableTracking, - meta: meta ?? this._options.meta, - }; - - return new BoundReflagClient(this._client, boundConfig); + if (this.options.enableTracking === false) return; + await this.client.track(this.user.id, event, { + companyId: this.company?.id, + ...options, + }); } - - /** - * Flushes the batch buffer. - */ public async flush() { - await this._client.flush(); + await this.client.flush(); } - - /** - * Refreshes the flag definitions from the server. - * - * Fetch starts are throttled to at most once per second. Outside - * `in-request` mode, a call to `refreshFlags(waitForVersion)` still waits - * until the cache has applied that version or newer. - * - * In `in-request` mode, a throttled call only records pending refresh work. - * The fetch then runs on the next request/access or `refreshFlags()` call - * after the throttle window expires. - * - * @param waitForVersion - Optional flag state version to wait for before returning updated definitions. - */ public async refreshFlags(waitForVersion?: number) { - await this._client.refreshFlags(waitForVersion); + await this.client.refreshFlags(waitForVersion); } } - -function checkMeta( - meta?: TrackingMeta, -): asserts meta is TrackingMeta | undefined { +function checkTrackOptions(options?: TrackOptions) { + ok(options === undefined || isObject(options), "options must be an object"); ok( - typeof meta === "undefined" || isObject(meta), - "meta must be an object if given", + options?.attributes === undefined || isObject(options.attributes), + "attributes must be an object", ); ok( - meta?.active === undefined || typeof meta?.active === "boolean", - "meta.active must be a boolean if given", - ); -} - -function checkContext(context: Context): asserts context is Context { - ok(isObject(context), "context must be an object"); - ok( - typeof context.user === "undefined" || isObject(context.user), - "user must be an object if given", + options?.meta === undefined || isObject(options.meta), + "meta must be an object", ); - if (typeof context.user?.id !== "undefined") { - idOk(context.user.id, "user.id"); - } - ok( - typeof context.company === "undefined" || isObject(context.company), - "company must be an object if given", + options?.meta?.active === undefined || + typeof options.meta.active === "boolean", + "meta.active must be a boolean", ); - if (typeof context.company?.id !== "undefined") { - idOk(context.company.id, "company.id"); +} +function checkContext(context: ContextWithTracking) { + ok(isObject(context), "context must be an object"); + for (const name of ["user", "company"] as const) { + ok( + context[name] === undefined || isObject(context[name]), + `${name} must be an object`, + ); + if (context[name]?.id !== undefined) idOk(context[name].id, `${name}.id`); } - ok( context.other === undefined || isObject(context.other), - "other must be an object if given", + "other must be an object", ); -} - -function checkContextWithTracking( - context: ContextWithTracking, -): asserts context is ContextWithTracking & { enableTracking: boolean } { - checkContext(context); - ok( - typeof context.enableTracking === "boolean", + context.enableTracking === undefined || + typeof context.enableTracking === "boolean", "enableTracking must be a boolean", ); - - checkMeta(context.meta); + checkTrackOptions({ meta: context.meta }); } diff --git a/packages/node-sdk/src/config.ts b/packages/node-sdk/src/config.ts index e549274c1..b0ab3ccf2 100644 --- a/packages/node-sdk/src/config.ts +++ b/packages/node-sdk/src/config.ts @@ -1,6 +1,7 @@ import { readFileSync } from "fs"; import { version } from "../package.json"; +import { isFlagValue } from "./flag-definitions"; import { LOG_LEVELS } from "./types"; import { isObject, ok } from "./utils"; @@ -24,26 +25,7 @@ function parseOverrides(config: object | undefined) { if (!config) return {}; if ("flagOverrides" in config && isObject(config.flagOverrides)) { Object.entries(config.flagOverrides).forEach(([key, value]) => { - ok( - typeof value === "boolean" || isObject(value), - `invalid type "${typeof value}" for key ${key}, expected boolean or object`, - ); - if (isObject(value)) { - ok( - "isEnabled" in value && typeof value.isEnabled === "boolean", - `invalid type "${typeof value.isEnabled}" for key ${key}.isEnabled, expected boolean`, - ); - ok( - value.config === undefined || isObject(value.config), - `invalid type "${typeof value.config}" for key ${key}.config, expected object or undefined`, - ); - if (isObject(value.config)) { - ok( - "key" in value.config && typeof value.config.key === "string", - `invalid type "${typeof value.config.key}" for key ${key}.config.key, expected string`, - ); - } - } + ok(isFlagValue(value), `invalid JSON flag override for ${key}`); }); return config.flagOverrides; diff --git a/packages/node-sdk/src/flag-defaults.ts b/packages/node-sdk/src/flag-defaults.ts new file mode 100644 index 000000000..9669111e8 --- /dev/null +++ b/packages/node-sdk/src/flag-defaults.ts @@ -0,0 +1,32 @@ +import { isFlagValue } from "./flag-definitions"; +import type { FlagValue } from "./types"; +import { ok } from "./utils"; + +export function checkFlagDefault(value: unknown): asserts value is FlagValue { + ok(isFlagValue(value), "defaultValue must be a JSON value"); +} + +export function flagValueType(value: FlagValue): string { + return value === null + ? "null" + : Array.isArray(value) + ? "array" + : typeof value; +} + +/** Null is an explicit arbitrary-JSON read; other defaults require the same category. */ +export function applyFlagDefault( + value: FlagValue | undefined, + defaultValue: FlagValue, +) { + const typeMismatch = + value !== undefined && + defaultValue !== null && + flagValueType(value) !== flagValueType(defaultValue); + const usedDefault = value === undefined || typeMismatch; + return { + value: usedDefault ? defaultValue : value, + usedDefault, + typeMismatch, + }; +} diff --git a/packages/node-sdk/src/flag-definitions.ts b/packages/node-sdk/src/flag-definitions.ts new file mode 100644 index 000000000..ee469ee75 --- /dev/null +++ b/packages/node-sdk/src/flag-definitions.ts @@ -0,0 +1,208 @@ +import { + newEvaluator, + type RuleFilter, + type RuleResult, +} from "@reflag/flag-evaluation"; + +import type { CachedFlagCollection, FlagCollection, FlagValue } from "./types"; +import { isObject, ok } from "./utils"; + +export function isFlagValue( + value: unknown, + ancestors = new Set(), +): value is FlagValue { + if (value === null || typeof value === "string" || typeof value === "boolean") + return true; + if (typeof value === "number") return Number.isFinite(value); + if (typeof value !== "object" || ancestors.has(value)) return false; + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) + return false; + ancestors.add(value); + const valid = Object.values(value).every((entry) => + isFlagValue(entry, ancestors), + ); + ancestors.delete(value); + return valid; +} + +const operators = new Set([ + "IS", + "IS_NOT", + "ANY_OF", + "NOT_ANY_OF", + "CONTAINS", + "NOT_CONTAINS", + "GT", + "LT", + "AFTER", + "BEFORE", + "DATE_AFTER", + "DATE_BEFORE", + "SET", + "NOT_SET", + "IS_TRUE", + "IS_FALSE", +]); +function isFilter(value: unknown): value is RuleFilter { + if (!isObject(value)) return false; + switch (value.type) { + case "constant": + return typeof value.value === "boolean"; + case "context": + return ( + typeof value.field === "string" && + operators.has(value.operator) && + (value.values === undefined || + (Array.isArray(value.values) && + value.values.every((v: unknown) => typeof v === "string"))) + ); + case "rolloutPercentage": + return ( + typeof value.key === "string" && + typeof value.partialRolloutAttribute === "string" && + Number.isInteger(value.partialRolloutThreshold) && + value.partialRolloutThreshold >= 0 && + value.partialRolloutThreshold <= 100000 + ); + case "flag": + return ( + typeof value.key === "string" && typeof value.variantKey === "string" + ); + case "segment": + return typeof value.key === "string"; + case "negation": + return isFilter(value.filter); + case "group": + return ( + (value.operator === "and" || value.operator === "or") && + Array.isArray(value.filters) && + value.filters.every(isFilter) + ); + default: + return false; + } +} +function isResult( + value: unknown, + variants: Record, +): value is RuleResult { + if (!isObject(value)) return false; + if (value.type === "variant") + return ( + typeof value.variantKey === "string" && + Object.prototype.hasOwnProperty.call(variants, value.variantKey) + ); + if ( + value.type !== "percentageDistribution" || + typeof value.key !== "string" || + !value.key || + typeof value.attribute !== "string" || + !value.attribute || + !Array.isArray(value.allocations) || + !value.allocations.length + ) + return false; + return ( + value.allocations.every( + (a: unknown) => + isObject(a) && + Number.isInteger(a.percentage) && + a.percentage >= 0 && + a.percentage <= 100 && + isObject(a.destination) && + (a.destination.type === "nextRule" || + (a.destination.type === "variant" && + isResult(a.destination, variants))), + ) && + value.allocations.reduce( + (sum: number, a: { percentage: number }) => sum + a.percentage, + 0, + ) === 100 + ); +} + +/** Validate and prepare the entire graph before replacing any cached snapshot. */ +export function prepareFlagCollection( + value: unknown, + trackSelections = true, +): CachedFlagCollection { + ok( + isObject(value) && + Array.isArray(value.flags) && + Array.isArray(value.segments), + "expected a complete flags/segments collection", + ); + for (const flag of value.flags) { + ok( + isObject(flag) && + typeof flag.key === "string" && + flag.key.length > 0 && + typeof flag.sourceVersionId === "string" && + flag.sourceVersionId.length > 0 && + isObject(flag.variants) && + isFlagValue(flag.variants) && + Object.keys(flag.variants).length > 0 && + Object.keys(flag.variants).every((key) => key.length > 0) && + Array.isArray(flag.rules) && + flag.rules.length > 0, + "invalid compiled flag", + ); + const ids = new Set(); + for (const rule of flag.rules) { + ok( + isObject(rule) && + typeof rule.id === "string" && + rule.id.length > 0 && + !ids.has(rule.id) && + isFilter(rule.filter) && + isResult(rule.result, flag.variants as Record), + "invalid compiled rule", + ); + ids.add(rule.id); + } + const last = flag.rules[flag.rules.length - 1]; + ok( + last.filter.type === "constant" && + last.filter.value === true && + last.result.type === "variant", + "compiled flag requires a fixed catch-all", + ); + } + for (const segment of value.segments) + ok( + isObject(segment) && + typeof segment.key === "string" && + isFilter(segment.filter), + "invalid compiled segment", + ); + const collection = structuredClone({ + flags: value.flags.map( + ({ + key, + sourceVersionId, + variants, + rules, + }: FlagCollection["flags"][number]) => ({ + key, + sourceVersionId, + variants, + rules, + }), + ), + segments: value.segments.map( + ({ key, filter }: FlagCollection["segments"][number]) => ({ + key, + filter, + }), + ), + }) as FlagCollection; + return { + ...collection, + evaluator: newEvaluator(collection), + trackSelections, + }; +} diff --git a/packages/node-sdk/src/flags-cache.ts b/packages/node-sdk/src/flags-cache.ts index 88619581e..7823bff4d 100644 --- a/packages/node-sdk/src/flags-cache.ts +++ b/packages/node-sdk/src/flags-cache.ts @@ -1,9 +1,10 @@ -import type { CachedFlagDefinition, Logger } from "./types"; +import type { CachedFlagCollection, Logger } from "./types"; const DEFAULT_MIN_REFRESH_INTERVAL_MS = 1000; type FlagsCacheRefreshResult = { - definitions: CachedFlagDefinition[]; + definitions: CachedFlagCollection; + fallback?: boolean; flagStateVersion?: number; }; @@ -17,6 +18,7 @@ type ScheduledRefresh = { type FlagsCacheOptions = { logger?: Logger; + onApply?: (result: FlagsCacheRefreshResult) => void; minRefreshIntervalMs?: number; scheduleTrailingRefresh?: ( delayMs: number, @@ -36,7 +38,7 @@ type FlagsCacheOptions = { * keep the pending refresh queued until the next caller touches the cache. */ export class FlagsCache { - private value: CachedFlagDefinition[] | undefined; + private value: CachedFlagCollection | undefined; private flagStateVersion: number | undefined; private refreshPromise: Promise | undefined; private scheduledRefresh: ScheduledRefresh | undefined; @@ -55,7 +57,7 @@ export class FlagsCache { private readonly fetchFlags: ( waitForVersion?: number, ) => Promise, - options: FlagsCacheOptions = {}, + private readonly options: FlagsCacheOptions = {}, ) { this.logger = options.logger; this.minRefreshIntervalMs = @@ -268,6 +270,7 @@ export class FlagsCache { this.value = result.definitions; this.flagStateVersion = result.flagStateVersion; this.lastRefreshAt = Date.now(); + this.options.onApply?.(result); this.logger?.info("refreshed flag definitions"); } diff --git a/packages/node-sdk/src/flagsFallbackProvider.ts b/packages/node-sdk/src/flagsFallbackProvider.ts index 5caeb900a..8ad3d55dd 100644 --- a/packages/node-sdk/src/flagsFallbackProvider.ts +++ b/packages/node-sdk/src/flagsFallbackProvider.ts @@ -2,7 +2,8 @@ import { promises as fs } from "fs"; import path from "path"; import type { - FlagAPIResponse, + FlagDefinition, + FlagValue, FlagsFallbackProvider, FlagsFallbackProviderContext, FlagsFallbackSnapshot, @@ -133,11 +134,11 @@ export type StaticFallbackProviderOptions = { /** * Static fallback flags keyed by flag key. */ - flags: Record; + flags: Record; }; function defaultSnapshotName(secretKeyHash: string) { - return `flags-fallback-${secretKeyHash.slice(0, 16)}.json`; + return `flags-fallback-v2-${secretKeyHash.slice(0, 16)}.json`; } function snapshotFilePath( @@ -166,7 +167,7 @@ function snapshotRedisKey( context: FlagsFallbackProviderContext, keyPrefix = "reflag:flags-fallback", ) { - return `${trimTrailingCharacter(keyPrefix, ":")}:${context.secretKeyHash.slice(0, 16)}`; + return `${trimTrailingCharacter(keyPrefix, ":")}:v2:${context.secretKeyHash.slice(0, 16)}`; } export function isFlagsFallbackSnapshot( @@ -174,25 +175,15 @@ export function isFlagsFallbackSnapshot( ): value is FlagsFallbackSnapshot { return ( isObject(value) && - value.version === 1 && + value.version === 2 && typeof value.savedAt === "string" && + Number.isFinite(Date.parse(value.savedAt)) && + (value.source === undefined || value.source === "static") && + (value.flagStateVersion === undefined || + (Number.isSafeInteger(value.flagStateVersion) && + value.flagStateVersion >= 0)) && Array.isArray(value.flags) && - value.flags.every(isFlagApiResponse) - ); -} - -function isFlagApiResponse(value: unknown): value is FlagAPIResponse { - return ( - isObject(value) && - typeof value.key === "string" && - typeof value.description !== "undefined" && - isObject(value.targeting) && - typeof value.targeting.version === "number" && - Array.isArray(value.targeting.rules) && - (value.config === undefined || - (isObject(value.config) && - typeof value.config.version === "number" && - Array.isArray(value.config.variants))) + Array.isArray(value.segments) ); } @@ -230,24 +221,18 @@ function isNotFoundError(error: any) { ); } -function staticFlagApiResponse( - key: string, - isEnabled: boolean, -): FlagAPIResponse { +function staticFlagDefinition(key: string, value: FlagValue): FlagDefinition { return { key, - description: null, - targeting: { - version: 1, - rules: [ - { - filter: { - type: "constant", - value: isEnabled, - }, - }, - ], - }, + sourceVersionId: `static:${key}`, + variants: { default: value }, + rules: [ + { + id: "default", + filter: { type: "constant", value: true }, + result: { type: "variant", variantKey: "default" }, + }, + ], }; } @@ -371,11 +356,13 @@ export function createStaticFallbackProvider({ return { async load() { return { - version: 1, + version: 2, + source: "static", savedAt: new Date().toISOString(), - flags: Object.entries(flags).map(([key, isEnabled]) => - staticFlagApiResponse(key, isEnabled), + flags: Object.entries(flags).map(([key, value]) => + staticFlagDefinition(key, value), ), + segments: [], }; }, diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index e6bcd752c..ad75d01b7 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -7,7 +7,7 @@ import { } from "./flagsFallbackProvider"; export { BoundReflagClient, ReflagClient } from "./client"; -export { EdgeClient, EdgeClientOptions } from "./edgeClient"; +export { EdgeClient, type EdgeClientOptions } from "./edgeClient"; export type { FileFallbackProviderOptions, GCSFallbackProviderClient, @@ -18,7 +18,6 @@ export type { S3FallbackProviderOptions, StaticFallbackProviderOptions, } from "./flagsFallbackProvider"; - export const fallbackProviders = { static: createStaticFallbackProvider, file: createFileFallbackProvider, @@ -34,30 +33,26 @@ export type { ClientOptions, Context, ContextWithTracking, - EmptyFlagRemoteConfig, - Flag, - FlagAPIResponse, - FlagConfigVariant, + FlagCollection, FlagDefinition, - FlagOverride, + FlagDefaultValue, + FlagEvent, FlagOverrides, FlagOverridesFn, - FlagRemoteConfig, Flags, FlagsAPIResponse, FlagsFallbackProvider, FlagsFallbackProviderContext, FlagsFallbackSnapshot, FlagsSyncMode, - FlagType, + FlagValue, + FlagValueFromDefault, HttpClient, HttpClientResponse, IdType, - LOG_LEVELS, Logger, LogLevel, RawFlag, - RawFlagRemoteConfig, RawFlags, TrackingMeta, TrackOptions, diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 5701c027d..4066ca11b 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -1,851 +1,213 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ - -import { +import type { + CompiledCollection, + CompiledFlag, EvaluationError, - newEvaluator, - RuleFilter, -} from "@reflag/flag-evaluation-v1"; - -/** - * Describes the meta context associated with tracking. - **/ -export type TrackingMeta = { - /** - * Whether the user or company is active. - **/ - active?: boolean; -}; + Evaluator, + FlagEvaluationResult, +} from "@reflag/flag-evaluation"; + +export type FlagValue = + | null + | boolean + | number + | string + | FlagValue[] + | { [key: string]: FlagValue }; +/** JSON defaults may be readonly; returned values are independent mutable copies. */ +export type FlagDefaultValue = + | null + | boolean + | number + | string + | readonly FlagDefaultValue[] + | { readonly [key: string]: FlagDefaultValue }; + +/** Infer values from defaults, widening literals. Null explicitly requests arbitrary JSON. */ +export type FlagValueFromDefault = T extends null + ? FlagValue + : T extends boolean + ? boolean + : T extends number + ? number + : T extends string + ? string + : T extends readonly (infer Element)[] + ? [Element] extends [never] + ? FlagValue[] + : FlagValueFromDefault[] + : T extends object + ? { -readonly [Key in keyof T]: FlagValueFromDefault } + : never; -/** - * Describes the attributes of a user, company or event. - **/ export type Attributes = Record; +export type TrackingMeta = { active?: boolean }; +export type IdType = string | number; -/** - * Describes a flag event. Can be "check" or "check-config event". - **/ -export type FlagEvent = { - /** - * The action that was performed. - **/ - action: "check" | "check-config"; - - /** - * The flag key. - **/ - key: string; - - /** - * The feature targeting version (optional). - **/ - targetingVersion: number | undefined; - - /** - * The result of targeting evaluation. - **/ - evalResult: - | boolean - | { key: string; payload: any } - | { key: undefined; payload: undefined }; - - /** - * The context that was used for evaluation. - **/ - evalContext?: Record; - - /** - * The result of evaluation of each rule (optional). - **/ - evalRuleResults?: boolean[]; +/** Augment this interface with flag keys and their returned value types. */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface Flags {} +export type TypedFlagKey = keyof Flags extends never + ? string + : Extract; +export type TypedFlags = keyof Flags extends never + ? Record + : { [Key in keyof Flags]: Flags[Key] | undefined }; +export type FlagOverrides = Partial< + keyof Flags extends never + ? Record + : { [Key in keyof Flags]: Flags[Key] } +>; +export type FlagOverridesFn = (context: Context) => FlagOverrides; - /** - * The missing fields in the evaluation context (optional). - **/ - evalMissingFields?: string[]; +export type FlagDefinition = CompiledFlag; +export type FlagCollection = CompiledCollection & { + segments: NonNullable; }; - -/** - * A remotely managed configuration value for a feature. - */ -export type RawFlagRemoteConfig = { - /** - * The key of the matched configuration value. - */ - key: string; - - /** - * The version of the targeting rules used to select the config value. - */ - targetingVersion?: number; - - /** - * The optional user-supplied payload data. - */ - payload: any; - - /** - * The rule results of the evaluation (optional). - */ - ruleEvaluationResults?: boolean[]; - - /** - * The missing fields in the evaluation context (optional). - * @deprecated Use `evaluationErrors` and check for `MISSING_CONTEXT_FIELD`. - */ - missingContextFields?: string[]; - - /** - * Non-fatal diagnostics produced while evaluating targeting rules. - */ - evaluationErrors?: EvaluationError[]; +export type FlagsAPIResponse = FlagCollection & { + protocolVersion: 2; + flagStateVersion: number; }; - -/** - * Describes a feature. - */ -export interface RawFlag { - /** - * The key of the feature. - */ - key: string; - - /** - * If the feature is enabled. - */ - isEnabled: boolean; - - /** - * The version of the targeting used to evaluate if the feature is enabled (optional). - */ - targetingVersion?: number; - - /** - * The remote configuration value for the feature. - */ - config?: RawFlagRemoteConfig; - - /** - * The rule results of the evaluation (optional). - */ - ruleEvaluationResults?: boolean[]; - - /** - * The missing fields in the evaluation context (optional). - * @deprecated Use `evaluationErrors` and check for `MISSING_CONTEXT_FIELD`. - */ - missingContextFields?: string[]; - - /** - * Non-fatal diagnostics produced while evaluating targeting rules. - */ - evaluationErrors?: EvaluationError[]; -} - -/** - * Describes a collection of evaluated raw flags. - */ -export type RawFlags = Record; - -/** - * Describes a collection of evaluated raw flags and the context for bootstrapping. - */ +export type CachedFlagCollection = FlagCollection & { + evaluator: Evaluator; + /** Static SDK values do not identify authored variants. */ + trackSelections: boolean; +}; +export type RawFlag = Omit< + FlagEvaluationResult, + "sourceVersionId" | "value" | "resolved" +> & { + sourceVersionId?: string; + value: FlagValue | undefined; + resolved: boolean; +}; +export type RawFlags = Record; export type BootstrappedFlags = { + protocolVersion: 2; context: Context; flags: RawFlags; flagStateVersion?: number; }; - -export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined }; - -/** - * A remotely managed configuration value for a feature. - */ -export type FlagRemoteConfig = - | { - /** - * The key of the matched configuration value. - */ - key: string; - - /** - * The optional user-supplied payload data. - */ - payload: any; - } - | EmptyFlagRemoteConfig; - -/** - * Describes a feature - */ -export interface Flag< - TConfig extends FlagType["config"] = EmptyFlagRemoteConfig, -> { - /** - * The key of the feature. - */ - key: string; - - /** - * If the feature is enabled. - */ - isEnabled: boolean; - - /* - * Optional user-defined configuration. - */ - config: - | ({ - key: string; - } & TConfig) - | EmptyFlagRemoteConfig; - - /** - * Track feature usage in Reflag. - */ - track(): Promise; -} - -export type FlagType = { - config?: { - payload: any; - }; -}; - -export type FlagOverride = - | (FlagType & { - isEnabled: boolean; - config?: { - key: string; - }; - }) - | boolean; - -/** - * Describes a feature definition. - */ -export type FlagDefinition = { - /** - * The key of the feature. - */ - key: string; - - /** - * Description of the feature. - */ - description: string | null; - - /** - * The targeting rules for the feature. - */ - flag: { - /** - * The version of the targeting rules. - */ - version: number; - - /** - * The targeting rules. - */ - rules: { - /** - * The filter for the rule. - */ - filter: RuleFilter; - }[]; - }; - - /** - * The remote configuration for the feature. - */ - config?: { - /** - * The version of the remote configuration. - */ - version: number; - - /** - * The variants of the remote configuration. - */ - variants: FlagConfigVariant[]; - }; -}; - -/** - * Describes a collection of evaluated features. - * - * @remarks - * You should extend the Flags interface to define the available features. - */ -export interface Flags {} - -/** - * Describes a collection of evaluated feature. - * - * @remarks - * This types falls back to a generic Record if the Flags interface - * has not been extended. - * - */ -export type TypedFlags = keyof Flags extends never - ? Record - : { - [FlagKey in keyof Flags]: Flags[FlagKey] extends FlagType - ? Flag - : Flag; - }; - -export type TypedFlagKey = keyof TypedFlags; - -/** - * Describes the feature overrides. - */ -export type FlagOverrides = Partial< - keyof Flags extends never - ? Record - : { - [FlagKey in keyof Flags]: Flags[FlagKey] extends FlagOverride - ? Flags[FlagKey] - : Exclude; - } ->; - -export type FlagOverridesFn = (context: Context) => FlagOverrides; - -/** - * Describes a remote feature config variant. - */ -export type FlagConfigVariant = { - /** - * The filter for the variant. - */ - filter: RuleFilter; - - /** - * The optional user-supplied payload data. - */ - payload: any; - - /** - * The key of the variant. - */ - key: string; +export type EvaluatedFlagsAPIResponse = { + protocolVersion: 2; + flagStateVersion: number; + remoteContextUsed: boolean; + flags: Record< + string, + Omit< + Extract, { resolved: true }>, + "flagKey" | "resolved" + > + >; }; -/** - * (Internal) Describes a specific feature in the API response. - * - * @internal - */ -export type FlagAPIResponse = { - /** - * The key of the feature. - */ +/** A check records the selected variant key, never its JSON payload. */ +export type FlagEvent = { + action: "check"; key: string; - - /** - * Description of the feature. - */ - description: string | null; - - /** - * The targeting rules for the feature. - */ - targeting: { - /** - * The version of the targeting rules. - */ - version: number; - - /** - * The targeting rules. - */ - rules: { - /** - * The filter for the rule. - */ - filter: RuleFilter; - }[]; - }; - - /** - * The remote configuration for the feature. - */ - config?: { - /** - * The version of the remote configuration. - */ - version: number; - - /** - * The variants of the remote configuration. - */ - variants: FlagConfigVariant[]; - }; -}; - -/** - * (Internal) Describes the response of the features endpoint. - * - * @internal - */ -export type FlagsAPIResponse = { - /** - * Optional for backward compatibility; when absent, the snapshot version - * should be treated as unknown by consumers. - */ + evalResult?: string; + sourceVersionId?: string; flagStateVersion?: number; - - /** - * The feature definitions. - */ - features: FlagAPIResponse[]; + evalContext?: Record; + evalRuleResults?: boolean[]; + evalMissingFields?: string[]; + evalErrors?: EvaluationError[]; }; -/** - * Snapshot of flag definitions used for fallback initialization. - */ -export type FlagsFallbackSnapshot = { - /** - * Snapshot schema version. - */ - version: number; - - /** - * ISO timestamp indicating when the snapshot was saved. - */ +/** V1 snapshots cannot be evaluated by the collection evaluator. */ +export type FlagsFallbackSnapshot = FlagCollection & { + version: 2; savedAt: string; - - /** - * Raw flag definitions as returned by the API. - */ - flags: FlagAPIResponse[]; -}; - -/** - * Non-secret context passed to fallback providers so they can derive - * storage keys without access to the raw secret key. - */ -export type FlagsFallbackProviderContext = { - /** - * Deterministic hash of the configured secret key. - */ - secretKeyHash: string; + flagStateVersion?: number; + source?: "static"; }; - -/** - * Provider used to load and save raw flag definition snapshots. - */ +export type FlagsFallbackProviderContext = { secretKeyHash: string }; export interface FlagsFallbackProvider { - /** - * Load a previously saved snapshot. - */ load( context: FlagsFallbackProviderContext, ): Promise; - - /** - * Persist a snapshot after a successful live fetch. - */ save( context: FlagsFallbackProviderContext, snapshot: FlagsFallbackSnapshot, ): Promise; } -/** - * (Internal) Flag definitions with the addition of a pre-prepared - * evaluators functions for the rules. - * - * @internal - */ -export type CachedFlagDefinition = FlagAPIResponse & { - enabledEvaluator: ReturnType>; - configEvaluator: ReturnType> | undefined; -}; - -/** - * (Internal) Describes the response of the evaluated features endpoint. - * - * @internal - */ -export type EvaluatedFlagsAPIResponse = { - /** - * True if request successful. - */ - success: true; - - /** - * True if additional context for user or company was found and used for evaluation on the remote server. - */ - remoteContextUsed: boolean; - - /** - * The feature definitions. - */ - features: RawFlags; -}; - -/** - * Describes the response of a HTTP client. - * - * @typeParam TResponse - The type of the response body. - */ -export type HttpClientResponse = { - /** - * The status code of the response. - **/ +export type HttpClientResponse = { status: number; - - /** - * Indicates that the request succeeded. - **/ ok: boolean; - - /** - * The body of the response if available. - **/ - body: TResponse | undefined; + body: Response | undefined; }; - -/** - * Defines the interface for an HTTP client. - * - * @remarks - * This interface is used to abstract the HTTP client implementation from the SDK. - * Define your own implementation of this interface to use a different HTTP client. - **/ export interface HttpClient { - /** - * Sends a POST request to the specified URL. - * - * @param url - The URL to send the request to. - * @param headers - The headers to include in the request. - * @param body - The body of the request. - * @returns The response from the server. - **/ - post( + post( url: string, headers: Record, - body: TBody, - ): Promise>; - - /** - * Sends a GET request to the specified URL. - * - * @param url - The URL to send the request to. - * @param headers - The headers to include in the request. - * @returns The response from the server. - **/ - get( + body: Body, + ): Promise>; + get( url: string, headers: Record, timeoutMs: number, - ): Promise>; + ): Promise>; } - -/** - * Logger interface for logging messages - */ export interface Logger { - /** - * Log a debug messages - * - * @param message - The message to log - * @param data - Optional data to log - */ - debug: (message: string, data?: any) => void; - - /** - * Log an info messages - * - * @param message - The message to log - * @param data - Optional data to log - */ - info: (message: string, data?: any) => void; - - /** - * Log a warning messages - * - * @param message - The message to log - * @param data - Optional data to log - */ - warn: (message: string, data?: any) => void; - - /** - * Log an error messages - * - * @param message - The message to log - * @param data - Optional data to log - */ - error: (message: string, data?: any) => void; + debug(message: string, data?: any): void; + info(message: string, data?: any): void; + warn(message: string, data?: any): void; + error(message: string, data?: any): void; } - -/** - * Options for configuring the BatchBuffer. - * - * @template T - The type of items in the buffer. - */ export type BatchBufferOptions = { - /** - * A function that handles flushing the items in the buffer. - **/ flushHandler: (items: T[]) => Promise; - - /** - * The logger to use for logging (optional). - **/ logger?: Logger; - - /** - * The maximum size of the buffer before it is flushed. - * - * @defaultValue `100` - **/ maxSize?: number; - - /** - * The interval in milliseconds at which the buffer is flushed. - * @remarks - * If `0`, the buffer is flushed only when `maxSize` is reached. - * @defaultValue `10000` - **/ intervalMs?: number; - - /** - * Whether to flush the buffer on exit. - * - * @defaultValue `true` - */ flushOnExit?: boolean; }; - export type CacheStrategy = "periodically-update" | "in-request"; - export type FlagsSyncMode = "polling" | "in-request" | "push"; - -/** - * Defines the options for the SDK client. - * - **/ export type ClientOptions = { - /** - * The secret key used to authenticate with the Reflag API. - **/ secretKey?: string; - - /** - * @deprecated - * Use `apiBaseUrl` instead. - **/ + /** @deprecated Use apiBaseUrl. */ host?: string; - - /** - * The host to send requests to (optional). - **/ apiBaseUrl?: string; - - /** - * The logger to use for logging (optional). Default is info level logging to console. - **/ logger?: Logger; - - /** - * Use the console logger, but set a log level. Ineffective if a custom logger is provided. - **/ logLevel?: LogLevel; - - /** - * The features to "enable" as fallbacks when the API is unavailable (optional). - * Can be an array of feature keys, or a record of feature keys and boolean or object values. - * - * If a record is supplied instead of array, the values of each key are either the - * configuration values or the boolean value `true`. - * - * @deprecated Use `flagsFallbackProvider` instead. - **/ - fallbackFlags?: - | TypedFlagKey[] - | Record>; - - /** - * Optional provider used to load and save raw flag definitions for fallback startup. - * Ignored in offline mode. - */ + /** Value-only startup fallbacks. Prefer a complete saved collection. */ + fallbackFlags?: TypedFlagKey[] | FlagOverrides; flagsFallbackProvider?: FlagsFallbackProvider; - - /** - * The HTTP client to use for sending requests (optional). Default is the built-in fetch client. - **/ httpClient?: HttpClient; - - /** - * The timeout in milliseconds for fetching feature targeting data (optional). - * Default is 10000 ms. - **/ fetchTimeoutMs?: number; - - /** - * Number of times to retry fetching feature definitions (optional). - * Default is 3 times. - **/ flagsFetchRetries?: number; - - /** - * The options for the batch buffer (optional). - * If not provided, the default options are used. - **/ batchOptions?: Omit, "flushHandler" | "logger">; - - /** - * Local flag overrides for testing or development. - * - * If a function is specified, the function will be called with the context - * and should return a record of flag keys and boolean or object values. - **/ - flagOverrides?: FlagOverrides | ((context: Context) => FlagOverrides); - - /** - * In offline mode, no data is sent or fetched from the the Reflag API, - * and `flagsFallbackProvider` is not used. - * This is useful for testing or development. - */ + /** Applied after evaluation; overrides do not change dependency results. */ + flagOverrides?: FlagOverrides | FlagOverridesFn; offline?: boolean; - - /** - * Deprecated: evaluation events are no longer emitted. - * - * @deprecated This option has no effect and will be removed in the next major version. - */ + /** @deprecated Evaluation events are not emitted. */ emitEvaluationEvents?: boolean; - - /** - * The path to the config file. If supplied, the config file will be loaded. - * Defaults to `reflag.config.json` when NODE_ENV is not production. Can also be - * set through the environment variable REFLAG_CONFIG_FILE. - */ configFile?: string; - - /** - * How flag definitions are synchronized. - * - * - `push` (default): live updates over SSE keep flag definitions up to date. - * - `polling`: periodic background refresh. - * - `in-request`: stale refresh is triggered during request handling. - */ flagsSyncMode?: FlagsSyncMode; - - /** - * Push endpoint used when `flagsSyncMode` is `"push"`. - * - * @defaultValue `"https://front.reflag.com/sse/server"` - */ flagsPushUrl?: string; - - /** - * @deprecated Use `flagsSyncMode`. - **/ + /** @deprecated Use flagsSyncMode. */ cacheStrategy?: CacheStrategy; }; - -/** - * Defines the options for tracking of entities. - * - **/ -export type TrackOptions = { - /** - * The attributes associated with the event. - **/ - attributes?: Attributes; - - /** - * The meta context associated with the event. - **/ - meta?: TrackingMeta; -}; - -/** - * Describes the current user context, company context, and other context. - * This is used to determine if feature targeting matches and to track events. - **/ +export type TrackOptions = { attributes?: Attributes; meta?: TrackingMeta }; export type Context = { - /** - * The user context. If no `id` key is set, the whole object is ignored. - */ user?: { - /** - * The identifier of the user. - */ - id: string | number | undefined; - - /** - * The name of the user. - */ - name?: string | undefined; - - /** - * The email of the user. - */ - email?: string | undefined; - - /** - * The avatar URL of the user. - */ - avatar?: string | undefined; - - /** - * Custom attributes of the user. - */ - [k: string]: any; + id: IdType | undefined; + name?: string; + email?: string; + avatar?: string; + [key: string]: any; }; - /** - * The company context. If no `id` key is set, the whole object is ignored. - */ company?: { - /** - * The identifier of the company. - */ - id: string | number | undefined; - - /** - * The name of the company. - */ - name?: string | undefined; - - /** - * The avatar URL of the company. - */ - avatar?: string | undefined; - - /** - * Custom attributes of the company. - */ - [k: string]: any; + id: IdType | undefined; + name?: string; + avatar?: string; + [key: string]: any; }; - - /** - * The other context. This is used for any additional context that is not related to user or company. - */ other?: Record; }; - -/** - * A context with tracking option. - **/ export interface ContextWithTracking extends Context { - /** - * Enable tracking for the context. - * If set to `false`, tracking will be disabled for the context. Default is `true`. - */ enableTracking?: boolean; - - /** - * The meta context used to update the user or company when syncing is required during - * feature retrieval. - */ meta?: TrackingMeta; } - export const LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const; export type LogLevel = (typeof LOG_LEVELS)[number]; - -export type IdType = string | number; diff --git a/packages/node-sdk/test/client-sync.test.ts b/packages/node-sdk/test/client-sync.test.ts new file mode 100644 index 000000000..58a45ec8d --- /dev/null +++ b/packages/node-sdk/test/client-sync.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ReflagClient, type ClientOptions, type HttpClient } from "../src"; +import { FLAGS_REFETCH_MS } from "../src/config"; +import { graph } from "./fixtures"; + +const sse = vi.hoisted(() => ({ open: vi.fn(), close: vi.fn() })); +vi.mock("../src/flag-updates-sse", () => ({ openFlagUpdatesSSE: sse.open })); +vi.mock("../src/flusher", () => ({ subscribe: vi.fn() })); +const clients: ReflagClient[] = []; +function harness(options: ClientOptions = {}) { + const state = { response: graph() }; + const get = vi.fn( + async ( + _url: string, + _headers: Record, + _timeout: number, + ) => ({ + ok: true, + status: 200, + body: { success: true, ...state.response }, + }), + ); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const client = new ReflagClient({ + offline: false, + secretKey: "validSecretKeyWithMoreThan22Chars", + flagsFetchRetries: 0, + flagsPushUrl: "https://sse.example.com/server?channels=not-authoritative", + apiBaseUrl: "https://api.example.com/", + httpClient: { get, post: vi.fn() } as unknown as HttpClient, + logger, + batchOptions: { intervalMs: 1e9, flushOnExit: false }, + ...options, + }); + clients.push(client); + return { client, state, get }; +} +beforeEach(() => { + vi.useFakeTimers(); + sse.open.mockReset(); + sse.close.mockReset(); + sse.open.mockImplementation(() => ({ + ready: Promise.resolve(), + close: sse.close, + })); +}); +afterEach(() => { + clients.splice(0).forEach((client) => client.destroy()); + vi.useRealTimers(); +}); + +describe("collection sync wiring", () => { + it("uses authenticated push by default and refreshes the complete graph for a notified version", async () => { + const h = harness(); + await h.client.initialize(); + expect(sse.open).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://sse.example.com/server", + headers: expect.objectContaining({ + Authorization: "Bearer validSecretKeyWithMoreThan22Chars", + }), + }), + ); + h.state.response.flagStateVersion = 2; + h.state.response.segments[0].filter = { type: "constant", value: false }; + await vi.advanceTimersByTimeAsync(1001); + sse.open.mock.calls[0][0].onFlagStateVersion(2); + await h.client.flush(); + expect(h.get).toHaveBeenLastCalledWith( + "https://api.example.com/flags?waitForVersion=2", + expect.anything(), + expect.any(Number), + ); + expect( + h.client.getFlags({ + company: { id: "acme", plan: "enterprise" }, + enableTracking: false, + }), + ).toEqual({ model: "quick", dependent: false }); + h.client.destroy(); + expect(sse.close).toHaveBeenCalledOnce(); + }); + it("polls complete snapshots without opening SSE", async () => { + const h = harness({ flagsSyncMode: "polling" }); + await h.client.initialize(); + h.state.response = { + ...graph(), + flags: [], + segments: [], + flagStateVersion: 2, + }; + await vi.advanceTimersByTimeAsync(FLAGS_REFETCH_MS); + await h.client.flush(); + expect(h.get).toHaveBeenCalledTimes(2); + expect(sse.open).not.toHaveBeenCalled(); + expect(h.client.getFlagDefinitions()).toEqual({ flags: [], segments: [] }); + }); + it("refreshes stale state on access in request-driven mode", async () => { + const h = harness({ flagsSyncMode: "in-request" }); + await h.client.initialize(); + h.state.response = { + ...graph(), + flags: [], + segments: [], + flagStateVersion: 2, + }; + await vi.advanceTimersByTimeAsync(FLAGS_REFETCH_MS + 1); + h.client.getFlags({ enableTracking: false }); + await h.client.flush(); + expect(h.get).toHaveBeenCalledTimes(2); + expect(sse.open).not.toHaveBeenCalled(); + expect(h.client.getFlags({ enableTracking: false })).toEqual({}); + }); + it.each(["periodically-update", "in-request"] as const)( + "preserves deprecated cache strategy %s", + async (cacheStrategy) => { + const h = harness({ cacheStrategy }); + await h.client.initialize(); + expect(sse.open).not.toHaveBeenCalled(); + }, + ); +}); + +describe("v2 option validation", () => { + it.each([ + { flagsFetchRetries: -1 }, + { fetchTimeoutMs: 1.5 }, + { flagsSyncMode: "invalid" }, + { flagsPushUrl: "" }, + { flagsPushUrl: "not a URL" }, + { secretKey: 123 }, + { flagOverrides: { flag: undefined } }, + { flagOverrides: null }, + { fallbackFlags: { flag: NaN } }, + { flagsFallbackProvider: { load: () => undefined } }, + { logger: "not a logger" }, + { httpClient: false }, + ])("rejects invalid options %#", (options) => { + expect( + () => + new ReflagClient({ + offline: true, + ...options, + } as unknown as ClientOptions), + ).toThrow(); + }); +}); diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 7a2e0155c..bba2fd829 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1,3697 +1,924 @@ -import { mkdtempSync, rmSync, writeFileSync } from "fs"; -import os from "os"; -import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import flushPromises from "flush-promises"; import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - test, - vi, -} from "vitest"; - -import { BoundReflagClient, fallbackProviders, ReflagClient } from "../src"; -import { - API_BASE_URL, - API_TIMEOUT_MS, - BATCH_INTERVAL_MS, - BATCH_MAX_SIZE, - FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, - FLAGS_REFETCH_MS, - SDK_VERSION, - SDK_VERSION_HEADER_NAME, -} from "../src/config"; -import fetchClient from "../src/fetch-http-client"; -import { subscribe as triggerOnExit } from "../src/flusher"; -import { newRateLimiter } from "../src/rate-limiter"; -import { - ClientOptions, - Context, - FlagsAPIResponse, - FlagsFallbackProvider, -} from "../src/types"; - -const BULK_ENDPOINT = "https://api.example.com/bulk"; - -const missingContextFieldError = (field: string) => ({ - code: "MISSING_CONTEXT_FIELD", - field, - message: `Context field "${field}" is required to evaluate targeting rules.`, -}); - -vi.mock("../src/rate-limiter", async (importOriginal) => { - const original = (await importOriginal()) as any; - - return { - ...original, - newRateLimiter: vi.fn(original.newRateLimiter), - }; -}); - -vi.mock("../src/flusher", () => ({ - subscribe: vi.fn(), -})); - -// Mock NextJS headers module for dynamic import -vi.mock("next/headers", () => ({ - cookies: vi.fn(), -})); - -const user = { - id: "user123", - age: 1, - name: "John", -}; - -const company = { - id: "company123", - employees: 100, - name: "Acme Inc.", -}; - -const event = { - event: "flag-event", - attrs: { key: "value" }, -}; - -const otherContext = { custom: "context", key: "value" }; -const logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; -const httpClient = { post: vi.fn(), get: vi.fn() }; - -const fallbackFlags = ["key"]; - -const validOptions: ClientOptions = { - secretKey: "validSecretKeyWithMoreThan22Chars", - apiBaseUrl: "https://api.example.com/", - logger, - httpClient, - fallbackFlags, - flagsFetchRetries: 2, - batchOptions: { - maxSize: 99, - intervalMs: 10001, - flushOnExit: false, - }, - flagsSyncMode: "polling", - offline: false, -}; - -const expectedHeaders = { - [SDK_VERSION_HEADER_NAME]: SDK_VERSION, - "Content-Type": "application/json", - Authorization: `Bearer ${validOptions.secretKey}`, -}; - -function createSSEMessageStream(data: object) { - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode(`event: message\ndata: ${JSON.stringify(data)}\n\n`), - ); - }, - }); -} - -function createFailingSSEStream(error = new Error("stream failed")) { - return new ReadableStream({ - start(controller) { - controller.error(error); - }, - }); -} - -function createIdleSSEStream() { - return new ReadableStream({ - start() { - // keep the stream open without sending messages - }, - }); -} - -const flagDefinitions: FlagsAPIResponse = { - flagStateVersion: 1, - features: [ - { - key: "flag1", - description: "Flag 1", - targeting: { - version: 1, - rules: [ - { - filter: { - type: "context" as const, - field: "company.id", - operator: "IS", - values: ["company123"], - }, - }, - ], - }, - config: { - version: 1, - variants: [ - { - filter: { - type: "context", - field: "company.id", - operator: "IS", - values: ["company123"], - }, - key: "config-1", - payload: { something: "else" }, - }, - ], - }, - }, - { - key: "flag2", - description: "Flag 2", - targeting: { - version: 2, - rules: [ - { - filter: { - type: "group" as const, - operator: "and", - filters: [ - { - type: "context" as const, - field: "company.id", - operator: "IS", - values: ["company123"], - }, - { - partialRolloutThreshold: 0.5, - partialRolloutAttribute: "attributeKey", - type: "rolloutPercentage" as const, - key: "flag2", - }, - ], - }, - }, - ], - }, - }, - ], -}; - -describe("ReflagClient", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("constructor", () => { - it("should initialize with no options", async () => { - const secretKeyEnv = process.env.REFLAG_SECRET_KEY; - process.env.REFLAG_SECRET_KEY = "validSecretKeyWithMoreThan22Chars"; - try { - const reflagInstance = new ReflagClient(); - expect(reflagInstance).toBeInstanceOf(ReflagClient); - } finally { - process.env.REFLAG_SECRET_KEY = secretKeyEnv; - } - }); - - it("should accept fallback flags as an array", async () => { - const reflagInstance = new ReflagClient({ - secretKey: "validSecretKeyWithMoreThan22Chars", - fallbackFlags: ["flag1", "flag2"], - }); - - expect(reflagInstance["_config"].fallbackFlags).toEqual({ - flag1: { - isEnabled: true, - key: "flag1", - }, - flag2: { - isEnabled: true, - key: "flag2", - }, - }); - }); - - it("should accept fallback flags as an object", async () => { - const reflagInstance = new ReflagClient({ - secretKey: "validSecretKeyWithMoreThan22Chars", - fallbackFlags: { - flag1: true, - flag2: { - isEnabled: true, - config: { - key: "config1", - payload: { value: true }, - }, - }, - }, - }); - - expect(reflagInstance["_config"].fallbackFlags).toStrictEqual({ - flag1: { - key: "flag1", - config: undefined, - isEnabled: true, - }, - flag2: { - key: "flag2", - isEnabled: true, - config: { - key: "config1", - payload: { value: true }, - }, - }, - }); - }); - - it("should use the REFLAG_CONFIG_FILE path when set", () => { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "reflag-node-sdk-")); - const originalConfigFile = process.env.REFLAG_CONFIG_FILE; - const customConfigFile = path.join(tempDir, "custom.reflag.config.json"); - - writeFileSync( - customConfigFile, - JSON.stringify({ apiBaseUrl: "https://custom-config.example/" }), - ); - - process.env.REFLAG_CONFIG_FILE = customConfigFile; - - try { - const client = new ReflagClient({ - offline: true, - }); - - expect(client["_config"].apiBaseUrl).toBe( - "https://custom-config.example/", - ); - } finally { - if (originalConfigFile === undefined) { - delete process.env.REFLAG_CONFIG_FILE; - } else { - process.env.REFLAG_CONFIG_FILE = originalConfigFile; - } - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it("should create a client instance with valid options", () => { - const client = new ReflagClient(validOptions); - - expect(client).toBeInstanceOf(ReflagClient); - expect(client["_config"].apiBaseUrl).toBe("https://api.example.com/"); - expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS); - expect(client.logger).toBeDefined(); - expect(client.httpClient).toBe(validOptions.httpClient); - expect(client["_config"].headers).toEqual(expectedHeaders); - expect(client["batchBuffer"]).toMatchObject({ - maxSize: 99, - intervalMs: 10001, - }); - - expect(client["_config"].fallbackFlags).toEqual({ - key: { - key: "key", - isEnabled: true, - }, - }); - expect(client["_config"].flagsFetchRetries).toBe(2); - }); - - it("should route messages to the supplied logger", () => { - const client = new ReflagClient(validOptions); - - const actualLogger = client.logger!; - actualLogger.debug("debug message"); - actualLogger.info("info message"); - actualLogger.warn("warn message"); - actualLogger.error("error message"); - - expect(logger.debug).toHaveBeenCalledWith( - expect.stringMatching("debug message"), - ); - expect(logger.info).toHaveBeenCalledWith( - expect.stringMatching("info message"), - ); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringMatching("warn message"), - ); - expect(logger.error).toHaveBeenCalledWith( - expect.stringMatching("error message"), - ); - }); - - it("should create a client instance with default values for optional fields", () => { - const client = new ReflagClient({ - secretKey: "validSecretKeyWithMoreThan22Chars", - }); - - expect(client["_config"].apiBaseUrl).toBe(API_BASE_URL); - expect(client["_config"].refetchInterval).toBe(FLAGS_REFETCH_MS); - expect(client["_config"].flagsSyncMode).toBe("push"); - expect(client["_config"].flagsPushUrl).toBe( - "https://front.reflag.com/sse/server", - ); - expect(client.httpClient).toBe(fetchClient); - expect(client["_config"].headers).toEqual(expectedHeaders); - expect(client["_config"].fallbackFlags).toBeUndefined(); - expect(client["batchBuffer"]).toMatchObject({ - maxSize: BATCH_MAX_SIZE, - intervalMs: BATCH_INTERVAL_MS, - }); - }); - - it("should strip legacy channels from custom push URLs", () => { - const client = new ReflagClient({ - secretKey: "validSecretKeyWithMoreThan22Chars", - flagsPushUrl: "https://example.com/sse?channels=legacy&foo=bar", - }); - - expect(client["_config"].flagsPushUrl).toBe( - "https://example.com/sse?foo=bar", - ); - }); - - it("should map deprecated cacheStrategy values to sync modes", () => { - const inRequestClient = new ReflagClient({ - secretKey: "validSecretKeyWithMoreThan22Chars", - cacheStrategy: "in-request", - }); - const pollingClient = new ReflagClient({ - secretKey: "validSecretKeyWithMoreThan22Chars", - cacheStrategy: "periodically-update", - }); - - expect(inRequestClient["_config"].flagsSyncMode).toBe("in-request"); - expect(pollingClient["_config"].flagsSyncMode).toBe("polling"); - }); - - it("should throw an error if options are invalid", () => { - let invalidOptions: any = null; - expect(() => new ReflagClient(invalidOptions)).toThrow( - "options must be an object", - ); - - invalidOptions = { ...validOptions, secretKey: "shortKey" }; - expect(() => new ReflagClient(invalidOptions)).toThrow( - "invalid secretKey specified", - ); - - invalidOptions = { ...validOptions, host: 123 }; - expect(() => new ReflagClient(invalidOptions)).toThrow( - "host must be a string", - ); - - invalidOptions = { - ...validOptions, - logger: "invalidLogger" as any, - }; - expect(() => new ReflagClient(invalidOptions)).toThrow( - "logger must be an object", - ); - - invalidOptions = { - ...validOptions, - httpClient: "invalidHttpClient" as any, - }; - expect(() => new ReflagClient(invalidOptions)).toThrow( - "httpClient must be an object", - ); - - invalidOptions = { - ...validOptions, - batchOptions: "invalid" as any, - }; - expect(() => new ReflagClient(invalidOptions)).toThrow( - "batchOptions must be an object", - ); - - invalidOptions = { - ...validOptions, - fallbackFlags: "invalid" as any, - }; - expect(() => new ReflagClient(invalidOptions)).toThrow( - "fallbackFlags must be an array or object", - ); - - invalidOptions = { - ...validOptions, - flagsFallbackProvider: {} as any, - }; - expect(() => new ReflagClient(invalidOptions)).toThrow( - "flagsFallbackProvider must be an object with load/save functions", - ); - }); - - it("should create a new flag events rate-limiter", () => { - const client = new ReflagClient(validOptions); - - expect(client["rateLimiter"]).toBeDefined(); - expect(newRateLimiter).toHaveBeenCalledWith( - FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, - ); - }); - - it("should not register an exit flush handler if `batchOptions.flushOnExit` is false", () => { - new ReflagClient({ - ...validOptions, - batchOptions: { ...validOptions.batchOptions, flushOnExit: false }, - }); - - expect(triggerOnExit).not.toHaveBeenCalled(); - }); - - it("should not register an exit flush handler if `offline` is true", () => { - new ReflagClient({ - ...validOptions, - offline: true, - }); - - expect(triggerOnExit).not.toHaveBeenCalled(); - }); - - it.each([undefined, true])( - "should register an exit flush handler if `batchOptions.flushOnExit` is `%s`", - (flushOnExit) => { - new ReflagClient({ - ...validOptions, - batchOptions: { ...validOptions.batchOptions, flushOnExit }, - }); - - expect(triggerOnExit).toHaveBeenCalledWith(expect.any(Function)); - }, - ); - - it.each([ - ["https://api.example.com", "https://api.example.com/bulk"], - ["https://api.example.com/", "https://api.example.com/bulk"], - ["https://api.example.com/path", "https://api.example.com/path/bulk"], - ["https://api.example.com/path/", "https://api.example.com/path/bulk"], - ])( - "should build the URLs correctly %s -> %s", - async (apiBaseUrl, expectedUrl) => { - const client = new ReflagClient({ - ...validOptions, - apiBaseUrl, - }); - - await client.updateUser("user_id"); - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledWith( - expectedUrl, - expect.any(Object), - expect.any(Object), - ); - }, - ); - }); - - describe("bindClient", () => { - const client = new ReflagClient(validOptions); - const context = { - user, - company, - other: otherContext, - }; - - beforeEach(() => { - vi.mocked(httpClient.post).mockResolvedValue({ body: { success: true } }); - client["rateLimiter"].clearStale(true); - }); - - it("should return a new client instance with the `user`, `company` and `other` set", async () => { - const newClient = client.bindClient(context); - await client.flush(); - - expect(newClient.user).toEqual(user); - expect(newClient.company).toEqual(company); - expect(newClient.otherContext).toEqual(otherContext); - - expect(newClient).toBeInstanceOf(BoundReflagClient); - expect(newClient).not.toBe(client); // Ensure a new instance is returned - expect(newClient["_options"]).toEqual({ - enableTracking: true, - ...context, - }); - }); - - it("should update user in Reflag when called", async () => { - client.bindClient({ user: context.user }); - await client.flush(); - - const { id: _, ...attributes } = context.user; - - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - { - type: "user", - userId: user.id, - attributes: attributes, - context: undefined, - }, - ], - ); - - expect(httpClient.post).toHaveBeenCalledOnce(); - }); - - it("should update company in Reflag when called", async () => { - client.bindClient({ company: context.company, meta: { active: true } }); - await client.flush(); - - const { id: _, ...attributes } = context.company; - - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - { - type: "company", - companyId: company.id, - attributes: attributes, - context: { - active: true, - }, - }, - ], - ); - - expect(httpClient.post).toHaveBeenCalledOnce(); - }); - - it("should not update `company` or `user` in Reflag when `enableTracking` is `false`", async () => { - client.bindClient({ - user: context.user, - company: context.company, - enableTracking: false, - }); - - await client.flush(); - - expect(httpClient.post).not.toHaveBeenCalled(); - }); - - it("should throw an error if `user` is invalid", () => { - expect(() => - client.bindClient({ user: "bad_attributes" as any }), - ).toThrow("validation failed: user must be an object if given"); - expect(() => client.bindClient({ user: { id: {} as any } })).toThrow( - "validation failed: user.id must be a string or number if given", - ); - }); - - it("should throw an error if `company` is invalid", () => { - expect(() => - client.bindClient({ company: "bad_attributes" as any }), - ).toThrow("validation failed: company must be an object if given"); - expect(() => client.bindClient({ company: { id: {} as any } })).toThrow( - "validation failed: company.id must be a string or number if given", - ); - }); - - it("should throw an error if `other` is invalid", () => { - expect(() => - client.bindClient({ other: "bad_attributes" as any }), - ).toThrow("validation failed: other must be an object"); - }); - - it("should throw an error if `enableTracking` is invalid", () => { - expect(() => - client.bindClient({ enableTracking: "bad_attributes" as any }), - ).toThrow("validation failed: enableTracking must be a boolean"); - }); - - it("should allow context without id", () => { - const c = client.bindClient({ - user: { id: undefined, name: "userName" }, - company: { id: undefined, name: "companyName" }, - }); - expect(c.user?.id).toBeUndefined(); - expect(c.company?.id).toBeUndefined(); - }); - }); - - describe("updateUser", () => { - const client = new ReflagClient(validOptions); - - beforeEach(() => { - client["rateLimiter"].clearStale(true); - }); - - // try with both string and number IDs - test.each([ - { id: "user123", age: 1, name: "John" }, - { id: 42, age: 1, name: "John" }, - ])("should successfully update the user", async (testUser) => { - const response = { status: 200, body: { success: true } }; - httpClient.post.mockResolvedValue(response); - - await client.updateUser(testUser.id, { - attributes: { age: 2, brave: false }, - meta: { - active: true, - }, - }); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - { - type: "user", - userId: testUser.id, - attributes: { age: 2, brave: false }, - context: { active: true }, - }, - ], - ); - - expect(logger.debug).toHaveBeenCalledWith( - expect.stringMatching("post request to "), - response, - ); - }); - - it("should log an error if the post request throws", async () => { - const error = new Error("Network error"); - httpClient.post.mockRejectedValue(error); - - await client.updateUser(user.id); - await client.flush(); - - expect(logger.warn).toHaveBeenCalledWith( - "flush of buffered items failed; discarding items", - expect.objectContaining({ - count: 1, - error: expect.objectContaining({ - message: "failed to send bulk events", - }), - }), - ); - }); - - it("should log if API call returns false", async () => { - const response = { status: 200, body: { success: false } }; - - httpClient.post.mockResolvedValue(response); - - await client.updateUser(user.id); - await client.flush(); - - expect(logger.warn).toHaveBeenCalledWith( - "flush of buffered items failed; discarding items", - expect.objectContaining({ - count: 1, - error: expect.objectContaining({ - message: "failed to send bulk events", - }), - }), - ); - expect(logger.error).not.toHaveBeenCalled(); - }); - - it("should throw an error if opts are not valid or the user is not set", async () => { - await expect( - client.updateUser(user.id, "bad_opts" as any), - ).rejects.toThrow("validation failed: options must be an object"); - - await expect( - client.updateUser(user.id, { attributes: "bad_attributes" as any }), - ).rejects.toThrow("attributes must be an object"); - - await expect( - client.updateUser(user.id, { meta: "bad_meta" as any }), - ).rejects.toThrow("meta must be an object"); - }); - }); - - describe("updateCompany", () => { - const client = new ReflagClient(validOptions); - - beforeEach(() => { - client["rateLimiter"].clearStale(true); - }); - - test.each([ - { - id: "company123", - employees: 100, - name: "Acme Inc.", - userId: "user123", - }, - { id: 42, employees: 100, name: "Acme Inc.", userId: 42 }, - ])(`should successfully update the company`, async (testCompany) => { - const response = { status: 200, body: { success: true } }; - httpClient.post.mockResolvedValue(response); - - await client.updateCompany(testCompany.id, { - attributes: { employees: 200, bankrupt: false }, - meta: { active: true }, - userId: testCompany.userId, - }); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - { - type: "company", - companyId: testCompany.id, - attributes: { employees: 200, bankrupt: false }, - context: { active: true }, - userId: testCompany.userId, - }, - ], - ); - - expect(logger.debug).toHaveBeenCalledWith( - expect.stringMatching("post request to .*"), - response, - ); - }); - - it("should log an error if the post request throws", async () => { - const error = new Error("Network error"); - httpClient.post.mockRejectedValue(error); - - await client.updateCompany(company.id, {}); - await client.flush(); - - expect(logger.warn).toHaveBeenCalledWith( - "flush of buffered items failed; discarding items", - expect.objectContaining({ - count: 1, - error: expect.objectContaining({ - message: "failed to send bulk events", - }), - }), - ); - }); - - it("should log an error if API responds with success: false", async () => { - const response = { - status: 200, - body: { success: false }, - }; - - httpClient.post.mockResolvedValue(response); - - await client.updateCompany(company.id, {}); - await client.flush(); - - expect(logger.warn).toHaveBeenCalledWith( - "flush of buffered items failed; discarding items", - expect.objectContaining({ - count: 1, - error: expect.objectContaining({ - message: "failed to send bulk events", - }), - }), - ); - expect(logger.error).not.toHaveBeenCalled(); - }); - - it("should throw an error if company is not valid", async () => { - await expect( - client.updateCompany(company.id, "bad_opts" as any), - ).rejects.toThrow("validation failed: options must be an object"); - - await expect( - client.updateCompany(company.id, { - attributes: "bad_attributes" as any, - }), - ).rejects.toThrow("attributes must be an object"); - - await expect( - client.updateCompany(company.id, { meta: "bad_meta" as any }), - ).rejects.toThrow("meta must be an object"); - }); - }); - - describe("track", () => { - const client = new ReflagClient(validOptions); - - beforeEach(() => { - client["rateLimiter"].clearStale(true); - }); - - test.each([ - { id: "user123", age: 1, name: "John" }, - { id: 42, age: 1, name: "John" }, - ])("should successfully track the flag usage", async (testUser) => { - const response = { - status: 200, - body: { success: true }, - }; - httpClient.post.mockResolvedValue(response); - - await client.bindClient({ user: testUser, company }).track(event.event, { - attributes: event.attrs, - meta: { active: true }, - }); - - await client.flush(); - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - expect.objectContaining({ - type: "company", - }), - expect.objectContaining({ - type: "user", - }), - { - attributes: { - key: "value", - }, - context: { - active: true, - }, - event: "flag-event", - type: "event", - userId: testUser.id, - companyId: company.id, - }, - ], - ); - - expect(logger.debug).toHaveBeenCalledWith( - expect.stringMatching("post request to"), - response, - ); - }); - - it("should successfully track the flag usage including user and company", async () => { - httpClient.post.mockResolvedValue({ - status: 200, - body: { success: true }, - }); - - await client.bindClient({ user }).track(event.event, { - companyId: "otherCompanyId", - attributes: event.attrs, - meta: { active: true }, - }); - - await client.flush(); - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - expect.objectContaining({ - type: "user", - }), - { - attributes: { - key: "value", - }, - context: { - active: true, - }, - event: "flag-event", - companyId: "otherCompanyId", - type: "event", - userId: "user123", - }, - ], - ); - }); - - it("should log an error if the post request fails", async () => { - const error = new Error("Network error"); - httpClient.post.mockRejectedValue(error); - - await client.bindClient({ user }).track(event.event); - await client.flush(); - - expect(logger.warn).toHaveBeenCalledWith( - "flush of buffered items failed; discarding items", - expect.objectContaining({ - count: 2, - error: expect.objectContaining({ - message: "failed to send bulk events", - }), - }), - ); - }); - - it("should log if the API call returns false", async () => { - const response = { - status: 200, - body: { success: false }, - }; - - httpClient.post.mockResolvedValue(response); - - await client.bindClient({ user }).track(event.event); - await client.flush(); - - expect(logger.warn).toHaveBeenCalledWith( - "flush of buffered items failed; discarding items", - expect.objectContaining({ - count: 2, - error: expect.objectContaining({ - message: "failed to send bulk events", - }), - }), - ); - expect(logger.error).not.toHaveBeenCalled(); - }); - - it("should log if user is not set", async () => { - const boundClient = client.bindClient({ company }); - - await boundClient.track("hello"); - - expect(httpClient.post).not.toHaveBeenCalled(); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringMatching("no user set, cannot track event"), - ); - }); - - it("should throw an error if event is invalid", async () => { - const boundClient = client.bindClient({ company, user }); - - await expect(boundClient.track(undefined as any)).rejects.toThrow( - "event must be a string", - ); - await expect(boundClient.track(1 as any)).rejects.toThrow( - "event must be a string", - ); - - await expect( - boundClient.track(event.event, "bad_opts" as any), - ).rejects.toThrow("validation failed: options must be an object"); - - await expect( - boundClient.track(event.event, { - attributes: "bad_attributes" as any, - }), - ).rejects.toThrow("attributes must be an object"); - - await expect( - boundClient.track(event.event, { meta: "bad_meta" as any }), - ).rejects.toThrow("meta must be an object"); - }); - }); - - describe("user", () => { - it("should return the undefined if user was not set", () => { - const client = new ReflagClient(validOptions).bindClient({ company }); - expect(client.user).toBeUndefined(); - }); - - it("should return the user if user was associated", () => { - const client = new ReflagClient(validOptions).bindClient({ user }); - - expect(client.user).toEqual(user); - }); - }); - - describe("company", () => { - it("should return the undefined if company was not set", () => { - const client = new ReflagClient(validOptions).bindClient({ user }); - expect(client.company).toBeUndefined(); - }); - - it("should return the user if company was associated", () => { - const client = new ReflagClient(validOptions).bindClient({ company }); - - expect(client.company).toEqual(company); - }); - }); - - describe("otherContext", () => { - it("should return the undefined if custom context was not set", () => { - const client = new ReflagClient(validOptions).bindClient({ company }); - expect(client.otherContext).toBeUndefined(); - }); - - it("should return the user if custom context was associated", () => { - const client = new ReflagClient(validOptions).bindClient({ - other: otherContext, - }); - - expect(client.otherContext).toEqual(otherContext); - }); - }); - - describe("initialize", () => { - it("should initialize the client", async () => { - const client = new ReflagClient(validOptions); - - const get = vi - .spyOn(client["flagsCache"], "get") - .mockReturnValue(undefined); - const refresh = vi - .spyOn(client["flagsCache"], "refresh") - .mockResolvedValue(undefined); - - await client.initialize(); - await client.initialize(); - await client.initialize(); - - expect(refresh).toHaveBeenCalledTimes(1); - expect(get).not.toHaveBeenCalled(); - }); - - it("should call the backend to obtain flags", async () => { - const client = new ReflagClient(validOptions); - - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - }); - - await client.initialize(); - - expect(httpClient.get).toHaveBeenCalledWith( - `https://api.example.com/features`, - expectedHeaders, - API_TIMEOUT_MS, - ); - }); - - it("should refresh features with waitForVersion when receiving push updates", async () => { - const fetchMock = vi.spyOn(global, "fetch").mockResolvedValue( - new Response( - createSSEMessageStream({ - id: "flags-state:test:0", - timestamp: 1774605839478, - name: "flags-updated", - clientId: "reflag-backend", - channel: "flags-state:test", - action: 0, - encoding: "json", - data: JSON.stringify({ - envId: "endbMeqfmcgLyZ", - flagStateVersion: 22, - }), - }), - { - status: 200, - headers: { - "Content-Type": "text/event-stream", - }, - }, - ), - ); - - try { - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - ...flagDefinitions, - }, - }); - - const client = new ReflagClient({ - ...validOptions, - flagsSyncMode: "push", - }); - - await client.initialize(); - - await vi.waitFor(() => { - expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features?waitForVersion=22", - expectedHeaders, - API_TIMEOUT_MS, - ); - }); - - const [calledUrl, calledInit] = fetchMock.mock.calls[0] ?? []; - const url = new URL(String(calledUrl)); - expect(url.origin).toBe("https://front.reflag.com"); - expect(url.pathname).toBe("/sse/server"); - expect(url.searchParams.has("channels")).toBe(false); - expect(calledInit).toMatchObject({ - method: "GET", - headers: { - ...expectedHeaders, - Accept: "text/event-stream", - "Cache-Control": "no-cache", - }, - }); - expect(calledInit?.body).toBeUndefined(); - expect(calledInit?.signal).toBeDefined(); - - client.destroy(); - } finally { - fetchMock.mockRestore(); - } - }); - - it("should refresh features after push reconnects", async () => { - const fetchMock = vi - .spyOn(global, "fetch") - .mockResolvedValueOnce( - new Response(createFailingSSEStream(), { - status: 200, - headers: { - "Content-Type": "text/event-stream", - }, - }), - ) - .mockResolvedValueOnce( - new Response(createIdleSSEStream(), { - status: 200, - headers: { - "Content-Type": "text/event-stream", - }, - }), - ); - - vi.useFakeTimers(); - try { - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - ...flagDefinitions, - }, - }); - - const client = new ReflagClient({ - ...validOptions, - flagsSyncMode: "push", - }); - - await client.initialize(); - await vi.runOnlyPendingTimersAsync(); - - await vi.waitFor(() => { - expect(httpClient.get).toHaveBeenCalledTimes(2); - }); - - expect(httpClient.get).toHaveBeenNthCalledWith( - 2, - "https://api.example.com/features", - expectedHeaders, - API_TIMEOUT_MS, - ); - - client.destroy(); - } finally { - vi.useRealTimers(); - fetchMock.mockRestore(); - } - }); - - it("should load flag definitions from flagsFallbackProvider when live fetch fails", async () => { - const savedAt = "2026-03-09T00:00:00.000Z"; - const flagsFallbackProvider: FlagsFallbackProvider = { - load: vi.fn().mockResolvedValue({ - version: 1, - savedAt, - flags: flagDefinitions.features, - }), - save: vi.fn(), - }; - - httpClient.get.mockResolvedValue({ success: false }); - - const client = new ReflagClient({ - ...validOptions, - flagsFallbackProvider, - flagsFetchRetries: 0, - }); - - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-09T00:20:00.000Z")); - try { - await client.initialize(); - - expect(flagsFallbackProvider.load).toHaveBeenCalledWith( - expect.objectContaining({ - secretKeyHash: expect.any(String), - }), - ); - expect(logger.warn).toHaveBeenCalledTimes(1); - expect(logger.warn).toHaveBeenCalledWith( - `remote flags unavailable, using fallback flags fetched 20m ago (${savedAt})`, - ); - - expect( - client.getFlag({ company, user, other: otherContext }, "flag1"), - ).toStrictEqual({ - key: "flag1", - isEnabled: true, - config: { - key: "config-1", - payload: { something: "else" }, - }, - track: expect.any(Function), - }); - } finally { - vi.useRealTimers(); - } - }); - - it("should load static fallback flag definitions when live fetch fails", async () => { - httpClient.get.mockResolvedValue({ success: false }); - - const client = new ReflagClient({ - ...validOptions, - flagsFallbackProvider: fallbackProviders.static({ - flags: { - huddle: true, - "smart-summaries": false, - }, - }), - flagsFetchRetries: 0, - }); - - await client.initialize(); - - expect(client.getFlag({}, "huddle")).toStrictEqual({ - key: "huddle", - isEnabled: true, - config: { - key: undefined, - payload: undefined, - }, - track: expect.any(Function), - }); - expect(client.getFlag({}, "smart-summaries")).toStrictEqual({ - key: "smart-summaries", - isEnabled: false, - config: { - key: undefined, - payload: undefined, - }, - track: expect.any(Function), - }); - }); - - it("should log remote flag fetch failures at debug level when using fallback definitions", async () => { - const error = new Error("fetch failed"); - const savedAt = "2026-03-09T00:00:00.000Z"; - const flagsFallbackProvider: FlagsFallbackProvider = { - load: vi.fn().mockResolvedValue({ - version: 1, - savedAt, - flags: flagDefinitions.features, - }), - save: vi.fn(), - }; - - httpClient.get.mockRejectedValue(error); - - const client = new ReflagClient({ - ...validOptions, - flagsFallbackProvider, - flagsFetchRetries: 0, - }); - - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-09T00:20:00.000Z")); - try { - await client.initialize(); - - expect(logger.debug).toHaveBeenCalledWith( - 'get request to "features" failed with error after 0 retries', - error, - ); - expect(logger.warn).toHaveBeenCalledWith( - `remote flags unavailable, using fallback flags fetched 20m ago (${savedAt})`, - ); - expect(logger.error).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it("should include the cause when warning that flag fetch will retry", async () => { - const error = new Error("fetch failed"); - httpClient.get.mockRejectedValue(error); - - const client = new ReflagClient({ - ...validOptions, - flagsSyncMode: "in-request", - flagsFetchRetries: 1, - }); - - vi.useFakeTimers(); - try { - const initializePromise = client.initialize(); - await vi.runAllTimersAsync(); - await initializePromise; - - expect(logger.warn).toHaveBeenCalledWith( - "failed to fetch flags, will retry", - error, - ); - } finally { - vi.useRealTimers(); - } - }); - - it("should warn when live fetch fails and flagsFallbackProvider has no saved snapshot", async () => { - const flagsFallbackProvider: FlagsFallbackProvider = { - load: vi.fn().mockResolvedValue(undefined), - save: vi.fn(), - }; - - httpClient.get.mockResolvedValue({ success: false }); - - const client = new ReflagClient({ - ...validOptions, - flagsFallbackProvider, - flagsFetchRetries: 0, - }); - - await client.initialize(); - - expect(flagsFallbackProvider.load).toHaveBeenCalledWith( - expect.objectContaining({ - secretKeyHash: expect.any(String), - }), - ); - expect(logger.warn).toHaveBeenCalledWith( - "remote flags unavailable, no fallback flags found in flagsFallbackProvider", - ); - }); - - it("should save fetched flag definitions to flagsFallbackProvider", async () => { - const flagsFallbackProvider: FlagsFallbackProvider = { - load: vi.fn(), - save: vi.fn().mockResolvedValue(undefined), - }; - - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - ...flagDefinitions, - }, - }); - - const client = new ReflagClient({ - ...validOptions, - flagsFallbackProvider, - }); - - await client.initialize(); - await flushPromises(); - - expect(flagsFallbackProvider.save).toHaveBeenCalledWith( - expect.objectContaining({ - secretKeyHash: expect.any(String), - }), - expect.objectContaining({ - version: 1, - flags: flagDefinitions.features, - savedAt: expect.any(String), - }), - ); - }); - - it("should not fail initialize when flagsFallbackProvider.save fails", async () => { - const error = new Error("save failed"); - const flagsFallbackProvider: FlagsFallbackProvider = { - load: vi.fn(), - save: vi.fn().mockRejectedValue(error), - }; - - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - ...flagDefinitions, - }, - }); - - const client = new ReflagClient({ - ...validOptions, - flagsFallbackProvider, - }); - - await client.initialize(); - await flushPromises(); - - expect(logger.error).toHaveBeenCalledWith( - "flagsFallbackProvider: failed to save flag definitions", - error, - ); - }); - }); - - describe("flush", () => { - it("should flush all bulk data", async () => { - const client = new ReflagClient(validOptions); - - await client.updateUser(user.id, { attributes: { age: 2 } }); - await client.updateUser(user.id, { attributes: { age: 3 } }); - await client.updateUser(user.id, { attributes: { name: "Jane" } }); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - { - type: "user", - userId: user.id, - attributes: { age: 2 }, - }, - { - type: "user", - userId: user.id, - attributes: { age: 3 }, - }, - { - type: "user", - userId: user.id, - attributes: { name: "Jane" }, - }, - ], - ); - }); - - it("should not flush all bulk data if `offline` is true", async () => { - const client = new ReflagClient({ - ...validOptions, - offline: true, - }); - - await client.updateUser(user.id, { attributes: { age: 2 } }); - await client.flush(); - - expect(httpClient.post).not.toHaveBeenCalled(); - }); - }); - - describe("refreshFlags", () => { - it("should call flagsCache.refresh()", async () => { - const client = new ReflagClient(validOptions); - - const refresh = vi - .spyOn(client["flagsCache"], "refresh") - .mockResolvedValue(undefined); - - await client.refreshFlags(); - - expect(refresh).toHaveBeenCalledTimes(1); - }); - - it("should call flagsCache.refresh() on each invocation", async () => { - const client = new ReflagClient(validOptions); - - const refresh = vi - .spyOn(client["flagsCache"], "refresh") - .mockResolvedValue(undefined); - - await client.refreshFlags(); - await client.refreshFlags(); - await client.refreshFlags(); - - expect(refresh).toHaveBeenCalledTimes(3); - }); - }); - - describe("getFlag", () => { - let client: ReflagClient; - - beforeEach(async () => { - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - ...flagDefinitions, - }, - }); - - client = new ReflagClient(validOptions); - - httpClient.post.mockResolvedValue({ - status: 200, - body: { success: true }, - }); - }); - - it("returns a flag", async () => { - await client.initialize(); - const flag = client.getFlag( - { - company, - user, - other: otherContext, - }, - "flag1", - ); - - expect(flag).toStrictEqual({ - key: "flag1", - isEnabled: true, - config: { - key: "config-1", - payload: { something: "else" }, - }, - track: expect.any(Function), - }); - }); - - it("evaluates percentage rollouts using user.id", async () => { - const userRolloutDefinitions: FlagsAPIResponse = { - flagStateVersion: 2, - features: [ - { - key: "test-flag", - description: "User rollout", - targeting: { - version: 1, - rules: [ - { - filter: { - type: "rolloutPercentage", - key: "test-flag", - partialRolloutAttribute: "user.id", - partialRolloutThreshold: 50000, - }, - }, - ], - }, - }, - ], - }; - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { success: true, ...userRolloutDefinitions }, - }); - - await client.initialize(); - - expect( - client.getFlag( - { - user: { id: "user-1" }, - company, - enableTracking: false, - }, - "test-flag", - ).isEnabled, - ).toBe(true); - expect( - client.getFlag( - { - user: { id: "user-2" }, - company, - enableTracking: false, - }, - "test-flag", - ).isEnabled, - ).toBe(false); - }); - - it("`track` sends all expected events when `enableTracking` is `true`", async () => { - const context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlag( - { - ...context, - meta: { - active: true, - }, - enableTracking: true, - }, - "flag1", - ); - - await flag.track(); - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - { - attributes: { - employees: 100, - name: "Acme Inc.", - }, - companyId: "company123", - context: { - active: true, - }, - type: "company", - userId: user.id, - }, - { - attributes: { - age: 1, - name: "John", - }, - context: { - active: true, - }, - type: "user", - userId: "user123", - }, - { - type: "event", - event: "flag1", - userId: user.id, - companyId: company.id, - }, - ], - ); - }); - - it("`isEnabled` sends `check` event", async () => { - const context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlag(context, "flag1"); - - // trigger `check` event - expect(flag.isEnabled).toBe(true); - - await client.flush(); - const checkEvents = httpClient.post.mock.calls - .flatMap((call) => call[2]) - .filter((e) => e.action === "check"); - - expect(checkEvents).toStrictEqual([ - { - type: "feature-flag-event", - action: "check", - key: "flag1", - targetingVersion: 1, - evalResult: true, - evalContext: context, - evalRuleResults: [true], - evalMissingFields: [], - }, - ]); - }); - - it("`isEnabled` warns about missing context fields", async () => { - const context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlag(context, "flag2"); - - // trigger the warning - expect(flag.isEnabled).toBe(false); - - expect(logger.warn).toHaveBeenCalledWith( - "flag targeting rules could not be fully evaluated.", - { - flag2: [ - { - code: "MISSING_CONTEXT_FIELD", - field: "attributeKey", - message: - 'Context field "attributeKey" is required to evaluate targeting rules.', - }, - ], - }, - ); - - const sameErrorForAnotherUser = client.getFlag( - { ...context, user: { ...context.user, id: "another-user" } }, - "flag2", - ); - expect(sameErrorForAnotherUser.isEnabled).toBe(false); - expect(logger.warn).toHaveBeenCalledTimes(1); - }); - - it.each(["CONTAINS", "NOT_CONTAINS", "IS", "IS_NOT"] as const)( - "evaluates array %s for targeting and config without warnings", - async (operator) => { - const filter = { - type: "context" as const, - field: "user.roles", - operator, - values: ["admin"], - }; - const definitions: FlagsAPIResponse = { - flagStateVersion: 2, - features: [ - { - key: "array-membership", - description: "Array membership", - targeting: { version: 1, rules: [{ filter }] }, - config: { - version: 1, - variants: [ - { key: "matched", payload: { access: true }, filter }, - ], - }, - }, - ], - }; - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { success: true, ...definitions }, - }); - await client.initialize(); - - for (const [roles, contains, equals] of [ - [["admin"], true, true], - [["admin", "editor"], true, false], - [["admin", "admin"], true, false], - [["superadmin"], false, false], - [["Admin"], false, false], - [[], false, false], - // Scalars retain substring matching and exact equality respectively. - ["SUPERADMIN", true, false], - ["admin", true, true], - ] as const) { - const flag = client.getFlag( - { user: { id: "user123", roles } }, - "array-membership", - ); - const positiveMatch = - operator === "IS" || operator === "IS_NOT" ? equals : contains; - const expected = - operator === "CONTAINS" || operator === "IS" - ? positiveMatch - : !positiveMatch; - expect(flag.isEnabled).toBe(expected); - expect(flag.config).toEqual( - expected - ? { key: "matched", payload: { access: true } } - : { key: undefined, payload: undefined }, - ); - } - expect(logger.warn).not.toHaveBeenCalled(); - }, - ); - - it("`isEnabled` warns about unsupported array operators", async () => { - const arrayDefinitions: FlagsAPIResponse = { - flagStateVersion: 2, - features: [ - { - key: "array-flag", - description: "Array flag", - targeting: { - version: 1, - rules: [ - { - filter: { - type: "context", - field: "user.roles", - operator: "GT", - values: ["admin"], - }, - }, - ], - }, - }, - ], - }; - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { success: true, ...arrayDefinitions }, - }); - - await client.initialize(); - const flag = client.getFlag( - { user: { id: "user123", roles: ["admin"] } }, - "array-flag", - ); - - expect(flag.isEnabled).toBe(false); - expect(logger.warn).toHaveBeenCalledWith( - "flag targeting rules could not be fully evaluated.", - { - "array-flag": [ - { - code: "UNSUPPORTED_ARRAY_OPERATOR", - field: "user.roles", - operator: "GT", - message: - 'Operator GT does not support array-valued context field "user.roles".', - }, - ], - }, - ); - - const sameErrorForAnotherUser = client.getFlag( - { user: { id: "another-user", roles: ["admin"] } }, - "array-flag", - ); - expect(sameErrorForAnotherUser.isEnabled).toBe(false); - expect(logger.warn).toHaveBeenCalledTimes(1); - }); - - it("`config` warns about unsupported array operators", async () => { - const arrayConfigDefinitions: FlagsAPIResponse = { - flagStateVersion: 2, - features: [ - { - key: "array-config", - description: "Array config", - targeting: { - version: 1, - rules: [{ filter: { type: "constant", value: true } }], - }, - config: { - version: 1, - variants: [ - { - key: "admin", - payload: { access: true }, - filter: { - type: "context", - field: "user.roles", - operator: "GT", - values: ["admin"], - }, - }, - ], - }, - }, - ], - }; - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { success: true, ...arrayConfigDefinitions }, - }); - - await client.initialize(); - const flag = client.getFlag( - { user: { id: "user123", roles: ["admin"] } }, - "array-config", - ); - - expect(flag.config).toEqual({ key: undefined, payload: undefined }); - expect(logger.warn).toHaveBeenCalledWith( - "flag targeting rules could not be fully evaluated.", - { - "array-config.config": [ - { - code: "UNSUPPORTED_ARRAY_OPERATOR", - field: "user.roles", - operator: "GT", - message: - 'Operator GT does not support array-valued context field "user.roles".', - }, - ], - }, - ); - }); - - it("`isEnabled` should not warn about missing context fields if not needed", async () => { - const context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlag(context, "flag1"); - - // should not trigger the warning - expect(flag.isEnabled).toBe(true); - - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it("`config` sends `check` event", async () => { - const context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlag(context, "flag1"); - - // trigger `check` event - expect(flag.config).toBeDefined(); - - await client.flush(); - - const checkEvents = httpClient.post.mock.calls - .flatMap((call) => call[2]) - .filter((e) => e.action === "check-config"); - - expect(checkEvents).toStrictEqual([ - { - type: "feature-flag-event", - action: "check-config", - key: "flag1", - evalResult: { - key: "config-1", - payload: { - something: "else", - }, - }, - targetingVersion: 1, - evalContext: context, - evalRuleResults: [true], - evalMissingFields: [], - }, - ]); - }); - - it("sends events for unknown flags", async () => { - const context: Context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlag(context, "unknown-flag"); - - // trigger `check` event - expect(flag.isEnabled).toBe(false); - await flag.track(); - await client.flush(); - - const checkEvents = httpClient.post.mock.calls - .flatMap((call) => call[2]) - .filter((e) => e.type === "feature-flag-event"); - - expect(checkEvents).toStrictEqual([ - { - type: "feature-flag-event", - action: "check", - key: "unknown-flag", - targetingVersion: undefined, - evalContext: context, - evalResult: false, - evalRuleResults: undefined, - evalMissingFields: undefined, - }, - ]); - }); - - it("sends company/user and track events", async () => { - const context: Context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlag(context, "flag1"); - - // trigger `check` event - await flag.track(); - await client.flush(); - - const checkEvents = httpClient.post.mock.calls - .flatMap((call) => call[2]) - .filter( - (e) => - e.type === "company" || e.type === "user" || e.type === "event", - ); - - expect(checkEvents).toStrictEqual([ - { - type: "company", - companyId: "company123", - attributes: { - employees: 100, - name: "Acme Inc.", - }, - userId: user.id, - context: undefined, - }, - { - type: "user", - userId: "user123", - attributes: { - age: 1, - name: "John", - }, - context: undefined, - }, - { - type: "event", - event: "flag1", - userId: user.id, - companyId: company.id, - context: undefined, - attributes: undefined, - }, - ]); - }); - }); - - describe("getFlags", () => { - let client: ReflagClient; - - beforeEach(async () => { - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - ...flagDefinitions, - }, - }); - - client = new ReflagClient(validOptions); - - client["rateLimiter"].clearStale(true); - - httpClient.post.mockResolvedValue({ - ok: true, - status: 200, - body: { success: true }, - }); - }); - - it("should return evaluated flags", async () => { - httpClient.post.mockClear(); // not interested in updates - - await client.initialize(); - const result = client.getFlags({ - company, - user, - other: otherContext, - }); - - expect(result).toStrictEqual({ - flag1: { - key: "flag1", - isEnabled: true, - config: { - key: "config-1", - payload: { - something: "else", - }, - }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(1); - }); - - it("should properly define the rate limiter key", async () => { - const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed"); - - await client.initialize(); - client.getFlags({ user, company, other: otherContext }); - - expect(isAllowedSpy).toHaveBeenNthCalledWith( - 1, - "5Zt35h50IPRNU8yXAj/YbPME/qE=", - ); - expect(isAllowedSpy).toHaveBeenNthCalledWith( - 2, - "J3G4oF56f2t+T6xYzES6inc78+c=", - ); - }); - - it("should return evaluated flags when only user is defined", async () => { - httpClient.post.mockClear(); // not interested in updates - - await client.initialize(); - const flags = client.getFlags({ user }); - - expect(flags).toStrictEqual({ - flag1: { - isEnabled: false, - key: "flag1", - config: { - key: undefined, - payload: undefined, - }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(1); - }); - - it("should return evaluated flags when only company is defined", async () => { - await client.initialize(); - const flags = client.getFlags({ company }); - - // expect will trigger the `isEnabled` getter and send a `check` event - expect(flags).toStrictEqual({ - flag1: { - isEnabled: true, - key: "flag1", - config: { - key: "config-1", - payload: { - something: "else", - }, - }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(1); - }); - - it("should not send flag events when `enableTracking` is `false`", async () => { - await client.initialize(); - const flags = client.getFlags({ company, enableTracking: false }); - - // expect will trigger the `isEnabled` getter and send a `check` event - expect(flags).toStrictEqual({ - flag1: { - isEnabled: true, - key: "flag1", - config: { - key: "config-1", - payload: { - something: "else", - }, - }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - await client.flush(); - - expect(httpClient.post).not.toHaveBeenCalled(); - }); - - it("should return evaluated flags when only other context is defined", async () => { - await client.initialize(); - const flags = client.getFlags({ other: otherContext }); - - expect(flags).toStrictEqual({ - flag1: { - isEnabled: false, - key: "flag1", - config: { - key: undefined, - payload: undefined, - }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - await client.flush(); - }); - - it("should send `track` with user and company if provided", async () => { - await client.initialize(); - const flags = client.getFlags({ company, user }); - await client.flush(); - - await flags.flag1.track(); - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(2); - // second call includes the track event - const events = httpClient.post.mock.calls[1][2].filter( - (e: any) => e.type === "event", - ); - - expect(events).toStrictEqual([ - { - event: "flag1", - type: "event", - userId: "user123", - companyId: "company123", - attributes: undefined, - context: undefined, - }, - ]); - }); - - it("should send `track` with user if provided", async () => { - await client.initialize(); - const flags = client.getFlags({ user }); - - await client.flush(); - await flags.flag1.track(); - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(2); - - const emptyEvents = httpClient.post.mock.calls[0][2].filter( - (e: any) => e.type === "event", - ); - - expect(emptyEvents).toStrictEqual([]); - - // second call includes the track event - const events = httpClient.post.mock.calls[1][2].filter( - (e: any) => e.type === "event", - ); - - expect(events).toStrictEqual([ - { - event: "flag1", - type: "event", - userId: "user123", - companyId: undefined, - attributes: undefined, - context: undefined, - }, - ]); - }); - - it("should not send `track` with only company if no user is provided", async () => { - // we do not accept track events without a userId - await client.initialize(); - const flag = client.getFlags({ company }); - - await flag.flag1.track(); - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(1); - const events = httpClient.post.mock.calls[0][2].filter( - (e: any) => e.type === "event", - ); - - expect(events).toStrictEqual([]); - }); - - it("`isEnabled` does not send `check` event", async () => { - const context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlags(context); - - // trigger `check` event - expect(flag.flag1.isEnabled).toBe(true); - - await client.flush(); - const checkEvents = httpClient.post.mock.calls - .flatMap((call) => call[2]) - .filter((e) => e.action === "check"); - - expect(checkEvents).toStrictEqual([]); - }); - - it("`config` does not send `check` event", async () => { - const context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - const flag = client.getFlags(context); - - // attempt to trigger `check` event - expect(flag.flag1.config).toBeDefined(); - - await client.flush(); - - const checkEvents = httpClient.post.mock.calls - .flatMap((call) => call[2]) - .filter((e) => e.action === "check-config"); - - expect(checkEvents).toStrictEqual([]); - }); - - it("sends company/user events", async () => { - const context: Context = { - company, - user, - other: otherContext, - }; - - // test that the flag is returned - await client.initialize(); - client.getFlags(context); - - // trigger `check` event - await client.flush(); - - const checkEvents = httpClient.post.mock.calls - .flatMap((call) => call[2]) - .filter( - (e) => - e.type === "company" || e.type === "user" || e.type === "event", - ); - - expect(checkEvents).toStrictEqual([ - { - type: "company", - companyId: "company123", - attributes: { - employees: 100, - name: "Acme Inc.", - }, - userId: user.id, - context: undefined, - }, - { - type: "user", - userId: "user123", - attributes: { - age: 1, - name: "John", - }, - context: undefined, - }, - ]); - }); - - it("should use fallback flags when `getFlagDefinitions` returns `undefined`", async () => { - httpClient.get.mockResolvedValue({ - success: false, - }); - - await client.initialize(); - const result = client.getFlag( - { user: { id: "user123" }, enableTracking: true }, - "key", - ); - - expect(result).toStrictEqual({ - key: "key", - isEnabled: true, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }); - - expect(logger.warn).toHaveBeenCalledWith( - expect.stringMatching( - "no flag definitions available, using fallback flags", - ), - ); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(1); - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - expect.objectContaining({ type: "user" }), - expect.objectContaining({ - type: "feature-flag-event", - action: "check-config", - key: "key", - }), - expect.objectContaining({ - type: "feature-flag-event", - action: "check", - key: "key", - evalResult: true, - }), - ], - ); - }); - - it("should not fail if sendFlagEvent fails to send check event", async () => { - httpClient.post.mockResolvedValue({ - status: 200, - body: { success: true }, - }); - - await client.initialize(); - httpClient.post.mockRejectedValue(new Error("Network error")); - const context = { user, company, other: otherContext }; - - const result = client.getFlags(context); - - // Trigger a flag check - expect(result.flag1).toStrictEqual({ - key: "flag1", - isEnabled: true, - track: expect.any(Function), - config: { - key: "config-1", - payload: { - something: "else", - }, - }, - }); - - await client.flush(); - - expect(logger.warn).toHaveBeenCalledWith( - "flush of buffered items failed; discarding items", - expect.objectContaining({ - count: 2, - error: expect.objectContaining({ - message: "failed to send bulk events", - }), - }), - ); - }); - - it("should use base flag overrides", async () => { - await client.initialize(); - const context = { user, company, other: otherContext }; - - const pristineResults = client.getFlags(context); - expect(pristineResults).toStrictEqual({ - flag1: { - key: "flag1", - isEnabled: true, - config: { - key: "config-1", - payload: { - something: "else", - }, - }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - client.setFlagOverrides({ - flag1: false, - }); - const flags = client.getFlags(context); - - expect(flags).toStrictEqual({ - flag1: { - key: "flag1", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - client.clearFlagOverrides(); - const flags2 = client.getFlags(context); - - expect(flags2).toStrictEqual({ - ...pristineResults, - flag1: { - ...pristineResults.flag1, - track: expect.any(Function), - }, - flag2: { - ...pristineResults.flag2, - track: expect.any(Function), - }, - }); - }); - - it("should use base flag overrides from function", async () => { - await client.initialize(); - const context = { user, company, other: otherContext }; - - const pristineResults = client.getFlags(context); - expect(pristineResults).toStrictEqual({ - flag1: { - key: "flag1", - isEnabled: true, - config: { - key: "config-1", - payload: { - something: "else", - }, - }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - client.setFlagOverrides((_context: Context) => { - expect(context).toStrictEqual(context); - return { - flag1: { isEnabled: false }, - flag2: true, - flag3: { - isEnabled: true, - config: { - key: "config-1", - payload: { something: "else" }, - }, - }, - }; - }); - const flags = client.getFlags(context); - - expect(flags).toStrictEqual({ - flag1: { - key: "flag1", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: true, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - flag3: { - key: "flag3", - isEnabled: true, - config: { - key: "config-1", - payload: { something: "else" }, - }, - track: expect.any(Function), - }, - }); - }); - - it("should push and restore layered flag overrides", async () => { - await client.initialize(); - const context = { user, company, other: otherContext }; - - expect(client.getFlag(context, "flag1").isEnabled).toBe(true); - expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - - const removeFlag1 = client.pushFlagOverrides({ - flag1: false, - }); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(false); - expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - - const removeFlag2 = client.pushFlagOverrides({ - flag2: true, - }); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(false); - expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - - removeFlag2(); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(false); - expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - - removeFlag1(); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(true); - expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - }); - - it("should compose pushed flag overrides with existing override functions", async () => { - await client.initialize(); - const context = { user, company, other: otherContext }; - - client.setFlagOverrides(() => ({ - flag1: false, - })); - - const remove = client.pushFlagOverrides((overrideContext: Context) => { - expect(overrideContext).toStrictEqual({ - user, - company, - other: otherContext, - }); - - return { - flag2: true, - }; - }); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(false); - expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - - remove(); - remove(); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(false); - expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - }); - - it("should keep pushed overrides layered on top when replacing base overrides", async () => { - await client.initialize(); - const context = { user, company, other: otherContext }; - - client.setFlagOverrides({ - flag1: false, - }); - - const remove = client.pushFlagOverrides({ - flag1: true, - }); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(true); - - client.setFlagOverrides({ - flag1: false, - flag2: true, - }); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(true); - expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - - remove(); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(false); - expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - }); - - it("should only clear the base overrides", async () => { - await client.initialize(); - const context = { user, company, other: otherContext }; - - client.setFlagOverrides({ - flag1: false, - }); - - const remove = client.pushFlagOverrides({ - flag2: true, - }); - - client.clearFlagOverrides(); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(true); - expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - - remove(); - - expect(client.getFlag(context, "flag1").isEnabled).toBe(true); - expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - }); - }); - - describe("getFlagsForBootstrap", () => { - let client: ReflagClient; - - beforeEach(async () => { - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - ...flagDefinitions, - }, - }); - - client = new ReflagClient(validOptions); - - client["rateLimiter"].clearStale(true); - - httpClient.post.mockResolvedValue({ - ok: true, - status: 200, - body: { success: true }, - }); - }); - - it("should return raw flags without wrapper functions", async () => { - httpClient.post.mockClear(); // not interested in updates - - await client.initialize(); - const result = client.getFlagsForBootstrap({ - company, - user, - other: otherContext, - enableTracking: true, - }); - - expect(result).toStrictEqual({ - context: { - company, - user, - other: otherContext, - enableTracking: true, - }, - flags: { - flag1: { - key: "flag1", - isEnabled: true, - targetingVersion: 1, - config: { - key: "config-1", - payload: { - something: "else", - }, - targetingVersion: 1, - missingContextFields: [], - ruleEvaluationResults: [true], - }, - ruleEvaluationResults: [true], - missingContextFields: [], - }, - flag2: { - key: "flag2", - isEnabled: false, - targetingVersion: 2, - config: { - key: undefined, - payload: undefined, - targetingVersion: undefined, - missingContextFields: [], - ruleEvaluationResults: [], - }, - ruleEvaluationResults: [false], - missingContextFields: ["attributeKey"], - evaluationErrors: [missingContextFieldError("attributeKey")], - }, - }, - flagStateVersion: 1, - }); - - // Should not have track function like regular getFlags - expect(result.flags.flag1).not.toHaveProperty("track"); - expect(result.flags.flag2).not.toHaveProperty("track"); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(1); - }); - - it("should return raw flags when only user is defined", async () => { - httpClient.post.mockClear(); // not interested in updates - - await client.initialize(); - const flags = client.getFlagsForBootstrap({ user }); - - expect(flags).toStrictEqual({ - context: { - user, - enableTracking: true, - }, - flags: { - flag1: { - key: "flag1", - isEnabled: false, - targetingVersion: 1, - config: { - key: undefined, - payload: undefined, - targetingVersion: 1, - missingContextFields: ["company.id"], - evaluationErrors: [missingContextFieldError("company.id")], - ruleEvaluationResults: [false], - }, - ruleEvaluationResults: [false], - missingContextFields: ["company.id"], - evaluationErrors: [missingContextFieldError("company.id")], - }, - flag2: { - key: "flag2", - isEnabled: false, - targetingVersion: 2, - config: { - key: undefined, - payload: undefined, - targetingVersion: undefined, - missingContextFields: [], - ruleEvaluationResults: [], - }, - ruleEvaluationResults: [false], - missingContextFields: ["company.id"], - evaluationErrors: [missingContextFieldError("company.id")], - }, - }, - flagStateVersion: 1, - }); - - // Should not have track function - expect(flags.flags.flag1).not.toHaveProperty("track"); - expect(flags.flags.flag2).not.toHaveProperty("track"); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledTimes(1); - }); - - it("should return raw flags when only company is defined", async () => { - await client.initialize(); - const flags = client.getFlagsForBootstrap({ company }); - - expect(flags).toStrictEqual({ - context: { - company, - enableTracking: true, - }, - flags: { - flag1: { - key: "flag1", - isEnabled: true, - targetingVersion: 1, - config: { - key: "config-1", - payload: { - something: "else", - }, - targetingVersion: 1, - missingContextFields: [], - ruleEvaluationResults: [true], - }, - ruleEvaluationResults: [true], - missingContextFields: [], - }, - flag2: { - key: "flag2", - isEnabled: false, - targetingVersion: 2, - config: { - key: undefined, - payload: undefined, - targetingVersion: undefined, - missingContextFields: [], - ruleEvaluationResults: [], - }, - ruleEvaluationResults: [false], - missingContextFields: ["attributeKey"], - evaluationErrors: [missingContextFieldError("attributeKey")], - }, - }, - flagStateVersion: 1, - }); - - // Should not have track function - expect(flags.flags.flag1).not.toHaveProperty("track"); - expect(flags.flags.flag2).not.toHaveProperty("track"); - }); - - it("should return raw flags when only other context is defined", async () => { - await client.initialize(); - const flags = client.getFlagsForBootstrap({ other: otherContext }); - - expect(flags).toStrictEqual({ - context: { - other: otherContext, - enableTracking: true, - }, - flags: { - flag1: { - key: "flag1", - isEnabled: false, - targetingVersion: 1, - config: { - key: undefined, - payload: undefined, - targetingVersion: 1, - missingContextFields: ["company.id"], - evaluationErrors: [missingContextFieldError("company.id")], - ruleEvaluationResults: [false], - }, - ruleEvaluationResults: [false], - missingContextFields: ["company.id"], - evaluationErrors: [missingContextFieldError("company.id")], - }, - flag2: { - key: "flag2", - isEnabled: false, - targetingVersion: 2, - config: { - key: undefined, - payload: undefined, - targetingVersion: undefined, - missingContextFields: [], - ruleEvaluationResults: [], - }, - ruleEvaluationResults: [false], - missingContextFields: ["company.id"], - evaluationErrors: [missingContextFieldError("company.id")], - }, - }, - flagStateVersion: 1, - }); - - // Should not have track function - expect(flags.flags.flag1).not.toHaveProperty("track"); - expect(flags.flags.flag2).not.toHaveProperty("track"); - }); - - it("should return fallback flags when client is not initialized", async () => { - const flags = client.getFlagsForBootstrap({ - company, - user, - other: otherContext, - enableTracking: true, - }); - - // Should return the fallback flags defined in validOptions - expect(flags).toStrictEqual({ - context: { - company, - user, - other: otherContext, - enableTracking: true, - }, - flags: { - key: { - isEnabled: true, - key: "key", - }, - }, - }); - }); - - it("should return fallback flags when flag definitions are not available", async () => { - httpClient.get.mockResolvedValueOnce({ - ok: true, + fallbackProviders, + ReflagClient, + type ClientOptions, + type FlagsFallbackSnapshot, + type HttpClient, +} from "../src"; +import { + FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS, + SDK_VERSION, + SDK_VERSION_HEADER_NAME, +} from "../src/config"; +import { definition, graph, snapshot } from "./fixtures"; + +vi.mock("../src/flusher", () => ({ subscribe: vi.fn() })); +const clients: ReflagClient[] = []; +const company = { id: "acme", plan: "enterprise" }; +function harness(response: unknown = graph(), options: ClientOptions = {}) { + const state = { response }; + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const httpClient = { + get: vi.fn( + async ( + _url: string, + _headers: Record, + _timeoutMs: number, + ) => ({ status: 200, - body: { - success: true, - flagStateVersion: 1, - features: [], // No flag definitions - }, - }); - - await client.initialize(); - const flags = client.getFlagsForBootstrap({ - company, - user, - other: otherContext, - }); + ok: true, + body: { success: true, ...(state.response as object) }, + }), + ), + post: vi.fn( + async ( + _url: string, + _headers: Record, + _body: unknown[], + ) => ({ status: 200, ok: true, body: { success: true } }), + ), + }; + const client = new ReflagClient({ + secretKey: "validSecretKeyWithMoreThan22Chars", + apiBaseUrl: "https://api.example.com/", + offline: false, + flagsFetchRetries: 0, + flagsSyncMode: "in-request", + logger, + httpClient: httpClient as unknown as HttpClient, + batchOptions: { intervalMs: 1e9, flushOnExit: false }, + ...options, + }); + clients.push(client); + const events = () => + httpClient.post.mock.calls.flatMap( + (call) => + ( + call as unknown as [ + string, + unknown, + { type: string; [key: string]: unknown }[], + ] + )[2], + ); + return { + client, + state, + logger, + httpClient, + events, + checks: () => + events().filter((event) => event.type === "feature-flag-event"), + }; +} +async function nextRefresh(client: ReflagClient, version?: number) { + await vi.advanceTimersByTimeAsync(1001); + await client.refreshFlags(version); +} +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + for (const client of clients.splice(0)) client.destroy(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); - expect(flags).toStrictEqual({ - context: { - company, - user, - other: otherContext, - enableTracking: true, - }, - flags: {}, +describe("direct flag values", () => { + it.each([ + false, + true, + null, + 0, + 42, + "", + "quality", + [false, "a"], + { nested: { enabled: false } }, + ])("returns %j itself, without a wrapper", async (value) => { + const h = harness(snapshot([definition("flag", value)])); + await h.client.initialize(); + expect(h.client.getFlag({ company }, "flag", null)).toEqual(value); + await h.client.flush(); + expect(h.checks()).toEqual([ + expect.objectContaining({ + action: "check", + key: "flag", + evalResult: "default", + sourceVersionId: "version:flag", flagStateVersion: 1, - }); - }); - - it("should handle enableTracking parameter", async () => { - await client.initialize(); - - // Test with enableTracking: true (default) - const flagsWithTracking = client.getFlagsForBootstrap({ - company, - user, - other: otherContext, - enableTracking: true, - }); - - // Test with enableTracking: false - const flagsWithoutTracking = client.getFlagsForBootstrap({ - company, - user, - other: otherContext, - enableTracking: true, - }); - - // Both should return the same raw flag structure - expect(flagsWithTracking).toStrictEqual(flagsWithoutTracking); - - // Neither should have track functions - expect(flagsWithTracking.flags.flag1).not.toHaveProperty("track"); - expect(flagsWithoutTracking.flags.flag1).not.toHaveProperty("track"); - }); - - it("should properly define the rate limiter key", async () => { - const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed"); - - await client.initialize(); - client.getFlagsForBootstrap({ user, company, other: otherContext }); - - expect(isAllowedSpy).toHaveBeenNthCalledWith( - 1, - "5Zt35h50IPRNU8yXAj/YbPME/qE=", - ); - expect(isAllowedSpy).toHaveBeenNthCalledWith( - 2, - "J3G4oF56f2t+T6xYzES6inc78+c=", - ); - }); - - it("should work in offline mode", async () => { - const offlineClient = new ReflagClient({ - ...validOptions, - offline: true, - }); - - const flags = offlineClient.getFlagsForBootstrap({ - company, - user, - other: otherContext, - enableTracking: true, - }); - - expect(flags).toStrictEqual({ - context: { - company, - user, - other: otherContext, - enableTracking: true, - }, - flags: {}, - }); - }); - - it("should use fallback flags when provided and no definitions available", async () => { - const fallbackTestFlags = { - fallbackFlag: { - key: "fallbackFlag", - isEnabled: true, - config: { key: "fallback-config", payload: { test: "data" } }, - }, - }; - - const clientWithFallback = new ReflagClient({ - ...validOptions, - fallbackFlags: fallbackTestFlags, - }); - - // Don't initialize to simulate no flag definitions - const flags = clientWithFallback.getFlagsForBootstrap({ - company, - user, - other: otherContext, - enableTracking: true, - }); - - expect(flags).toStrictEqual({ - context: { - company, - user, - other: otherContext, - enableTracking: true, - }, - flags: fallbackTestFlags, - }); - }); + evalContext: { company }, + }), + ]); + expect(h.checks()[0]).not.toHaveProperty("value"); + }); + it("fetches one complete /flags collection with SDK authentication", async () => { + const h = harness(); + await h.client.initialize(); + await h.client.initialize(); + expect(h.httpClient.get).toHaveBeenCalledTimes(1); + expect(h.httpClient.get).toHaveBeenCalledWith( + "https://api.example.com/flags", + expect.objectContaining({ + Authorization: "Bearer validSecretKeyWithMoreThan22Chars", + [SDK_VERSION_HEADER_NAME]: SDK_VERSION, + }), + expect.any(Number), + ); }); -}); - -describe("getFlagsRemote", () => { - let client: ReflagClient; - - beforeEach(async () => { - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - remoteContextUsed: true, - features: { - flag1: { - key: "flag1", - targetingVersion: 1, - isEnabled: true, - config: { - key: "config-1", - version: 3, - default: true, - payload: { something: "else" }, - missingContextFields: ["funny"], - }, - missingContextFields: ["something", "funny"], - }, - flag2: { - key: "flag2", - targetingVersion: 2, - isEnabled: false, - missingContextFields: ["another"], - }, - flag3: { - key: "flag3", - targetingVersion: 5, - isEnabled: true, - }, - }, + it("checks only the requested root, even when the caller ignores its value", async () => { + const h = harness(); + await h.client.initialize(); + h.client.getFlag({ company }, "dependent"); + await h.client.flush(); + expect(h.checks()).toEqual([ + expect.objectContaining({ key: "dependent", evalResult: "true" }), + ]); + }); + it("batch-evaluates roots and returns a value record", async () => { + const h = harness(); + await h.client.initialize(); + expect(h.client.getFlags({ company })).toEqual({ + model: "quality", + dependent: true, + }); + await h.client.flush(); + expect(h.checks().map((event) => event.evalResult)).toEqual([ + "quality", + "true", + ]); + }); + it("keeps successive evaluations scoped to their contexts", async () => { + const h = harness(); + await h.client.initialize(); + expect(h.client.getFlag({ company }, "dependent")).toBe(true); + expect( + h.client.getFlag({ company: { id: "small", plan: "free" } }, "dependent"), + ).toBe(false); + expect(h.client.getFlag({ company }, "dependent")).toBe(true); + }); + it("single evaluation does not diagnose unrelated roots", async () => { + const collection = graph(); + const unrelated = definition("unrelated", false); + unrelated.rules.unshift({ + id: "requires-context", + filter: { + type: "context", + field: "other.missing", + operator: "IS", + values: ["yes"], }, + result: { type: "variant", variantKey: "default" }, + }); + collection.flags.push(unrelated); + const h = harness(collection); + await h.client.initialize(); + h.client.getFlag({ company }, "dependent"); + await h.client.flush(); + expect(h.checks()).toHaveLength(1); + expect(h.logger.warn).not.toHaveBeenCalled(); + }); + it("preserves diagnostic provenance with a successful fallback selection", async () => { + const h = harness(); + await h.client.initialize(); + expect(h.client.getFlag({}, "dependent")).toBe(false); + await h.client.flush(); + expect(h.checks()[0]).toMatchObject({ + evalResult: "false", + evalErrors: [ + expect.objectContaining({ + code: "MISSING_CONTEXT_FIELD", + field: "company.plan", + source: { type: "segment", key: "enterprise" }, + }), + ], }); - - client = new ReflagClient(validOptions); }); - - afterEach(() => { - httpClient.get.mockClear(); - }); - - it("should return evaluated flags", async () => { - const result = await client.getFlagsRemote("c1", "u1", { - other: otherContext, - }); - - expect(result).toStrictEqual({ - flag1: { - key: "flag1", - isEnabled: true, - config: { - key: "config-1", - payload: { something: "else" }, - }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - flag3: { - key: "flag3", - isEnabled: true, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), - }, - }); - - expect(httpClient.get).toHaveBeenCalledTimes(1); - - expect(httpClient.get).toHaveBeenCalledWith( - `https://api.example.com/features/evaluated?${new URLSearchParams({ - contextJson: - '{"company":{"id":"u1"},"other":{"custom":"context","key":"value"},"user":{"id":"c1"}}', - })}`, - expectedHeaders, - API_TIMEOUT_MS, + it("does not let callers mutate cached variant payloads", async () => { + const h = harness(snapshot([definition("flag", { enabled: false })])); + await h.client.initialize(); + const value = h.client.getFlag({}, "flag", { enabled: false }); + value.enabled = true; + expect(h.client.getFlag({}, "flag", { enabled: true })).toEqual({ + enabled: false, + }); + const batch = h.client.getFlags({ enableTracking: false }); + (batch.flag as { enabled: boolean }).enabled = true; + expect(h.client.getFlag({}, "flag", { enabled: true })).toEqual({ + enabled: false, + }); + }); + it("captures check context before the caller mutates it", async () => { + const h = harness(); + await h.client.initialize(); + const mutable = { id: "acme", plan: "enterprise" }; + h.client.getFlag({ company: mutable }, "dependent"); + mutable.plan = "free"; + h.client.getFlag({ company: mutable }, "dependent"); + await h.client.flush(); + expect(h.checks()).toEqual([ + expect.objectContaining({ + evalResult: "true", + evalContext: { company: { id: "acme", plan: "enterprise" } }, + }), + expect.objectContaining({ + evalResult: "false", + evalContext: { company: { id: "acme", plan: "free" } }, + }), + ]); + }); + it("supports prototype-like keys without polluting records", async () => { + const h = harness( + snapshot([definition("__proto__", 3), definition("constructor", null)]), + ); + await h.client.initialize(); + expect(h.client.getFlag({}, "__proto__", 0)).toBe(3); + expect(h.client.getFlags({ enableTracking: false })).toEqual( + JSON.parse('{"__proto__":3,"constructor":null}'), ); }); +}); - it("sends array context as canonical JSON", async () => { - await client.getFlagsRemote(undefined, undefined, { - other: { - z: { second: 2, first: 1 }, - roles: ["admin", "editor"], - a: true, - }, - }); - - const requestUrl = new URL(httpClient.get.mock.calls[0][0]); - expect(requestUrl.searchParams.get("contextJson")).toBe( - '{"other":{"a":true,"roles":["admin","editor"],"z":{"first":1,"second":2}}}', - ); - expect(requestUrl.searchParams.has("context.other.roles.0")).toBe(false); +describe("default-value reads", () => { + it("distinguishes authored Boolean false from an identical implicit default", async () => { + const h = harness(snapshot([definition("release", false, "false")])); + await h.client.initialize(); + expect(h.client.getFlag({}, "release")).toBe(false); + expect(h.client.getFlag({}, "missing")).toBe(false); + await h.client.flush(); + expect(h.checks()).toEqual([ + expect.objectContaining({ + key: "release", + evalResult: "false", + sourceVersionId: "version:release", + }), + expect.objectContaining({ key: "missing", action: "check" }), + ]); + expect(h.checks()[1]).not.toHaveProperty("evalResult"); + expect(h.checks()[1]).not.toHaveProperty("sourceVersionId"); + }); + + it("uses an explicit default for a missing bound read without authored provenance", async () => { + const h = harness(snapshot()); + await h.client.initialize(); + expect( + h.client.bindClient({}).getFlag("settings", { model: "Swift" }), + ).toEqual({ model: "Swift" }); + await h.client.flush(); + expect(h.checks()).toEqual([expect.objectContaining({ key: "settings" })]); + expect(h.checks()[0]).not.toHaveProperty("evalResult"); + expect(h.checks()[0]).not.toHaveProperty("sourceVersionId"); }); it.each([ - ["absent", undefined], - ["effectively empty", { other: { nested: { value: undefined } } }], - ])("does not append %s context", async (_case, additionalContext) => { - await client.getFlagsRemote(undefined, undefined, additionalContext); - - expect(httpClient.get).toHaveBeenCalledTimes(1); + [false, true], + [0, 42], + ["", "fallback"], + [[], [1]], + [{}, { model: "Swift" }], + ])( + "preserves a selected falsy/empty value %j rather than the default", + async (value, defaultValue) => { + const h = harness(snapshot([definition("flag", value)])); + await h.client.initialize(); + expect(h.client.getFlag({}, "flag", defaultValue)).toEqual(value); + await h.client.flush(); + expect(h.checks()[0]).toMatchObject({ + evalResult: "default", + sourceVersionId: "version:flag", + }); + }, + ); - expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated", - expectedHeaders, - API_TIMEOUT_MS, + it.each([ + [null, { model: "Swift" }], + [[1], { model: "Swift" }], + ])( + "uses the default for a category mismatch without claiming a selected variant %#", + async (value, defaultValue) => { + const h = harness(snapshot([definition("flag", value)])); + await h.client.initialize(); + expect(h.client.getFlag({}, "flag", defaultValue)).toEqual(defaultValue); + await h.client.flush(); + expect(h.checks()).toEqual([expect.objectContaining({ key: "flag" })]); + expect(h.checks()[0]).not.toHaveProperty("evalResult"); + expect(h.checks()[0]).not.toHaveProperty("sourceVersionId"); + }, + ); + + it("keeps null as an explicit arbitrary-JSON read, including authored null", async () => { + const h = harness(snapshot([definition("flag", null)])); + await h.client.initialize(); + expect(h.client.getFlag({}, "flag", null)).toBeNull(); + expect(h.client.getFlag({}, "missing", null)).toBeNull(); + await h.client.flush(); + expect(h.checks()[0]).toMatchObject({ evalResult: "default" }); + expect(h.checks()[1]).not.toHaveProperty("evalResult"); + }); + + it("keeps Boolean defaults separate from authored payloads and dependent selections", async () => { + const collection = graph(); + collection.flags[0].variants.quality = { secret: "do-not-log-payload" }; + const h = harness(collection); + await h.client.initialize(); + expect(h.client.getFlag({ company }, "model")).toBe(false); + expect(h.client.getFlag({ company }, "dependent")).toBe(true); + await h.client.flush(); + expect(h.checks()[0]).not.toHaveProperty("evalResult"); + expect(h.checks()[0]).not.toHaveProperty("sourceVersionId"); + expect(h.checks()[1]).toMatchObject({ + key: "dependent", + evalResult: "true", + }); + expect(h.logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Using the default value"), + ); + expect(JSON.stringify(h.logger.warn.mock.calls)).not.toContain( + "do-not-log-payload", ); }); -}); -describe("getFlagRemote", () => { - let client: ReflagClient; - - beforeEach(async () => { - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - remoteContextUsed: true, - features: { - flag1: { - key: "flag1", - targetingVersion: 1, - isEnabled: true, - config: { - key: "config-1", - version: 3, - default: true, - payload: { something: "else" }, - missingContextFields: ["two"], - }, - missingContextFields: ["one", "two"], - }, + it("returns independent mutable copies of caller defaults", async () => { + const h = harness(snapshot()); + await h.client.initialize(); + const fallback = { nested: { model: "Swift" }, items: [1] }; + const value = h.client.getFlag({}, "missing", fallback); + value.nested.model = "changed"; + value.items.push(2); + expect(fallback).toEqual({ nested: { model: "Swift" }, items: [1] }); + const frozen = Object.freeze({ model: "Swift", items: Object.freeze([1]) }); + expect(h.client.getFlag({}, "missing", frozen)).toEqual({ + model: "Swift", + items: [1], + }); + }); + + it("validates local and remote defaults before evaluation or HTTP", async () => { + const h = harness(); + const defaultValue = { nested: undefined }; + expect(() => + Reflect.apply(h.client.getFlag, h.client, [{}, "flag", defaultValue]), + ).toThrow("JSON value"); + await expect( + Reflect.apply(h.client.getFlagRemote, h.client, ["flag", defaultValue]), + ).rejects.toThrow("JSON value"); + expect(h.httpClient.get).not.toHaveBeenCalled(); + await h.client.flush(); + expect(h.checks()).toEqual([]); + }); + + it("applies remote defaults without authenticating a fabricated selection", async () => { + const h = harness({ + protocolVersion: 2, + flagStateVersion: 7, + flags: { + model: { + value: { model: "Reasoner" }, + variantKey: "quality", + sourceVersionId: "remote-version", + matchedRuleId: "r", + ruleResults: [], + errors: [], }, }, }); - - client = new ReflagClient(validOptions); - }); - - afterEach(() => { - httpClient.get.mockClear(); - }); - - it("should return evaluated flag", async () => { - const result = await client.getFlagRemote("flag1", "c1", "u1", { - other: otherContext, - }); - - expect(result).toStrictEqual({ - key: "flag1", - isEnabled: true, - track: expect.any(Function), - config: { - key: "config-1", - payload: { something: "else" }, - }, - }); - - expect(httpClient.get).toHaveBeenCalledTimes(1); - - expect(httpClient.get).toHaveBeenCalledWith( - `https://api.example.com/features/evaluated?${new URLSearchParams({ - contextJson: - '{"company":{"id":"u1"},"other":{"custom":"context","key":"value"},"user":{"id":"c1"}}', - key: "flag1", - })}`, - expectedHeaders, - API_TIMEOUT_MS, - ); - }); - - it("should not try to append the context if it's empty", async () => { - await client.getFlagRemote("flag1"); - - expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?key=flag1", - expectedHeaders, - API_TIMEOUT_MS, + expect(await h.client.getFlagRemote("model")).toBe(false); + expect( + await h.client.bindClient({}).getFlagRemote("model", { model: "Swift" }), + ).toEqual({ model: "Reasoner" }); + expect(await h.client.getFlagRemote("missing", { model: "Swift" })).toEqual( + { model: "Swift" }, ); + await h.client.flush(); + expect(h.checks()).toHaveLength(3); + expect(h.checks()[0]).not.toHaveProperty("evalResult"); + expect(h.checks()[1]).toMatchObject({ + evalResult: "quality", + sourceVersionId: "remote-version", + flagStateVersion: 7, + }); + expect(h.checks()[2]).not.toHaveProperty("sourceVersionId"); }); -}); - -describe("offline mode", () => { - let client: ReflagClient; - beforeEach(async () => { - client = new ReflagClient({ - ...validOptions, + it("uses local defaults and overrides for offline remote reads without tracking", async () => { + const h = harness(graph(), { offline: true, + flagOverrides: { model: { model: "Swift" } }, }); - await client.initialize(); - }); - - it("should send not send or fetch anything", async () => { - client.getFlags({}); - - expect(httpClient.get).toHaveBeenCalledTimes(0); - expect(httpClient.post).toHaveBeenCalledTimes(0); + await h.client.initialize(); + expect( + await h.client.getFlagRemote("model", { model: "Reasoner" }), + ).toEqual({ model: "Swift" }); + expect(await h.client.bindClient({}).getFlagRemote("missing")).toBe(false); + expect(h.httpClient.get).not.toHaveBeenCalled(); + expect(h.checks()).toEqual([]); }); }); -describe("BoundReflagClient", () => { - beforeAll(() => { - const response = { - status: 200, - body: { success: true }, +describe("check identity and context", () => { + it("deduplicates equivalent normalized contexts within the check window", async () => { + const h = harness(); + await h.client.initialize(); + h.client.getFlag({ company, other: { n: 1 } }, "model", "quick"); + h.client.getFlag( + { other: { n: "1" }, company: { plan: "enterprise", id: "acme" } }, + "model", + "quick", + ); + await h.client.flush(); + expect(h.checks()).toHaveLength(1); + await vi.advanceTimersByTimeAsync( + FLAG_EVENT_RATE_LIMITER_WINDOW_SIZE_MS + 1, + ); + h.client.getFlag({ company, other: { n: 1 } }, "model", "quick"); + await h.client.flush(); + expect(h.checks()).toHaveLength(2); + }); + it("distinguishes equal-valued variants for the same normalized context", async () => { + const flag = definition("flag", null, "first"); + flag.variants.second = null; + const response = snapshot([flag]); + const h = harness(response); + await h.client.initialize(); + h.client.getFlag({}, "flag", null); + // Simulate a new immutable accepted definition with an unchanged snapshot number. + const changed = structuredClone(response); + changed.flags[0].rules[0].result = { + type: "variant", + variantKey: "second", }; - - httpClient.post.mockResolvedValue(response); - - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - ...flagDefinitions, + h.state.response = changed; + await nextRefresh(h.client); + h.client.getFlag({}, "flag", null); + await h.client.flush(); + expect(h.checks().map((event) => event.evalResult)).toEqual([ + "first", + "second", + ]); + }); + it("includes the snapshot version when only a dependency changes", async () => { + const h = harness(); + await h.client.initialize(); + expect(h.client.getFlag({ company }, "dependent")).toBe(true); + const changed = graph(); + changed.flagStateVersion = 2; + changed.segments[0].filter = { type: "constant", value: false }; + h.state.response = changed; + await nextRefresh(h.client, 2); + expect(h.client.getFlag({ company }, "dependent")).toBe(false); + await h.client.flush(); + expect(h.checks()).toEqual([ + expect.objectContaining({ + sourceVersionId: "version:dependent", + flagStateVersion: 1, + evalResult: "true", + }), + expect.objectContaining({ + sourceVersionId: "version:dependent", + flagStateVersion: 2, + evalResult: "false", + }), + ]); + }); + it("registers a new check when only the definition version changes", async () => { + const h = harness(); + await h.client.initialize(); + h.client.getFlag({ company }, "model", "quick"); + const changed = graph(); + changed.flags[0].sourceVersionId = "updated"; + h.state.response = changed; + await nextRefresh(h.client); + h.client.getFlag({ company }, "model", "quick"); + await h.client.flush(); + expect(h.checks().map((event) => event.sourceVersionId)).toEqual([ + "version:model", + "updated", + ]); + }); + it("disables both context synchronization and checks when requested", async () => { + const h = harness(); + await h.client.initialize(); + h.client.getFlag( + { company, user: { id: "u" }, enableTracking: false }, + "model", + "quick", + ); + h.client.getFlags({ company, enableTracking: false }); + await h.client.flush(); + expect(h.events()).toEqual([]); + }); + it("preserves numeric zero IDs for ordinary tracking", async () => { + const h = harness(); + await h.client.initialize(); + h.client.getFlag( + { + company: { id: 0, plan: "free" }, + user: { id: 0 }, + meta: { active: false }, }, - }); - }); - const client = new ReflagClient(validOptions); - - beforeEach(async () => { - await flushPromises(); - await client.flush(); - - vi.mocked(httpClient.post).mockClear(); - client["rateLimiter"].clearStale(true); - }); - - it("should create a client instance", () => { - expect(client).toBeInstanceOf(ReflagClient); - }); - - it("should return a new client instance with merged attributes", () => { - const userOverride = { sex: "male", age: 30 }; - const companyOverride = { employees: 200, bankrupt: false }; - const otherOverride = { key: "new-value" }; - const other = { key: "value" }; - - const newClient = client - .bindClient({ - user, - company, - other, - }) - .bindClient({ - user: { id: user.id, ...userOverride }, - company: { id: company.id, ...companyOverride }, - other: otherOverride, - }); - - expect(newClient["_options"]).toEqual({ - user: { ...user, ...userOverride }, - company: { ...company, ...companyOverride }, - other: { ...other, ...otherOverride }, - enableTracking: true, - }); + "model", + "quick", + ); + await h.client.track(0, "clicked", { companyId: 0 }); + await h.client.flush(); + expect(h.events()).toContainEqual( + expect.objectContaining({ + type: "company", + companyId: 0, + userId: 0, + context: { active: false }, + }), + ); + expect(h.events()).toContainEqual( + expect.objectContaining({ type: "event", userId: 0, companyId: 0 }), + ); }); +}); - it("should preserve existing user and company when omitted on rebind", () => { - const reboundClient = client - .bindClient({ user, company }) - .bindClient({ other: otherContext }); - - expect(reboundClient["_options"]).toEqual({ - user, +describe("bootstrap, bound clients, and overrides", () => { + it("bootstraps selections and version without registering checks", async () => { + const h = harness(); + await h.client.initialize(); + const bootstrap = h.client.getFlagsForBootstrap({ company, - other: otherContext, - enableTracking: true, + other: { raw: 42 }, }); - }); - - it("should preserve existing other context when omitted on rebind", () => { - const reboundClient = client - .bindClient({ other: otherContext }) - .bindClient({ user }); - - expect(reboundClient["_options"]).toEqual({ - user, - other: otherContext, - enableTracking: true, + expect(bootstrap).toMatchObject({ + protocolVersion: 2, + flagStateVersion: 1, + context: { company, other: { raw: 42 } }, + flags: { + model: { resolved: true, value: "quality", variantKey: "quality" }, + }, + }); + await h.client.flush(); + expect(h.checks()).toEqual([]); + }); + it("binds and merges context while preserving direct values and ordinary track", async () => { + const h = harness(); + await h.client.initialize(); + const bound = h.client + .bindClient({ company: { id: "acme", plan: "free" }, user: { id: 0 } }) + .bindClient({ company }); + expect(bound.getFlag("model", "quick")).toBe("quality"); + expect(bound.getFlags()).toEqual({ model: "quality", dependent: true }); + expect(bound.getFlagsForBootstrap().context).toMatchObject({ + user: { id: 0 }, + company, }); + await bound.track("selected"); + await bound.flush(); + expect(h.events()).toContainEqual( + expect.objectContaining({ + type: "event", + userId: 0, + companyId: "acme", + event: "selected", + }), + ); }); - - it("should allow using expected methods when bound to user", async () => { - const boundClient = client.bindClient({ user }); - expect(boundClient.user).toEqual(user); - - expect( - boundClient.bindClient({ other: otherContext }).otherContext, - ).toEqual(otherContext); - - boundClient.getFlags(); - - await boundClient.track("flag"); - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - expect.objectContaining({ type: "user" }), - { - event: "flag", - type: "event", - userId: "user123", - }, - ], + it.each([false, null, { payload: 1 }, [1, 2], "override"])( + "returns a direct override %j without claiming an authored selection", + async (value) => { + const h = harness(graph(), { flagOverrides: { model: value } }); + await h.client.initialize(); + expect(h.client.getFlag({ company }, "model", null)).toEqual(value); + // Overrides remain post-evaluation; they do not rewrite dependency results. + expect(h.client.getFlag({ company }, "dependent")).toBe(true); + await h.client.flush(); + expect(h.checks()[0]).not.toHaveProperty("evalResult"); + expect(h.checks()[0]).not.toHaveProperty("sourceVersionId"); + }, + ); + it("retains explicit tracking disablement when merged options are undefined", async () => { + const h = harness(); + await h.client.initialize(); + const bound = h.client + .bindClient({ company, enableTracking: false }) + .bindClient({ enableTracking: undefined, other: { n: 1 } }); + expect(bound.getFlag("model", "quick")).toBe("quality"); + await bound.flush(); + expect(h.events()).toEqual([]); + }); + it("supports context functions and disposable override layers", async () => { + const h = harness(graph(), { + flagOverrides: (context) => ({ + model: context.company?.id === "acme" ? 1 : 0, + }), + }); + await h.client.initialize(); + const pop = h.client.pushFlagOverrides({ model: null, extra: false }); + expect(h.client.getFlags({ company, enableTracking: false })).toMatchObject( + { model: null, extra: false }, ); + pop(); + pop(); + expect(h.client.getFlag({ company }, "model", 0)).toBe(1); + h.client.setFlagOverrides({ model: "base" }); + expect(h.client.getFlag({ company }, "model", "quick")).toBe("base"); + h.client.clearFlagOverrides(); + expect(h.client.getFlag({ company }, "model", "quick")).toBe("quality"); + }); + it("keeps prototype-like override keys", async () => { + const h = harness(snapshot(), { + flagOverrides: JSON.parse('{"__proto__":false}'), + }); + await h.client.initialize(); + expect(h.client.getFlag({}, "__proto__")).toBe(false); + }); + it("works offline without fetching, checks, or ordinary tracking", async () => { + const h = harness(graph(), { + offline: true, + flagOverrides: { model: null }, + }); + await h.client.initialize(); + expect(h.client.getFlag({ company }, "model", null)).toBeNull(); + await h.client.track(0, "event"); + await h.client.flush(); + expect(h.httpClient.get).not.toHaveBeenCalled(); + expect(h.events()).toEqual([]); }); +}); - it("should add company ID from the context if not explicitly supplied", async () => { - const boundClient = client.bindClient({ user, company }); - - boundClient.getFlags(); - await boundClient.track("flag"); - - await client.flush(); - - expect(httpClient.post).toHaveBeenCalledWith( - BULK_ENDPOINT, - expectedHeaders, - [ - expect.objectContaining({ type: "company" }), - expect.objectContaining({ type: "user" }), - { - companyId: "company123", - event: "flag", - type: "event", - userId: "user123", - }, - ], +describe("atomic refresh and complete fallback snapshots", () => { + it("replaces flags, segments, and version together", async () => { + const h = harness(); + await h.client.initialize(); + const changed = graph(); + changed.flagStateVersion = 2; + changed.segments[0].filter = { type: "constant", value: false }; + changed.flags[0].variants.quick = "new"; + h.state.response = changed; + await nextRefresh(h.client, 2); + expect(h.client.getFlags({ company, enableTracking: false })).toEqual({ + model: "new", + dependent: false, + }); + expect(h.client.getFlagsForBootstrap({ company }).flagStateVersion).toBe(2); + expect(h.httpClient.get).toHaveBeenLastCalledWith( + "https://api.example.com/flags?waitForVersion=2", + expect.anything(), + expect.any(Number), ); }); - - it("should disable tracking within the client if `enableTracking` is `false`", async () => { - const boundClient = client.bindClient({ - user, - company, - enableTracking: false, + it("rejects stale responses before saving them to a fallback provider", async () => { + const save = vi.fn(); + const h = harness(snapshot([], [], 5), { + flagsFallbackProvider: { load: vi.fn(), save }, }); - - const { track } = boundClient.getFlag("flag2"); - await track(); - await boundClient.track("flag1"); - - await client.flush(); - - expect(httpClient.post).not.toHaveBeenCalled(); + await h.client.initialize(); + h.state.response = graph(); + await nextRefresh(h.client); + await h.client.flush(); + expect(h.client.getFlagDefinitions()).toEqual({ flags: [], segments: [] }); + expect(h.client.getFlagsForBootstrap({}).flagStateVersion).toBe(5); + expect(save).toHaveBeenCalledTimes(1); }); - - it("should allow using expected methods", async () => { - const boundClient = client.bindClient({ other: { key: "value" } }); - expect(boundClient.otherContext).toEqual({ - key: "value", + it.each([ + { protocolVersion: 2, flagStateVersion: 2, flags: [] }, + { ...snapshot(), flagStateVersion: -1 }, + { ...snapshot(), protocolVersion: 1 }, + snapshot( + [definition("bad", 1)], + [{ key: "segment", filter: { type: "segment", key: "missing" } }], + 2, + ), + ])( + "retains a last-known-good graph after invalid refresh %#", + async (invalid) => { + const h = harness(); + await h.client.initialize(); + h.state.response = invalid; + await nextRefresh(h.client); + expect( + h.client.getFlag({ company, enableTracking: false }, "dependent"), + ).toBe(true); + expect(h.client.getFlagsForBootstrap({ company }).flagStateVersion).toBe( + 1, + ); + }, + ); + it("does not expose mutable definitions or its prepared evaluator", async () => { + const h = harness(); + await h.client.initialize(); + const definitions = h.client.getFlagDefinitions(); + definitions.segments.length = 0; + expect( + h.client.getFlag({ company, enableTracking: false }, "dependent"), + ).toBe(true); + expect(definitions).not.toHaveProperty("evaluator"); + }); + it("persists and restores the full graph and snapshot version", async () => { + let saved: FlagsFallbackSnapshot | undefined; + const provider = { + load: vi.fn(async () => saved), + save: vi.fn(async (_context: unknown, next: FlagsFallbackSnapshot) => { + saved = next; + }), + }; + const live = harness(graph(), { flagsFallbackProvider: provider }); + await live.client.initialize(); + await live.client.flush(); + expect(saved).toMatchObject({ + version: 2, + flagStateVersion: 1, + flags: graph().flags, + segments: graph().segments, + }); + const fallback = harness(undefined, { flagsFallbackProvider: provider }); + fallback.state.response = undefined; + await fallback.client.initialize(); + expect(fallback.client.getFlag({ company }, "dependent")).toBe(true); + await fallback.client.flush(); + expect(fallback.checks()[0]).toMatchObject({ + evalResult: "true", + sourceVersionId: "version:dependent", + flagStateVersion: 1, }); - - await client.initialize(); - - boundClient.getFlags(); - boundClient.getFlag("flag1"); - - await boundClient.flush(); + expect(provider.save).toHaveBeenCalledTimes(1); }); - - it("should return raw flags for bootstrap from bound client", async () => { - // Ensure client is properly initialized - await client.initialize(); - const boundClient = client.bindClient({ - user, - company, - other: otherContext, - enableTracking: true, - }); - - const result = boundClient.getFlagsForBootstrap(); - - expect(result).toStrictEqual({ - context: { - user, - company, - other: otherContext, - enableTracking: true, - }, - flags: { - flag1: { - key: "flag1", - isEnabled: true, - targetingVersion: 1, - config: { - key: "config-1", - payload: { - something: "else", - }, - targetingVersion: 1, - missingContextFields: [], - ruleEvaluationResults: [true], - }, - ruleEvaluationResults: [true], - missingContextFields: [], - }, - flag2: { - key: "flag2", - isEnabled: false, - targetingVersion: 2, - config: { - key: undefined, - payload: undefined, - targetingVersion: undefined, - missingContextFields: [], - ruleEvaluationResults: [], + it.each([1, 2])( + "rejects incomplete or legacy fallback snapshots (version %i)", + async (version) => { + const h = harness( + {}, + { + flagsFallbackProvider: { + load: vi.fn( + async () => + ({ + version, + savedAt: new Date().toISOString(), + flags: graph().flags, + }) as FlagsFallbackSnapshot, + ), + save: vi.fn(), }, - ruleEvaluationResults: [false], - missingContextFields: ["attributeKey"], - evaluationErrors: [missingContextFieldError("attributeKey")], }, + ); + await h.client.initialize(); + expect(h.client.getFlagDefinitions()).toEqual({ + flags: [], + segments: [], + }); + }, + ); + it("does not reload stale fallback state after a successful live refresh", async () => { + const load = vi.fn(); + const h = harness(graph(), { + flagsFallbackProvider: { load, save: vi.fn() }, + }); + await h.client.initialize(); + h.state.response = {}; + await nextRefresh(h.client); + expect(load).not.toHaveBeenCalled(); + }); + it("uses static JSON fallback values without an authored variant claim", async () => { + const h = harness( + {}, + { + flagsFallbackProvider: fallbackProviders.static({ + flags: { model: null, bool: false }, + }), }, - flagStateVersion: 1, + ); + await h.client.initialize(); + expect(h.client.getFlag({ company }, "model", null)).toBeNull(); + expect(h.client.getFlag({}, "bool")).toBe(false); + await h.client.flush(); + for (const event of h.checks()) { + expect(event).not.toHaveProperty("evalResult"); + expect(event).not.toHaveProperty("sourceVersionId"); + } + }); + it("uses value-only configured fallbacks without an authored variant claim", async () => { + const h = harness({}, { fallbackFlags: { flag: false } }); + await h.client.initialize(); + expect(h.client.getFlag({}, "flag")).toBe(false); + await h.client.flush(); + expect(h.checks()[0]).not.toHaveProperty("evalResult"); + }); + it("serializes fallback saves", async () => { + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const save = vi + .fn() + .mockImplementationOnce(() => pending) + .mockResolvedValue(undefined); + const h = harness(graph(), { + flagsFallbackProvider: { load: vi.fn(), save }, + }); + await h.client.initialize(); + h.state.response = snapshot([], [], 2); + await nextRefresh(h.client, 2); + expect(save).toHaveBeenCalledTimes(1); + finish(); + await h.client.flush(); + expect(save).toHaveBeenCalledTimes(2); + expect(save.mock.calls[1][1]).toMatchObject({ + flagStateVersion: 2, + flags: [], + segments: [], }); - - // Should not have track function like regular getFlags - expect(result.flags.flag1).not.toHaveProperty("track"); - expect(result.flags.flag2).not.toHaveProperty("track"); }); +}); - describe("getFlagRemote/getFlagsRemote", () => { - beforeEach(async () => { - httpClient.get.mockClear(); - httpClient.get.mockResolvedValue({ - ok: true, - status: 200, - body: { - success: true, - remoteContextUsed: true, - features: { - flag1: { - key: "flag1", - targetingVersion: 1, - isEnabled: true, - config: { - key: "config-1", - version: 3, - default: true, - payload: { something: "else" }, - missingContextFields: ["else"], - }, - }, - flag2: { - key: "flag2", - targetingVersion: 2, - isEnabled: false, - missingContextFields: ["something"], - }, - }, +describe("remote values", () => { + it("returns direct values, preserves explicit zero IDs, and registers selected keys", async () => { + const result = { + value: null, + variantKey: "quality", + sourceVersionId: "remote-version", + matchedRuleId: "r", + ruleResults: [], + errors: [], + }; + const h = harness({ + protocolVersion: 2, + flagStateVersion: 9, + flags: { model: result }, + remoteContextUsed: false, + }); + expect(await h.client.getFlagRemote("model", null, 0, 0)).toBeNull(); + await h.client.flush(); + expect(h.checks()[0]).toMatchObject({ + evalResult: "quality", + sourceVersionId: "remote-version", + flagStateVersion: 9, + evalContext: { user: { id: 0 }, company: { id: 0 } }, + }); + const url = new URL(h.httpClient.get.mock.calls[0][0]); + expect(url.pathname).toBe("/flags/evaluated"); + expect(JSON.parse(url.searchParams.get("contextJson")!)).toEqual({ + user: { id: 0 }, + company: { id: 0 }, + }); + }); + it("honors bound enableTracking=false for remote calls", async () => { + const h = harness({ + protocolVersion: 2, + flagStateVersion: 1, + flags: { + model: { + value: false, + variantKey: "false", + sourceVersionId: "v", + matchedRuleId: "r", + ruleResults: [], + errors: [], }, - }); + }, }); - - it("should return evaluated flags", async () => { - const boundClient = client.bindClient({ - user, - company, - other: otherContext, - }); - - const result = await boundClient.getFlagsRemote(); - - expect(result).toStrictEqual({ - flag1: { - key: "flag1", - isEnabled: true, - config: { key: "config-1", payload: { something: "else" } }, - track: expect.any(Function), - }, - flag2: { - key: "flag2", - isEnabled: false, - config: { key: undefined, payload: undefined }, - track: expect.any(Function), + expect( + await h.client + .bindClient({ company, enableTracking: false }) + .getFlagRemote("model"), + ).toBe(false); + await h.client.flush(); + expect(h.checks()).toEqual([]); + }); + it.each([ + { ruleResults: [null] }, + { errors: [null] }, + { sourceVersionId: "" }, + { variantKey: "" }, + ])("rejects malformed remote selection metadata %#", async (invalid) => { + const h = harness({ + protocolVersion: 2, + flagStateVersion: 1, + flags: { + model: { + value: false, + variantKey: "false", + sourceVersionId: "v", + matchedRuleId: "r", + ruleResults: [], + errors: [], + ...invalid, }, - }); - - expect(httpClient.get).toHaveBeenCalledTimes(1); - - expect(httpClient.get).toHaveBeenCalledWith( - `https://api.example.com/features/evaluated?${new URLSearchParams({ - contextJson: - '{"company":{"employees":100,"id":"company123","name":"Acme Inc."},"other":{"custom":"context","key":"value"},"user":{"age":1,"id":"user123","name":"John"}}', - })}`, - expectedHeaders, - API_TIMEOUT_MS, - ); - }); - - it("should return evaluated flag", async () => { - const boundClient = client.bindClient({ - user, - company, - other: otherContext, - }); - - const result = await boundClient.getFlagRemote("flag1"); - - expect(result).toStrictEqual({ - key: "flag1", - isEnabled: true, - config: { key: "config-1", payload: { something: "else" } }, - track: expect.any(Function), - }); - - expect(httpClient.get).toHaveBeenCalledTimes(1); - - expect(httpClient.get).toHaveBeenCalledWith( - `https://api.example.com/features/evaluated?${new URLSearchParams({ - contextJson: - '{"company":{"employees":100,"id":"company123","name":"Acme Inc."},"other":{"custom":"context","key":"value"},"user":{"age":1,"id":"user123","name":"John"}}', - key: "flag1", - })}`, - expectedHeaders, - API_TIMEOUT_MS, - ); + }, }); + expect(await h.client.getFlagRemote("model")).toBe(false); + await h.client.flush(); + expect(h.checks()).toEqual([expect.objectContaining({ key: "model" })]); + expect(h.checks()[0]).not.toHaveProperty("evalResult"); + expect(h.checks()[0]).not.toHaveProperty("sourceVersionId"); + }); + it("returns false without fabricating a remote selection after failure", async () => { + const h = harness({}); + expect(await h.client.getFlagRemote("missing")).toBe(false); + await h.client.flush(); + expect(h.checks()).toEqual([expect.objectContaining({ key: "missing" })]); + expect(h.checks()[0]).not.toHaveProperty("evalResult"); + expect(h.checks()[0]).not.toHaveProperty("sourceVersionId"); }); }); diff --git a/packages/node-sdk/test/fixtures.ts b/packages/node-sdk/test/fixtures.ts new file mode 100644 index 000000000..c290aaf07 --- /dev/null +++ b/packages/node-sdk/test/fixtures.ts @@ -0,0 +1,62 @@ +import type { + FlagCollection, + FlagDefinition, + FlagValue, + FlagsAPIResponse, +} from "../src"; + +export function definition( + key: string, + value: FlagValue, + variantKey = "default", +): FlagDefinition { + return { + key, + sourceVersionId: `version:${key}`, + variants: { [variantKey]: value }, + rules: [ + { + id: `default:${key}`, + filter: { type: "constant", value: true }, + result: { type: "variant", variantKey }, + }, + ], + }; +} +export function snapshot( + flags: FlagDefinition[] = [], + segments: FlagCollection["segments"] = [], + flagStateVersion = 1, +): FlagsAPIResponse { + return { protocolVersion: 2, flagStateVersion, flags, segments }; +} +export function graph(): FlagsAPIResponse { + const model = definition("model", "quick", "quick"); + model.variants.quality = "quality"; + model.rules.unshift({ + id: "enterprise", + filter: { type: "segment", key: "enterprise" }, + result: { type: "variant", variantKey: "quality" }, + }); + const dependent = definition("dependent", false, "false"); + dependent.variants.true = true; + dependent.rules.unshift({ + id: "quality-model", + filter: { type: "flag", key: "model", variantKey: "quality" }, + result: { type: "variant", variantKey: "true" }, + }); + return snapshot( + [model, dependent], + [ + { + key: "enterprise", + filter: { + type: "context", + field: "company.plan", + operator: "IS", + values: ["enterprise"], + }, + }, + ], + ); +} diff --git a/packages/node-sdk/test/flag-definitions.test.ts b/packages/node-sdk/test/flag-definitions.test.ts new file mode 100644 index 000000000..9d99ce75e --- /dev/null +++ b/packages/node-sdk/test/flag-definitions.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { isFlagValue, prepareFlagCollection } from "../src/flag-definitions"; +import { definition, graph, snapshot } from "./fixtures"; + +describe("collection preparation", () => { + it("owns complete reference definitions before preparing evaluation", () => { + const original = graph(); + const collection = prepareFlagCollection(original); + expect(collection.flags).toEqual(original.flags); + expect(collection.segments).toEqual(original.segments); + original.segments[0].filter = { type: "constant", value: false }; + expect( + collection.evaluator.evaluateFlag( + { company: { plan: "enterprise" } }, + "dependent", + ).value, + ).toBe(true); + }); + it.each([ + undefined, + null, + {}, + { flags: [] }, + { flags: {}, segments: [] }, + { flags: [], segments: {} }, + { flags: [{ key: "invalid" }], segments: [] }, + snapshot([definition("duplicate", true), definition("duplicate", false)]), + snapshot([], [{ key: "cycle", filter: { type: "segment", key: "cycle" } }]), + snapshot( + [], + [ + { + key: "missing", + filter: { type: "flag", key: "absent", variantKey: "true" }, + }, + ], + ), + ])("rejects malformed/incomplete graphs %# before caching them", (value) => + expect(() => prepareFlagCollection(value)).toThrow(), + ); + it.each([ + { type: "group", operator: "xor", filters: [] }, + { type: "context", field: "x", operator: "IS", values: [1] }, + { type: "context", field: "x", operator: "UNKNOWN" }, + { + type: "rolloutPercentage", + key: "x", + partialRolloutAttribute: "id", + partialRolloutThreshold: NaN, + }, + { type: "segment", key: "absent" }, + { type: "flag", key: "dependent", variantKey: "absent" }, + ])("rejects invalid filter %#", (filter) => { + const value = graph(); + value.flags[0].rules[0].filter = filter as never; + expect(() => prepareFlagCollection(value)).toThrow(); + }); + it.each([ + { type: "variant", variantKey: "absent" }, + { + type: "percentageDistribution", + key: "d", + attribute: "id", + allocations: [ + { + percentage: 99, + destination: { type: "variant", variantKey: "default" }, + }, + ], + }, + { + type: "percentageDistribution", + key: "d", + attribute: "id", + allocations: [ + { + percentage: -1, + destination: { type: "variant", variantKey: "default" }, + }, + { percentage: 101, destination: { type: "nextRule" } }, + ], + }, + ])("rejects invalid rule results %#", (result) => { + const value = snapshot([definition("a", true)]); + value.flags[0].rules.unshift({ + ...value.flags[0].rules[0], + id: "invalid", + result: result as never, + }); + expect(() => prepareFlagCollection(value)).toThrow(); + }); + it("requires a fixed catch-all and unique rule identities", () => { + const value = graph(); + value.flags[0].rules.pop(); + expect(() => prepareFlagCollection(value)).toThrow(); + const duplicate = graph(); + duplicate.flags[0].rules[0].id = duplicate.flags[0].rules[1].id; + expect(() => prepareFlagCollection(duplicate)).toThrow(); + }); + it.each([undefined, NaN, Infinity, 1n, () => true, new Date(), new Map()])( + "rejects non-JSON values %#", + (value) => expect(isFlagValue(value)).toBe(false), + ); + it("rejects cycles but accepts shared JSON subobjects", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(isFlagValue(cyclic)).toBe(false); + const shared = { enabled: false }; + expect(isFlagValue({ a: shared, b: shared })).toBe(true); + }); +}); diff --git a/packages/node-sdk/test/flagsFallbackProvider.test.ts b/packages/node-sdk/test/flagsFallbackProvider.test.ts index c3d4382f7..05af5a613 100644 --- a/packages/node-sdk/test/flagsFallbackProvider.test.ts +++ b/packages/node-sdk/test/flagsFallbackProvider.test.ts @@ -4,420 +4,219 @@ import path from "path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { FlagsFallbackProviderContext } from "../src"; -import { fallbackProviders } from "../src"; +import { fallbackProviders, type FlagsFallbackSnapshot } from "../src"; +import { graph } from "./fixtures"; -const context: FlagsFallbackProviderContext = { - secretKeyHash: "abc123def456ghi789", -}; - -const snapshot = { - version: 1 as const, +const context = { secretKeyHash: "abc123def456ghi789" }; +const snapshot: FlagsFallbackSnapshot = { + version: 2, savedAt: "2026-03-09T00:00:00.000Z", - flags: [ - { - key: "flag-1", - description: "Flag 1", - targeting: { - version: 1, - rules: [], - }, - }, - ], + flagStateVersion: 1, + flags: graph().flags, + segments: graph().segments, }; +const filename = `flags-fallback-v2-${context.secretKeyHash.slice(0, 16)}.json`; +const objectKey = `reflag/flags-fallback/${filename}`; describe("flagsFallbackProvider", () => { let tempDir: string | undefined; - afterEach(async () => { vi.restoreAllMocks(); - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }); - tempDir = undefined; - } + if (tempDir) await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; }); - it("loads static snapshots from a flag map", async () => { + it("creates complete static snapshots with explicitly local selection provenance", async () => { const provider = fallbackProviders.static({ - flags: { - "flag-1": true, - "flag-2": false, - }, + flags: { a: true, b: false, json: null }, }); - - await expect(provider.load(context)).resolves.toEqual({ - version: 1, + const result = await provider.load(context); + expect(result).toMatchObject({ + version: 2, + source: "static", savedAt: expect.any(String), - flags: [ - { - key: "flag-1", - description: null, - targeting: { - version: 1, - rules: [ - { - filter: { - type: "constant", - value: true, - }, - }, - ], - }, - }, - { - key: "flag-2", - description: null, - targeting: { - version: 1, - rules: [ - { - filter: { - type: "constant", - value: false, - }, - }, - ], - }, - }, - ], + segments: [], }); + expect(result?.flags.map((flag) => flag.variants.default)).toEqual([ + true, + false, + null, + ]); }); - - it("loads undefined when a file snapshot does not exist", async () => { + it("returns undefined for missing files", async () => { tempDir = await mkdtemp(path.join(os.tmpdir(), "reflag-node-sdk-")); - const provider = fallbackProviders.file({ directory: tempDir }); - - await expect(provider.load(context)).resolves.toBeUndefined(); + await expect( + fallbackProviders.file({ directory: tempDir }).load(context), + ).resolves.toBeUndefined(); }); - - it("saves and loads file snapshots", async () => { + it("round-trips full file snapshots in a versioned namespace", async () => { tempDir = await mkdtemp(path.join(os.tmpdir(), "reflag-node-sdk-")); const provider = fallbackProviders.file({ directory: tempDir }); - await provider.save(context, snapshot); - - const files = await readFile( - path.join( - tempDir, - `flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, - ), - "utf-8", - ); - expect(JSON.parse(files)).toEqual(snapshot); - + expect( + JSON.parse(await readFile(path.join(tempDir, filename), "utf8")), + ).toEqual(snapshot); await expect(provider.load(context)).resolves.toEqual(snapshot); }); - - it("loads S3 snapshots", async () => { - const send = vi.fn().mockResolvedValue({ - Body: { - transformToString: vi.fn().mockResolvedValue(JSON.stringify(snapshot)), - }, - }); - - const provider = fallbackProviders.s3({ - bucket: "bucket-name", - client: { send }, + it.each([ + { ...snapshot, version: 1 }, + { ...snapshot, segments: undefined }, + { ...snapshot, flagStateVersion: -1 }, + { ...snapshot, savedAt: "invalid" }, + ])("rejects incompatible fallback envelopes %#", async (value) => { + const provider = fallbackProviders.redis({ + client: { get: vi.fn(async () => JSON.stringify(value)), set: vi.fn() }, }); - - await expect(provider.load(context)).resolves.toEqual(snapshot); - expect(send).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - Bucket: "bucket-name", - Key: expect.stringMatching(/^reflag\/flags-fallback\//), - }), - }), - ); + await expect(provider.load(context)).resolves.toBeUndefined(); }); - - it("trims trailing slashes from generated S3 keys without regex backtracking", async () => { - const send = vi.fn().mockResolvedValue({}); - const provider = fallbackProviders.s3({ - bucket: "bucket-name", - client: { send }, - keyPrefix: "reflag/flags-fallback///", - }); - - await provider.save(context, snapshot); - + it("loads S3 snapshots", async () => { + const send = vi.fn(async () => ({ + Body: { transformToString: async () => JSON.stringify(snapshot) }, + })); + await expect( + fallbackProviders + .s3({ bucket: "bucket", client: { send } }) + .load(context), + ).resolves.toEqual(snapshot); expect(send).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - Bucket: "bucket-name", - Key: `reflag/flags-fallback/flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, - }), - }), + expect.objectContaining({ input: { Bucket: "bucket", Key: objectKey } }), ); }); - - it("returns undefined for missing S3 snapshots", async () => { - const send = vi.fn().mockRejectedValue({ name: "NoSuchKey" }); - const provider = fallbackProviders.s3({ - bucket: "bucket-name", - client: { send }, - }); - - await expect(provider.load(context)).resolves.toBeUndefined(); - }); - - it("saves S3 snapshots", async () => { - const send = vi.fn().mockResolvedValue({}); - const provider = fallbackProviders.s3({ - bucket: "bucket-name", - client: { send }, - }); - - await provider.save(context, snapshot); - + it("trims S3 key-prefix slashes and saves versioned objects", async () => { + const send = vi.fn(); + await fallbackProviders + .s3({ + bucket: "bucket", + client: { send }, + keyPrefix: "reflag/flags-fallback///", + }) + .save(context, snapshot); expect(send).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ - Bucket: "bucket-name", - Key: expect.stringMatching(/^reflag\/flags-fallback\//), - Body: JSON.stringify(snapshot), + input: { + Bucket: "bucket", + Key: objectKey, ContentType: "application/json", - }), + Body: JSON.stringify(snapshot), + }, }), ); }); - - it("loads GCS snapshots", async () => { - const exists = vi.fn().mockResolvedValue([true]); - const download = vi - .fn() - .mockResolvedValue([Buffer.from(JSON.stringify(snapshot), "utf-8")]); - const save = vi.fn(); - const file = vi.fn().mockReturnValue({ exists, download, save }); - const bucket = vi.fn().mockReturnValue({ file }); - - const provider = fallbackProviders.gcs({ - bucket: "bucket-name", - client: { bucket }, - }); - - await expect(provider.load(context)).resolves.toEqual(snapshot); - expect(bucket).toHaveBeenCalledWith("bucket-name"); - expect(file).toHaveBeenCalledWith( - expect.stringMatching(/^reflag\/flags-fallback\//), + it("loads and saves legacy GCS injected clients", async () => { + const download = vi.fn( + async () => [Buffer.from(JSON.stringify(snapshot))] as [Buffer], ); - }); - - it("returns undefined for missing GCS snapshots", async () => { - const exists = vi.fn().mockResolvedValue([false]); - const download = vi.fn(); const save = vi.fn(); - const file = vi.fn().mockReturnValue({ exists, download, save }); - const bucket = vi.fn().mockReturnValue({ file }); - - const provider = fallbackProviders.gcs({ - bucket: "bucket-name", - client: { bucket }, - }); - - await expect(provider.load(context)).resolves.toBeUndefined(); - expect(download).not.toHaveBeenCalled(); - }); - - it("saves GCS snapshots", async () => { - const exists = vi.fn(); - const download = vi.fn(); - const save = vi.fn().mockResolvedValue(undefined); - const file = vi.fn().mockReturnValue({ exists, download, save }); - const bucket = vi.fn().mockReturnValue({ file }); - + const file = vi.fn(() => ({ + exists: async () => [true] as [boolean], + download, + save, + })); const provider = fallbackProviders.gcs({ - bucket: "bucket-name", - client: { bucket }, - keyPrefix: "reflag/flags-fallback///", + bucket: "bucket", + client: { bucket: () => ({ file }) }, }); - + await expect(provider.load(context)).resolves.toEqual(snapshot); await provider.save(context, snapshot); - - expect(file).toHaveBeenCalledWith( - `reflag/flags-fallback/flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, - ); + expect(file).toHaveBeenCalledWith(objectKey); expect(save).toHaveBeenCalledWith(JSON.stringify(snapshot), { contentType: "application/json", }); }); - - it("accepts a provided @googleapis/storage client", async () => { + it("loads and saves Google APIs GCS clients", async () => { const get = vi .fn() .mockResolvedValueOnce({ data: { kind: "storage#object" } }) - .mockResolvedValueOnce({ - data: Buffer.from(JSON.stringify(snapshot), "utf-8"), - }); - const insert = vi.fn().mockResolvedValue({}); - + .mockResolvedValueOnce({ data: Buffer.from(JSON.stringify(snapshot)) }); + const insert = vi.fn(); const provider = fallbackProviders.gcs({ - bucket: "bucket-name", - client: { - objects: { - get, - insert, - }, - }, + bucket: "bucket", keyPrefix: "reflag/flags-fallback///", + client: { objects: { get, insert } }, }); - await expect(provider.load(context)).resolves.toEqual(snapshot); await provider.save(context, snapshot); - expect(get).toHaveBeenNthCalledWith(1, { - bucket: "bucket-name", - object: `reflag/flags-fallback/flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, + bucket: "bucket", + object: objectKey, }); expect(get).toHaveBeenNthCalledWith( 2, - { - bucket: "bucket-name", - object: `reflag/flags-fallback/flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, - alt: "media", - }, - { - responseType: "arraybuffer", - }, + { bucket: "bucket", object: objectKey, alt: "media" }, + { responseType: "arraybuffer" }, ); expect(insert).toHaveBeenCalledWith({ - bucket: "bucket-name", - name: `reflag/flags-fallback/flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, + bucket: "bucket", + name: objectKey, uploadType: "media", - media: { - mimeType: "application/json", - body: JSON.stringify(snapshot), - }, + media: { mimeType: "application/json", body: JSON.stringify(snapshot) }, }); }); - it("creates the default GCS client from @googleapis/storage", async () => { vi.resetModules(); - const get = vi .fn() .mockResolvedValueOnce({ data: { kind: "storage#object" } }) - .mockResolvedValueOnce({ - data: Buffer.from(JSON.stringify(snapshot), "utf-8"), - }); - const insert = vi.fn().mockResolvedValue({}); - const storage = vi.fn().mockReturnValue({ - objects: { - get, - insert, - }, - }); + .mockResolvedValueOnce({ data: Buffer.from(JSON.stringify(snapshot)) }); + const insert = vi.fn(); + const storage = vi.fn(() => ({ objects: { get, insert } })); const GoogleAuth = vi.fn(); - - vi.doMock("@googleapis/storage", () => ({ - auth: { GoogleAuth }, - storage, - })); - + vi.doMock("@googleapis/storage", () => ({ auth: { GoogleAuth }, storage })); try { const { createGCSFallbackProvider } = await import("../src/flagsFallbackProvider"); - const provider = createGCSFallbackProvider({ - bucket: "bucket-name", - keyPrefix: "reflag/flags-fallback///", - }); - + const provider = createGCSFallbackProvider({ bucket: "bucket" }); await expect(provider.load(context)).resolves.toEqual(snapshot); await provider.save(context, snapshot); - expect(GoogleAuth).toHaveBeenCalledWith({ scopes: ["https://www.googleapis.com/auth/devstorage.read_write"], }); expect(storage).toHaveBeenCalledWith( - expect.objectContaining({ - version: "v1", - }), - ); - expect(get).toHaveBeenNthCalledWith(1, { - bucket: "bucket-name", - object: `reflag/flags-fallback/flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, - }); - expect(get).toHaveBeenNthCalledWith( - 2, - { - bucket: "bucket-name", - object: `reflag/flags-fallback/flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, - alt: "media", - }, - { - responseType: "arraybuffer", - }, + expect.objectContaining({ version: "v1" }), ); - expect(insert).toHaveBeenCalledWith({ - bucket: "bucket-name", - name: `reflag/flags-fallback/flags-fallback-${context.secretKeyHash.slice(0, 16)}.json`, - uploadType: "media", - media: { - mimeType: "application/json", - body: JSON.stringify(snapshot), - }, - }); } finally { vi.doUnmock("@googleapis/storage"); vi.resetModules(); } }); - - it("loads Redis snapshots", async () => { - const get = vi.fn().mockResolvedValue(JSON.stringify(snapshot)); - const set = vi.fn(); - const provider = fallbackProviders.redis({ - client: { get, set }, - }); - + it("loads Redis snapshots in a versioned namespace", async () => { + const get = vi.fn(async () => JSON.stringify(snapshot)); + const provider = fallbackProviders.redis({ client: { get, set: vi.fn() } }); await expect(provider.load(context)).resolves.toEqual(snapshot); expect(get).toHaveBeenCalledWith( - `reflag:flags-fallback:${context.secretKeyHash.slice(0, 16)}`, + `reflag:flags-fallback:v2:${context.secretKeyHash.slice(0, 16)}`, ); }); - it("returns undefined for missing Redis snapshots", async () => { - const get = vi.fn().mockResolvedValue(null); - const set = vi.fn(); - const provider = fallbackProviders.redis({ - client: { get, set }, - }); - - await expect(provider.load(context)).resolves.toBeUndefined(); + await expect( + fallbackProviders + .redis({ client: { get: vi.fn(async () => null), set: vi.fn() } }) + .load(context), + ).resolves.toBeUndefined(); }); - - it("saves Redis snapshots", async () => { - const get = vi.fn(); - const set = vi.fn().mockResolvedValue("OK"); - const provider = fallbackProviders.redis({ - client: { get, set }, - keyPrefix: "reflag:flags-fallback:::", - }); - - await provider.save(context, snapshot); - + it("trims Redis prefixes and saves versioned keys", async () => { + const set = vi.fn(); + await fallbackProviders + .redis({ + client: { get: vi.fn(), set }, + keyPrefix: "reflag:flags-fallback:::", + }) + .save(context, snapshot); expect(set).toHaveBeenCalledWith( - `reflag:flags-fallback:${context.secretKeyHash.slice(0, 16)}`, + `reflag:flags-fallback:v2:${context.secretKeyHash.slice(0, 16)}`, JSON.stringify(snapshot), ); }); - it("requires REDIS_URL when no Redis client is provided", async () => { - const previousRedisUrl = process.env.REDIS_URL; + const previous = process.env.REDIS_URL; delete process.env.REDIS_URL; - try { - const provider = fallbackProviders.redis(); - - await expect(provider.load(context)).rejects.toThrow( - "fallbackProviders.redis() requires REDIS_URL to be set when no client is provided", + await expect(fallbackProviders.redis().load(context)).rejects.toThrow( + "requires REDIS_URL", ); } finally { - if (previousRedisUrl === undefined) { - delete process.env.REDIS_URL; - } else { - process.env.REDIS_URL = previousRedisUrl; - } + if (previous === undefined) delete process.env.REDIS_URL; + else process.env.REDIS_URL = previous; } }); }); diff --git a/packages/node-sdk/tsconfig.test.json b/packages/node-sdk/tsconfig.test.json new file mode 100644 index 000000000..c45161cdb --- /dev/null +++ b/packages/node-sdk/tsconfig.test.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "noUncheckedIndexedAccess": false + }, + "include": [ + "src", + "test/client.test.ts", + "test/fixtures.ts", + "test/flag-definitions.test.ts", + "test/flagsFallbackProvider.test.ts", + "test/flags-cache.test.ts", + "test/client-sync.test.ts" + ] +} diff --git a/packages/node-sdk/tsconfig.type-tests-augmented.json b/packages/node-sdk/tsconfig.type-tests-augmented.json new file mode 100644 index 000000000..ef4fcc1ef --- /dev/null +++ b/packages/node-sdk/tsconfig.type-tests-augmented.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.type-tests.json", + "include": ["src", "type-tests/augmented-defaults.ts"] +} diff --git a/packages/node-sdk/tsconfig.type-tests.json b/packages/node-sdk/tsconfig.type-tests.json new file mode 100644 index 000000000..e8c16772b --- /dev/null +++ b/packages/node-sdk/tsconfig.type-tests.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.test.json", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src", "type-tests/defaults.ts"] +} diff --git a/packages/node-sdk/type-tests/augmented-defaults.ts b/packages/node-sdk/type-tests/augmented-defaults.ts new file mode 100644 index 000000000..006ebc4d2 --- /dev/null +++ b/packages/node-sdk/type-tests/augmented-defaults.ts @@ -0,0 +1,36 @@ +import { expectTypeOf } from "vitest"; + +import { ReflagClient } from "../src"; + +declare module "../src" { + interface Flags { + release: boolean; + settings: { model: string } | null; + legacy: { isEnabled: boolean; config: { payload: string } }; + } +} + +declare const client: ReflagClient; +const bound = client.bindClient({}); + +expectTypeOf(client.getFlag({}, "legacy")).toEqualTypeOf(); +expectTypeOf(bound.getFlag("settings", { model: "Swift" })).toEqualTypeOf<{ + model: string; +}>(); +// A registry cannot silently change the runtime Boolean contract of a bare read. +expectTypeOf(bound.getFlag("settings")).toEqualTypeOf(); +expectTypeOf(client.getFlagRemote("settings")).toEqualTypeOf< + Promise +>(); +expectTypeOf(client.getFlags({}).settings).toEqualTypeOf< + { model: string } | null | undefined +>(); +// @ts-expect-error Registered keys still reject unknown flag names. +client.getFlag({}, "unknown"); +// @ts-expect-error Registered keys also apply to explicit defaults. +bound.getFlag("unknown", { model: "Swift" }); +// @ts-expect-error Old generated wrapper types cannot restore the singular wrapper API. +const { isEnabled: _legacyAlias } = bound.getFlag("legacy"); +const legacy = client.getFlag({}, "legacy"); +// @ts-expect-error Nor can accessing an assigned singular result. +const _legacyProperty = legacy.isEnabled; diff --git a/packages/node-sdk/type-tests/defaults.ts b/packages/node-sdk/type-tests/defaults.ts new file mode 100644 index 000000000..1fa488f58 --- /dev/null +++ b/packages/node-sdk/type-tests/defaults.ts @@ -0,0 +1,58 @@ +import { expectTypeOf } from "vitest"; + +import { ReflagClient, type FlagValue } from "../src"; + +// Deliberately no Flags augmentation or generated declarations. +declare const client: ReflagClient; +const bound = client.bindClient({}); + +expectTypeOf(client.getFlag({}, "release")).toEqualTypeOf(); +expectTypeOf(client.getFlag({}, "release", false)).toEqualTypeOf(); +expectTypeOf(bound.getFlag("release")).toEqualTypeOf(); +expectTypeOf(client.getFlag({}, "number", 0)).toEqualTypeOf(); +expectTypeOf(bound.getFlag("string", "Swift")).toEqualTypeOf(); +expectTypeOf(client.getFlag({}, "json", null)).toEqualTypeOf(); +expectTypeOf(bound.getFlag("array", [])).toEqualTypeOf(); +expectTypeOf(bound.getFlag("array", [false])).toEqualTypeOf(); +const settings = client.getFlag({}, "settings", { + model: "Swift", + temperature: 0.7, +}); +expectTypeOf(settings).toEqualTypeOf<{ model: string; temperature: number }>(); +const { model } = bound.getFlag("settings", { model: "Swift" }); +expectTypeOf(model).toEqualTypeOf(); +const frozen = { + model: "Swift", + nested: { enabled: false }, + items: [1], +} as const; +expectTypeOf(bound.getFlag("settings", frozen)).toEqualTypeOf<{ + model: string; + nested: { enabled: boolean }; + items: number[]; +}>(); + +expectTypeOf(client.getFlagRemote("release", undefined, 0, 0)).toEqualTypeOf< + Promise +>(); +expectTypeOf(client.getFlagRemote("string", "Swift", 0, 0)).toEqualTypeOf< + Promise +>(); +expectTypeOf(bound.getFlagRemote("release")).toEqualTypeOf>(); +expectTypeOf(bound.getFlagRemote("settings", { model: "Swift" })).toEqualTypeOf< + Promise<{ model: string }> +>(); + +// @ts-expect-error Boolean reads do not return the legacy wrapper, even with aliases. +const { isEnabled: _legacyAlias } = client.getFlag({}, "release"); +const legacyValue = client.getFlag({}, "release"); +// @ts-expect-error Assignment does not restore the wrapper type. +const _legacyProperty = legacyValue.isEnabled; + +const dictionaryDefault: Record = { model: "Swift" }; +expectTypeOf(bound.getFlag("settings", dictionaryDefault)).toEqualTypeOf< + Record +>(); + +// @ts-expect-error Functions are not JSON defaults. +bound.getFlag("settings", () => false); diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json index dd79be4f8..285a2faea 100644 --- a/packages/openfeature-node-provider/package.json +++ b/packages/openfeature-node-provider/package.json @@ -36,7 +36,7 @@ "fmt:fix": "oxfmt --write ." }, "dependencies": { - "@reflag/node-sdk": "1.8.0" + "@reflag/node-sdk-v1": "npm:@reflag/node-sdk@1.8.0" }, "devDependencies": { "@babel/core": "~7.29.6", diff --git a/packages/openfeature-node-provider/src/index.test.ts b/packages/openfeature-node-provider/src/index.test.ts index 69eba83f4..79eb641b0 100644 --- a/packages/openfeature-node-provider/src/index.test.ts +++ b/packages/openfeature-node-provider/src/index.test.ts @@ -1,12 +1,12 @@ import { ProviderStatus } from "@openfeature/server-sdk"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; -import { ReflagClient } from "@reflag/node-sdk"; +import { ReflagClient } from "@reflag/node-sdk-v1"; import { defaultContextTranslator, ReflagNodeProvider } from "./index"; -vi.mock("@reflag/node-sdk", () => { - const actualModule = vi.importActual("@reflag/node-sdk"); +vi.mock("@reflag/node-sdk-v1", () => { + const actualModule = vi.importActual("@reflag/node-sdk-v1"); return { __esModule: true, diff --git a/packages/openfeature-node-provider/src/index.ts b/packages/openfeature-node-provider/src/index.ts index b24e8f617..a677b8ab6 100644 --- a/packages/openfeature-node-provider/src/index.ts +++ b/packages/openfeature-node-provider/src/index.ts @@ -15,7 +15,7 @@ import { ClientOptions, Context as ReflagContext, ReflagClient, -} from "@reflag/node-sdk"; +} from "@reflag/node-sdk-v1"; type ProviderOptions = ClientOptions & { contextTranslator?: (context: EvaluationContext) => ReflagContext; diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts index d7223c7e6..508f4dc20 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts @@ -1,4 +1,4 @@ -import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; +import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk-v1"; const secretKey = process.env.REFLAG_SECRET_KEY; export const publishableKey = process.env.REFLAG_PUBLISHABLE_KEY || ""; diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/package.json b/packages/react-sdk/dev/nextjs-bootstrap-demo/package.json index 4807106f4..dcf7a256a 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/package.json +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/package.json @@ -14,7 +14,7 @@ "format": "yarn fmt:fix && yarn lint:fix" }, "dependencies": { - "@reflag/node-sdk": "workspace:^", + "@reflag/node-sdk-v1": "npm:@reflag/node-sdk@1.8.0", "@reflag/react-sdk": "workspace:^", "next": "16.1.5", "react": "^18", diff --git a/yarn.lock b/yarn.lock index 9b47f9376..2fd37292d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6948,7 +6948,7 @@ __metadata: languageName: unknown linkType: soft -"@reflag/flag-evaluation-v1@npm:@reflag/flag-evaluation@1.1.1": +"@reflag/flag-evaluation-v1@npm:@reflag/flag-evaluation@1.1.1, @reflag/flag-evaluation@npm:1.1.1": version: 1.1.1 resolution: "@reflag/flag-evaluation@npm:1.1.1" dependencies: @@ -6957,7 +6957,7 @@ __metadata: languageName: node linkType: hard -"@reflag/flag-evaluation@workspace:packages/flag-evaluation": +"@reflag/flag-evaluation@workspace:*, @reflag/flag-evaluation@workspace:packages/flag-evaluation": version: 0.0.0-use.local resolution: "@reflag/flag-evaluation@workspace:packages/flag-evaluation" dependencies: @@ -7000,7 +7000,19 @@ __metadata: languageName: unknown linkType: soft -"@reflag/node-sdk@npm:1.8.0, @reflag/node-sdk@workspace:*, @reflag/node-sdk@workspace:^, @reflag/node-sdk@workspace:packages/node-sdk": +"@reflag/node-sdk-v1@npm:@reflag/node-sdk@1.8.0": + version: 1.8.0 + resolution: "@reflag/node-sdk@npm:1.8.0" + dependencies: + "@aws-sdk/client-s3": "npm:^3.888.0" + "@googleapis/storage": "npm:21.2.0" + "@redis/client": "npm:^5.11.0" + "@reflag/flag-evaluation": "npm:1.1.1" + checksum: 10c0/319fcbecbe57d0ff3545bd072b959c648a5db9fc033ef58d45bd6c7add0d486d68e45ce6ccda6088cbe33178e7cadad04759ca3d4d2ed9a8d33ea8a9432ad6de + languageName: node + linkType: hard + +"@reflag/node-sdk@workspace:*, @reflag/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@reflag/node-sdk@workspace:packages/node-sdk" dependencies: @@ -7008,7 +7020,7 @@ __metadata: "@babel/core": "npm:~7.29.6" "@googleapis/storage": "npm:21.2.0" "@redis/client": "npm:^5.11.0" - "@reflag/flag-evaluation-v1": "npm:@reflag/flag-evaluation@1.1.1" + "@reflag/flag-evaluation": "workspace:*" "@reflag/tsconfig": "npm:~0.0.2" "@types/node": "npm:^22.12.0" "@vitest/coverage-v8": "npm:~1.6.0" @@ -7048,7 +7060,7 @@ __metadata: "@babel/core": "npm:~7.29.6" "@openfeature/core": "npm:^1.5.0" "@openfeature/server-sdk": "npm:>=1.16.1" - "@reflag/node-sdk": "npm:1.8.0" + "@reflag/node-sdk-v1": "npm:@reflag/node-sdk@1.8.0" "@reflag/tsconfig": "npm:~0.0.2" "@types/node": "npm:^22.12.0" flush-promises: "npm:~1.0.2" @@ -18598,7 +18610,7 @@ __metadata: version: 0.0.0-use.local resolution: "nextjs-bootstrap-demo@workspace:packages/react-sdk/dev/nextjs-bootstrap-demo" dependencies: - "@reflag/node-sdk": "workspace:^" + "@reflag/node-sdk-v1": "npm:@reflag/node-sdk@1.8.0" "@reflag/react-sdk": "workspace:^" "@types/node": "npm:^22.12.0" "@types/react": "npm:^18" From 521cf4b34bacaa549688744ff7e06eefaae84113 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 4 Nov 2026 11:16:14 +0000 Subject: [PATCH 14/18] feat: adopt v2 flag values in browser and framework SDKs --- .../browser-framework-collection-values.md | 17 + packages/browser-sdk/README.md | 616 +--- .../browser-sdk/example/typescript/app.ts | 6 +- packages/browser-sdk/package.json | 5 +- packages/browser-sdk/src/bulkQueue.ts | 8 +- packages/browser-sdk/src/client.ts | 337 +- packages/browser-sdk/src/flag/flagCache.ts | 370 +-- packages/browser-sdk/src/flag/flags.ts | 472 +-- packages/browser-sdk/src/flag/values.ts | 85 + packages/browser-sdk/src/httpClient.ts | 15 +- packages/browser-sdk/src/index.ts | 10 +- packages/browser-sdk/src/toolbar/Toolbar.tsx | 22 +- packages/browser-sdk/test/client.test.ts | 1996 +++--------- .../test/e2e/acceptance.browser.spec.ts | 70 +- .../test/e2e/feedback-widget.browser.spec.ts | 50 +- packages/browser-sdk/test/flagCache.test.ts | 206 +- packages/browser-sdk/test/flags.test.ts | 1294 ++------ .../browser-sdk/test/hooksManager.test.ts | 181 +- packages/browser-sdk/test/init.test.ts | 52 +- packages/browser-sdk/test/mocks/handlers.ts | 74 +- packages/browser-sdk/test/release.test.ts | 36 + packages/browser-sdk/test/usage.test.ts | 306 +- packages/browser-sdk/tsconfig.type-tests.json | 11 + packages/browser-sdk/type-tests/defaults.ts | 20 + packages/browser-sdk/vitest.setup.ts | 6 + packages/react-sdk/README.md | 961 +----- .../dev/nextjs-bootstrap-demo/app/client.ts | 2 +- .../components/Flags.tsx | 2 +- .../dev/nextjs-bootstrap-demo/package.json | 2 +- .../dev/nextjs-flag-demo/components/Flags.tsx | 2 +- packages/react-sdk/dev/plain/app.tsx | 70 +- packages/react-sdk/package.json | 6 +- packages/react-sdk/src/index.tsx | 219 +- packages/react-sdk/test/opt-in-types.tsx | 18 +- packages/react-sdk/test/usage.test.tsx | 2832 +++-------------- .../tsconfig.type-tests-defaults.json | 5 + packages/react-sdk/type-tests/defaults.tsx | 27 + packages/vue-sdk/README.md | 676 +--- packages/vue-sdk/dev/plain/App.vue | 7 +- .../dev/plain/components/FlagsList.vue | 143 +- .../plain/components/StartHuddlesButton.vue | 50 +- packages/vue-sdk/package.json | 6 +- .../src/ReflagBootstrappedProvider.vue | 9 +- packages/vue-sdk/src/hooks.ts | 85 +- packages/vue-sdk/src/index.ts | 6 +- packages/vue-sdk/src/types.ts | 48 +- packages/vue-sdk/test/opt-in-types.ts | 12 +- packages/vue-sdk/test/usage.test.ts | 93 +- .../vue-sdk/tsconfig.type-tests-defaults.json | 5 + packages/vue-sdk/type-tests/defaults.ts | 23 + yarn.lock | 39 +- 51 files changed, 2999 insertions(+), 8614 deletions(-) create mode 100644 .changeset/browser-framework-collection-values.md create mode 100644 packages/browser-sdk/src/flag/values.ts create mode 100644 packages/browser-sdk/test/release.test.ts create mode 100644 packages/browser-sdk/tsconfig.type-tests.json create mode 100644 packages/browser-sdk/type-tests/defaults.ts create mode 100644 packages/react-sdk/tsconfig.type-tests-defaults.json create mode 100644 packages/react-sdk/type-tests/defaults.tsx create mode 100644 packages/vue-sdk/tsconfig.type-tests-defaults.json create mode 100644 packages/vue-sdk/type-tests/defaults.ts diff --git a/.changeset/browser-framework-collection-values.md b/.changeset/browser-framework-collection-values.md new file mode 100644 index 000000000..362fc65c7 --- /dev/null +++ b/.changeset/browser-framework-collection-values.md @@ -0,0 +1,17 @@ +--- +"@reflag/browser-sdk": major +"@reflag/react-sdk": major +"@reflag/vue-sdk": major +--- + +Adopt protocol-v2 evaluated flags and direct JSON values across Browser, React and Vue. + +Bare singular reads return Boolean with an implicit false default. Positional JSON defaults infer widened direct-value types without generated declarations, enforce top-level categories, preserve accepted falsy values, and return independent copies. Null explicitly accepts arbitrary JSON; nested schema validation remains the application's responsibility. + +Browser `getFlag()` and `getFlags()` register root checks immediately with genuine selected-key/source/snapshot identity. Delivery stays buffered and identity-aware rate-limited. Diagnostic access and bootstrap serialization do not register checks; defaults, mismatches, overrides and static fallback values do not invent selections. V2 cache/override storage is isolated from v1, and complete evaluated snapshots are validated before acceptance. This requires compatible `/flags/evaluated` and canonical-check ingestion on the server. + +React `useFlag()` returns a direct value without suspending. Use the separate `useFlagSuspense()` hook to wait for initialization; missing flags then use the default. Vue `useFlag()` returns a ref to the direct value. Loading, application usage tracking and feedback remain separate hooks/composables. The existing opt-in Suspense controls remain specific to `useOptInFlags`. + +Upgrade bootstrap producers/consumers together: only protocol-v2 envelopes from Node SDK v2 are supported; legacy bootstrap wrappers and the Browser `bootstrappedFlags` option are rejected. Move wrapper `.track()`/`.requestFeedback()` calls to ordinary client/framework methods. Undefined clears a value override; null is a JSON override. + +Opt-in metadata arrives with ordinary v2 evaluated responses. Bootstrapped clients fetch missing metadata on demand from the same v2 endpoint without replacing server-derived values. Mutations confirm versioned evaluation and membership in one refresh. The unchanged OpenFeature Browser provider and React Native wrapper remain pinned to published v1 SDK dependencies. CLI direct-value generation remains a separate migration. diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index bc7364a98..202f66aba 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -1,582 +1,148 @@ -# Reflag Browser SDK +# Reflag Browser SDK v2 -Basic client for [Reflag.com](https://reflag.com). If you're using React, you'll be better off with the Reflag React SDK. +Client-side feature flags and JSON values for [Reflag](https://reflag.com). -Reflag supports flag toggling, tracking flag usage, [collecting feedback](#qualitative-feedback-on-beta-flags) on flags, and [remotely configuring flags](#remote-config). +> This is a breaking v2 prerelease. It requires the v2 evaluated-flags/check protocol on the server. Do not switch existing v1 applications or bootstrap consumers without coordinating that upgrade. Nothing in this branch publishes or deploys the SDK/server. -## Install - -First find your `publishableKey` under [environment settings](https://app.reflag.com/env-current/settings/app-environments) in Reflag. - -The package can be imported or used directly in a HTML script tag: - -A. Import module: - -```typescript -import { ReflagClient } from "@reflag/browser-sdk"; - -const user = { - id: 42, - role: "manager", -}; - -const company = { - id: 99, - plan: "enterprise", -}; - -const reflagClient = new ReflagClient({ publishableKey, user, company }); - -await reflagClient.initialize(); - -const { - isEnabled, - config: { payload: question }, - track, - requestFeedback, -} = reflagClient.getFlag("huddle"); - -if (isEnabled) { - // Show flag. When retrieving `isEnabled` the client automatically - // sends a "check" event for the "huddle" flag which is shown in the - // Reflag UI. - - // On usage, call `track` to let Reflag know that a user interacted with the flag - track(); - - // The `payload` is a user-supplied JSON in Reflag that is dynamically picked - // out depending on the user/company. - const question = payload?.question ?? "Tell us what you think of Huddles"; - - // Use `requestFeedback` to create "Send feedback" buttons easily for specific - // flags. This is not related to `track` and you can call them individually. - requestFeedback({ title: question }); -} - -// `track` just calls `reflagClient.track()` to send an event using the same flag key -// You can also use `track` on the client directly to send any custom event. -reflagClient.track("huddle"); - -// similarly, `requestFeedback` just calls `reflagClient.requestFeedback({flagKey: })` -// which you can also call directly: -reflagClient.requestFeedback({ flagKey: "huddle" }); -``` - -B. Script tag (client-side directly in html) - -See [example/browser.html](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/example/browser.html) for a working example: - -```html - - -Loading... - -``` - -### Init options - -Supply these to the constructor call: - -```typescript -type Configuration = { - logger: console; // by default only logs warn/error, by passing `console` you'll log everything - apiBaseUrl?: "https://front.reflag.com"; - credentials?: "include" | "same-origin" | "omit"; // forwarded to fetch requests; "include" also enables credentials for the default EventSource transport - feedback?: undefined; // See FEEDBACK.md - enableTracking?: true; // set to `false` to stop sending track events and user/company updates to Reflag servers. Useful when you're impersonating a user - enableLiveFlagUpdates?: false; // Set to `true` to keep flags up to date over SSE (browser SDK default: false) - eventSourceFactory?: (url: string) => { - // Advanced: provide a custom EventSource-compatible transport - addEventListener: (type: string, cb: (event: any) => void) => void; - close: () => void; - }; - fallbackFlags?: - | string[] - | Record; // Enable these flags if unable to contact reflag.com. Can be a list of flag keys or a record with configuration values - timeoutMs?: number; // Timeout for fetching flags (default: 5000ms) - staleWhileRevalidate?: boolean; // Revalidate in the background when cached flags turn stale to avoid latency in the UI (default: false) - staleTimeMs?: number; // at initialization time flags are loaded from the cache unless they have gone stale. Defaults to 0 which means the cache is disabled. Increase this in the case of a non-SPA - expireTimeMs?: number; // In case we're unable to fetch flags from Reflag, cached/stale flags will be used instead until they expire after `expireTimeMs`. Default is 30 days - offline?: boolean; // Use the SDK in offline mode. Offline mode is useful during testing and local development -}; -``` - -## Migrating from Bucket SDK - -If you have been using the Bucket SDKs, the following list will help you migrate to Reflag SDK: - -- `Bucket*` classes, and types have been renamed to `Reflag*` (e.g. `BucketClient` is now `ReflagClient`) -- `Feature*` classes, and types have been renamed to `Feature*` (e.g. `Feature` is now `Flag`, `RawFeatures` is now `RawFlags`) -- All methods that contained `feature` in the name have been renamed to use the `flag` terminology (e.g. `getFeature` is `getFlag`) -- The `fallbackFeatures` property in client constructor and configuration files has been renamed to `fallbackFlags` -- `featureKey` has been renamed to `flagKey` in all methods that accepts that argument -- The new cookies that are stored in the client's browser are now `reflag-*` prefixed instead og `bucket-*` -- The `featuresUpdated` hook has been renamed to `flagsUpdated` -- The `checkIsEnabled` and `checkConfig` hooks have been removed, use `check` from now on - -To ease in transition to Reflag SDK, some of the old methods have been preserved as aliases to the new methods: - -- `getFeature` method is an alias for `getFlag` -- `getFeatures` method is an alias for `getFlags` -- `featuresUpdated` hook is an alias for `flagsUpdated` - -If you are running with strict Content Security Policies active on your website, you will need change them as follows: - -- `connect-src https://front.bucket.co` to `connect-src https://front.reflag.com` - -Finally, if you have customized the look & feel of the Feedback component, update `--bucket-feedback-*` CSS classes to `--reflag-feedback-*` - -## Flag toggles - -Reflag determines which flags are active for a given user/company. The user/company is given in the ReflagClient constructor. - -If you supply `user` or `company` objects, they must include at least the `id` property otherwise they will be ignored in their entirety. -In addition to the `id`, you must also supply anything additional that you want to be able to evaluate flag targeting rules against. - -Attributes cannot be nested (multiple levels) and must be either strings, integers or booleans. -Some attributes are special and used in Reflag UI: - -- `name` -- display name for `user`/`company`, -- `email` -- is accepted for `user`s and will be highlighted in the Reflag UI if available, -- `avatar` -- can be provided for both `user` and `company` and should be an URL to an image. +## Get started ```ts -const reflagClient = new ReflagClient({ - publishableKey, - user: { - id: "user_123", - name: "John Doe", - email: "john@acme.com" - avatar: "https://example.com/images/udsy6363" - }, - company: { - id: "company_123", - name: "Acme, Inc", - avatar: "https://example.com/images/31232ds" - }, -}); -``` - -To retrieve flags along with their targeting information, use `getFlag(key: string)`: - -```ts -const huddle = reflagClient.getFlag("huddle"); -// { -// isEnabled: true, -// config: { key: "zoom", payload: { ... } }, -// track: () => Promise -// requestFeedback: (options: RequestFeedbackData) => void -// } -``` - -You can use `getFlags()` to retrieve all enabled flags currently. - -```ts -const flags = reflagClient.getFlags(); -// { -// huddle: { -// isEnabled: true, -// targetingVersion: 42, -// config: ... -// } -// } -``` - -`getFlags()` is meant to be more low-level than `getFlag()` and it typically used -by down-stream clients, like the React SDK. - -Note that accessing `isEnabled` on the object returned by `getFlags` does not automatically -generate a `check` event, contrary to the `isEnabled` property on the object returned by `getFlag`. - -## End-user opt-in - -If a flag has end-user opt-in enabled in Reflag, you can list the opt-in options for the current context and set or cancel opt-in for the current user or company. - -```ts -const optInFlags = reflagClient.getOptInFlags(); -const isLoadingOptInFlags = reflagClient.getIsLoadingOptInFlags(); -// [{ key, name, description, isEnabled, userOptedIn, companyOptedIn, isOptedIn }] - -await reflagClient.setOptIn("huddle", { optedIn: true }); -await reflagClient.setOptIn("huddle", { optedIn: false }); - -await reflagClient.setOptIn("huddle", { - optedIn: true, - scope: "company", -}); -``` - -By default, `setOptIn()` changes the opt-in for the current user, so the current context must include a `user.id`. To manage the current company's opt-in instead, pass `scope: "company"`; the context must then include a `company.id`. - -User and company opt-ins are managed independently. Setting `optedIn` to `false` removes the opt-in only for the selected scope. For example, cancelling a user's opt-in does not change the company's opt-in for the same flag. - -`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. It resolves after the latest flag state has been applied locally, the requested membership change has been confirmed, and `flagsUpdated` listeners have been notified. - -The `description` comes from the dedicated SDK-facing opt-in description configured in Reflag. - -For a bootstrapped client, the first `getOptInFlags()` or `getIsLoadingOptInFlags()` call starts one flags refresh. The list call returns the currently available list synchronously, and the loading getter returns `true` until the refresh succeeds or fails. Normal initialization already exposes loading through the client's state. - -Listen for `optInFlagsLoadingUpdated` to update UI when this loading state changes. `flagsUpdated` is emitted when a successful refresh updates the list. - -## Remote config - -Remote config is a dynamic and flexible approach to configuring flag behavior outside of your app – without needing to re-deploy it. - -Similar to `isEnabled`, each flag has a `config` property. This configuration is managed from within Reflag. -It is managed similar to the way access to flags is managed, but instead of the binary `isEnabled` you can have -multiple configuration values which are given to different user/companies. - -```ts -const flags = reflagClient.getFlags(); -// { -// huddle: { -// isEnabled: true, -// targetingVersion: 42, -// config: { -// key: "gpt-3.5", -// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } -// } -// } -// } -``` - -`key` is mandatory for a config, but if a flag has no config or no config value was matched against the context, the `key` will be `undefined`. Make sure to check against this case when trying to use the configuration in your application. `payload` is an optional JSON value for arbitrary configuration needs. - -Just as `isEnabled`, accessing `config` on the object returned by `getFlags` does not automatically -generate a `check` event, contrary to the `config` property on the object returned by `getFlag`. - -## Server-side rendering and bootstrapping - -For server-side rendered applications, you can render immediately with pre-fetched flag data by bootstrapping the client. - -### Init options bootstrapped - -```typescript -type Configuration = { - logger: console; // by default only logs warn/error, by passing `console` you'll log everything - apiBaseUrl?: "https://front.reflag.com"; - credentials?: "include" | "same-origin" | "omit"; // forwarded to fetch requests; "include" also enables credentials for the default EventSource transport - feedback?: undefined; // See FEEDBACK.md - enableTracking?: true; // set to `false` to stop sending track events and user/company updates to Reflag servers. Useful when you're impersonating a user - offline?: boolean; // Use the SDK in offline mode. Offline mode is useful during testing and local development - bootstrappedState?: { - context: ReflagContext; - flags: FetchedFlags; - flagStateVersion?: number; - }; // Pre-fetched evaluated state from server-side (see Server-side rendering section) - bootstrappedFlags?: FetchedFlags; // Deprecated: use `bootstrappedState` instead -}; -``` - -### Using `bootstrappedState` - -Use the Node SDK's `getFlagsForBootstrap()` method to pre-fetch evaluated state server-side, then pass the returned object directly to the browser client: - -```typescript -// Server-side: Get bootstrapped state using Node SDK -import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; - -const serverClient = new ReflagNodeClient({ secretKey: "your-secret-key" }); -await serverClient.initialize(); - -const bootstrappedState = serverClient.getFlagsForBootstrap({ - user: { id: "user123", name: "John Doe", email: "john@acme.com" }, - company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, -}); - -// Pass the bootstrapped state to the client using your framework's preferred method -app.get("/", (req, res) => { - res.set("Content-Type", "text/html"); - res.send( - Buffer.from( - ` -
`, - ), - ); -}); - -// Client-side: Initialize with pre-fetched evaluated state import { ReflagClient } from "@reflag/browser-sdk"; -const reflagClient = new ReflagClient({ +const reflag = new ReflagClient({ publishableKey: "your-publishable-key", - bootstrappedState, // Contains context, flags, and optional flagStateVersion + user: { id: "alice", role: "manager" }, + company: { id: "acme", plan: "enterprise" }, }); +await reflag.initialize(); -await reflagClient.initialize(); -const { isEnabled } = reflagClient.getFlag("huddle"); -``` - -The `bootstrappedState` object contains: - -- `context`: the evaluation context used on the server -- `flags`: the evaluated raw flags -- `flagStateVersion`: an optional version used to avoid redundant live-update refreshes immediately after bootstrapping - -If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`. +const enabled = reflag.getFlag("huddles"); // boolean, implicit false +const settings = reflag.getFlag("assistant-settings", { model: "quick" }); +// settings: { model: string } -If a bootstrapped application requests opt-in flags, the browser SDK performs one flags refresh. Applications that do not request opt-in data do not make this request. - -If you previously used `bootstrappedFlags`, migrate like this: - -```typescript -// Before -const { flags } = serverClient.getFlagsForBootstrap(context); -const client = new ReflagClient({ - publishableKey, - user: context.user, - company: context.company, - other: context.other, - bootstrappedFlags: flags, -}); - -// After -const bootstrappedState = serverClient.getFlagsForBootstrap(context); -const client = new ReflagClient({ - publishableKey, - bootstrappedState, -}); -``` - -> [!NOTE] -> After bootstrapping, any live flag updates are fetched directly by the browser SDK from Reflag using the browser-visible context. If your bootstrapped snapshot depends on server-only or secret context that is not available in the browser, later live refreshes may differ. In that case, keep `enableLiveFlagUpdates` disabled. - -This eliminates loading states and removes the initial render's dependency on the flags API. - -## Context management - -### Updating user/company/other context - -Attributes given for the user/company/other context in the ReflagClient constructor can be updated for use in flag targeting evaluation with the `updateUser()`, `updateCompany()` and `updateOtherContext()` methods. -They return a promise which resolves once the flags have been re-evaluated follow the update of the attributes. - -> [!NOTE] > `user`/`company` attributes are also stored remotely on the Reflag servers and will automatically be used to evaluate flag targeting if the page is refreshed. - -### setContext() - -The `setContext()` method allows you to replace the entire context (user, company, and other attributes) at once. This method is useful when you need to completely change the context, such as when a user logs in or switches between different accounts. - -```ts -await reflagClient.setContext({ - user: { - id: "new-user-123", - name: "Jane Doe", - email: "jane@example.com", - role: "admin", - }, - company: { - id: "company-456", - name: "New Company Inc", - plan: "enterprise", - }, - other: { - feature: "beta", - locale: "en-US", - }, -}); +if (enabled) { + // Application usage tracking is separate from reading the flag. + await reflag.track("Started huddle"); +} ``` -The method will: - -- Replace the entire context with the new values -- Re-evaluate all flags based on the new context -- Update the user and company information on Reflag servers -- Return a promise that resolves once the flags have been re-evaluated +Use only a **publishable** key in a browser. Secret keys belong on your server. -### getContext() - -The `getContext()` method returns the current context being used for flag evaluation. This is useful for debugging or when you need to inspect the current user, company, and other attributes. +## Direct-value reads ```ts -const currentContext = reflagClient.getContext(); -console.log(currentContext); -// { -// user: { id: "user-123", name: "John Doe", email: "john@example.com" }, -// company: { id: "company-456", name: "Acme Inc", plan: "enterprise" }, -// other: { locale: "en-US", feature: "beta" } -// } +reflag.getFlag("release"); // boolean; missing/mismatched -> false +reflag.getFlag("model", "quick"); // string +reflag.getFlag("limit", 10); // number +reflag.getFlag("settings", { model: "quick" }); +reflag.getFlag("nullable-settings", null); // FlagValue (arbitrary JSON) ``` -The returned context object contains: +- Defaults are positional JSON values; generation is not required for useful types. Primitive literals widen, and readonly defaults are accepted. +- A non-null default accepts only the same **top-level JSON category**: Boolean, number, string, array or object. A mismatch warns without logging the payload and returns the default. A bare read can never return a truthy object. +- Explicit `null` accepts any JSON value, including authored null. Missing values return null. +- Falsy selected values (`false`, `0`, `""`, null) are preserved when accepted by the requested category. +- Object fields and array elements are **not schema-validated**. Validate untrusted/nested payloads in your application. +- Values and defaults are returned as independent mutable copies. There are no reserved JSON property names. -- `user`: Current user attributes (if any) -- `company`: Current company attributes (if any) -- `other`: Additional context attributes not related to user or company +`getFlags()` returns a direct-value record (entries may be undefined) and registers checks for its returned roots. `getFlagResults()` returns owned evaluated diagnostics **without checks**; it is intended for tooling, not ordinary application reads. `getFlagsForBootstrap()` serializes protocol-v2 evaluated state without checks. -## Toolbar +## Checks and delivery -The Reflag Toolbar is great for toggling flags on/off for yourself to ensure that everything works both when a flag is on and when it's off. +Calling a getter registers a requested-root check immediately, even if its return value is ignored. There are no `.isEnabled`, `.config`, `.value` or flag `.track()` wrappers. Delivery remains buffered through `/bulk` and identity-aware rate-limited. -Toolbar screenshot +A selected check carries the authored selected key as `evalResult`, its `sourceVersionId` and the available snapshot `flagStateVersion`. Payload equality is not identity: equal-valued selections or segment-only snapshot changes remain distinguishable on subsequent reads. Defaults, category mismatches, local overrides and static fallback values do not fabricate authored selections. Diagnostics and bootstrap serialization generate no checks. The server evaluates dependencies; fetching their results does not itself register reads. -The toolbar will automatically appear on `localhost`. However, it can also be incredibly useful in production. -You have full control over when it appears through the `toolbar` configuration option passed to the `ReflagClient`. +`enableTracking: false` disables checks and user/company/usage tracking. `offline: true` skips HTTP and tracking. `await reflag.stop()` flushes buffered events and closes the client's background transports. -You can pass a simple boolean to force the toolbar to appear/disappear: +## Context, refresh and cache -```typescript -const client = new ReflagClient({ - // show the toolbar even in production if the user is an internal/admin user - toolbar: user?.isInternal, - ... +```ts +await reflag.updateUser({ role: "admin" }); +await reflag.updateCompany({ plan: "enterprise" }); +await reflag.updateOtherContext({ region: "eu" }); +await reflag.setContext({ + user: { id: "bob" }, + company: { id: "other-company" }, }); +await reflag.refresh(); ``` -You can also configure the position of the toolbar on the screen: - -```typescript -const client = new ReflagClient({ - toolbar: { - show: true; - position: { - placement: "bottom-left", - offset: {x: "1rem", y: "1rem"} - } - } - ... -}) -``` - -See [the reference](https://docs.reflag.com/supported-languages/browser-sdk/globals#toolbaroptions) for details. - -## Qualitative feedback on beta flags - -Reflag can collect qualitative feedback from your users in the form of a [Customer Satisfaction Score](https://en.wikipedia.org/wiki/Customer_satisfaction) and a comment. +`setContext()` replaces the context; update methods shallow-merge their section. User/company sections require IDs, including valid numeric zero. Context supports nested JSON and arrays. A context change clears the old context's values while fetching; stale/in-flight responses cannot replace a newer context or snapshot. -### Automated feedback collection +Values come from `GET /flags/evaluated`, not legacy `/features/evaluated`. Server evaluation retains the complete internal graph and authorized public roots. The Browser SDK does not cache dependency evaluations of its own. -The Reflag Browser SDK comes with automated feedback collection mode enabled by default, which lets the Reflag service ask your users for feedback for relevant flags just after they've used them. +Constructor options include: -> [!NOTE] -> To get started with automatic feedback collection, make sure you've set `user` in the `ReflagClient` constructor. +| Option | Behavior | +| ------------------------------- | --------------------------------------------------------------------------- | +| `apiBaseUrl`, `appBaseUrl` | API origin and toolbar/feedback links | +| `credentials` | Fetch credentials mode; `include` also enables default SSE credentials | +| `timeoutMs` | Flag request timeout; default 5,000 ms | +| `staleTimeMs` | Fresh-cache duration; default 0 | +| `staleWhileRevalidate` | Serve stale cached state while revalidating; default false | +| `expireTimeMs` | Cache expiry; default 30 days | +| `storage` | Optional async storage adapter | +| `fallbackFlags` | Value record or array of enabled Boolean keys, without authored provenance | +| `enableLiveFlagUpdates` | Refresh after SSE publications; default false in Browser, true in React/Vue | +| `eventSourceFactory` | Optional EventSource-compatible transport | +| `trackingQueue` | Buffered-delivery `flushDelayMs` and `maxSize` | +| `logger`, `toolbar`, `feedback` | Logging, Boolean override toolbar and feedback options | -Automated feedback surveys work even if you're not using the SDK to send events to Reflag. -It works because the Reflag Browser SDK maintains a live connection to Reflag's servers and can automatically show a feedback prompt whenever the Reflag servers determines that an event should trigger a prompt - regardless of how this event is sent to Reflag. +Evaluated cache and override storage use isolated v2 keys. V1 cache entries are never interpreted as v2 values. Malformed refreshes leave known-good state intact. Valid saved v2 selections retain their real provenance on restore. There is no automatic periodic HTTP fallback after an SSE failure; manual refresh/context updates remain available. -You can find all the options to make changes to the default behavior in the [Reflag feedback documentation](./FEEDBACK.md). - -### Reflag feedback UI - -Reflag can assist you with collecting your user's feedback by offering a pre-built UI, allowing you to get started with minimal code and effort. - -[Read the Reflag feedback UI documentation](./FEEDBACK.md) - -### Reflag feedback SDK - -Feedback can be submitted to Reflag using the SDK: +## Overrides and toolbar ```ts -reflagClient.feedback({ - flagKey: "my-flag-key", // String (required), copy from Flag feedback tab - score: 5, // Number: 1-5 (optional) - comment: "Absolutely stellar work!", // String (optional) -}); +reflag.setFlagOverride("release", true); +reflag.setFlagOverride("settings", { model: "local" }); +reflag.setFlagOverride("nullable-settings", null); // a real null override +reflag.setFlagOverride("release", undefined); // clear the override ``` -### Reflag feedback API - -If you are not using the Reflag Browser SDK, you can still submit feedback using the HTTP API. - -See details in [Feedback HTTP API](https://docs.reflag.com/api/http-api#post-feedback) +Overrides apply after server evaluation and do not rewrite authored dependency results. The built-in toolbar appears on localhost by default and offers Boolean overrides only. It uses diagnostic access, so opening it does not register application checks. -## Tracking flag usage +## Server bootstrap -The `track` function lets you send events to Reflag to denote flag usage. -By default Reflag expects event names to align with the flag keys, but -you can customize it as you wish. +Generate the full payload with **Node SDK v2** on your server: ```ts -reflagClient.track("huddle", { voiceHuddle: true }); +const bootstrappedState = serverClient.getFlagsForBootstrap(context); +// Send via your framework's safe JSON/hydration mechanism. ``` -## Event listeners - -Event listeners allow for capturing various events occurring in the `ReflagClient`. This is useful to build integrations with other system or for various debugging purposes. The available events are: - -- `check`: Your code used `isEnabled` or `config` for a flag -- `flagsUpdated`: Flags were updated. Either because they were loaded as part of initialization or because the user/company updated -- `optInFlagsLoadingUpdated`: The opt-in flag loading state changed -- `user`: User information updated (similar to the `identify` call used in tracking terminology) -- `company`: Company information updated (sometimes to the `group` call used in tracking terminology) -- `track`: Track event occurred. - -Use the `on()` method to add an event listener to respond to certain events. See the API reference for details on each hook. - ```ts -import { ReflagClient, CheckEvent, RawFlags } from "@reflag/browser-sdk"; - -const client = new ReflagClient({ - // options -}); - -// or add the hooks after construction: -const unsub = client.on("check", (check: CheckEvent) => - console.log(`Check event ${check}`), -); -// use the returned function to unsubscribe, or call `off()` with the same arguments again -unsub(); +const reflag = new ReflagClient({ publishableKey, bootstrappedState }); +await reflag.initialize(); +const enabled = reflag.getFlag("release"); ``` -## Zero PII - -The Reflag Browser SDK doesn't collect any metadata and HTTP IP addresses are _not_ being stored. +The payload includes `protocolVersion: 2`, context, evaluated diagnostics/selections and an optional snapshot version. V1 payloads and the old `bootstrappedFlags` option are rejected. Preserve the entire envelope; do not pass just its `flags` property. A version is required for bootstrapped live updates. Never embed secrets or server-only context in public bootstrap data. Later refreshes use browser-visible context and can differ from server-only evaluation; disable live updates when that is inappropriate. -For tracking individual users, we recommend using something like database ID as userId, as it's unique and doesn't include any PII (personal identifiable information). If, however, you're using e.g. email address as userId, but prefer not to send any PII to Reflag, you can hash the sensitive data before sending it to Reflag: +## End-user opt-in ```ts -import reflag from "@reflag/browser-sdk"; -import { sha256 } from "crypto-hash"; - -reflag.user(await sha256("john_doe")); +const options = reflag.getOptInFlags(); +const loading = reflag.getIsLoadingOptInFlags(); +await reflag.setOptIn("huddles", { optedIn: true }); +await reflag.setOptIn("huddles", { optedIn: false, scope: "company" }); ``` -## Use of cookies - -The Reflag Browser SDK uses a couple of cookies to support automated feedback surveys. These cookies are not used for tracking purposes and thus should not need to appear in cookie consent forms. - -The two cookies are: - -- `reflag-prompt-${userId}`: store the last automated feedback prompt message ID received to avoid repeating surveys -- `reflag-token-${userId}`: caching a token used to connect to Reflag's live messaging infrastructure that is used to deliver automated feedback surveys in real time. - -## TypeScript - -Types are bundled together with the library and exposed automatically when importing through a package manager. - -## Content Security Policy (CSP) +The list contains presentation/membership metadata (`key`, `name`, `description`, `isEnabled`, `userOptedIn`, `companyOptedIn`, `isOptedIn`); this is not the ordinary flag getter API. User/company grants remain independent. The selected scope requires its ID. Successful mutations wait for versioned evaluation and membership confirmation. -If you are running with strict Content Security Policies active on your website, you will need to enable these directives in order to use the SDK: +Metadata arrives with ordinary `/flags/evaluated` responses and is cached with the evaluated snapshot. Bootstrapped clients whose snapshot lacks metadata fetch it on demand from the same v2 endpoint, without replacing server-derived values or provenance with browser-context evaluations. Loading settles on success or failure, including an empty list. Opt-in mutations confirm values and membership together in one versioned refresh. `optInFlagsLoadingUpdated` and `flagsUpdated` support UI subscriptions. -| Directive | Values | Reason | -| ----------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| connect-src | [https://front.reflag.com](https://front.reflag.com) | API requests plus Server-Sent Events for live flag updates and automated feedback surveys. | -| style-src | 'unsafe-inline' | The feedback UI is styled with inline styles. Not having this directive results unstyled HTML elements. | +## Tracking, feedback and events -If you are including the Reflag tracking SDK with a ` - - - -``` - -If using Nuxt, wrap `` in ``. `` only renders client-side currently. - -### 2. Use `useFlag get flag status - -```vue - - - -``` - -See [useFlag()](#useflag) for a full example - -## Setting context - -Reflag determines which flags are active for a given `user`, `company`, or `other` context. -You can pass these to the `ReflagProvider` using the `context` prop. - -### Using the `context` prop - -```vue - - - -``` - -### Legacy individual props (deprecated) - -For backward compatibility, you can still use individual props, but these are deprecated and will be removed in the next major version: - -```vue - - - -``` - -> [!Important] -> The `user`, `company`, and `otherContext` props are deprecated. Use the `context` prop instead, which provides the same functionality in a more structured way. - -### Context requirements - -If you supply `user` or `company` objects, they must include at least the `id` property otherwise they will be ignored in their entirety. -In addition to the `id`, you must also supply anything additional that you want to be able to evaluate flag targeting rules against. -Attributes which are not properties of the `user` or `company` can be supplied using the `other` property. - -Attributes cannot be nested (multiple levels) and must be either strings, numbers or booleans. -A number of special attributes exist: - -- `name` -- display name for `user`/`company`, -- `email` -- the email of the user, -- `avatar` -- the URL for `user`/`company` avatar image. - -To retrieve flags along with their targeting information, use `useFlag(key: string)` hook (described in a section below). - -Note that accessing `isEnabled` on the object returned by `useFlag()` automatically -generates a `check` event. - -## Remote config - -Remote config is a dynamic and flexible approach to configuring flag behavior outside of your app – without needing to re-deploy it. - -Similar to `isEnabled`, each flag accessed using the `useFlag()` hook, has a `config` property. This configuration is managed from within Reflag. It is managed similar to the way access to flags is managed, but instead of the -binary `isEnabled` you can have multiple configuration values which are given to different user/companies. - -### Get started with Remote config - -```ts -const { - isEnabled, - config: { key, payload }, -} = useFlag("huddles"); - -// isEnabled: true, -// key: "gpt-3.5", -// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } -``` - -`key` is mandatory for a config, but if a flag has no config or no config value was matched against the context, the `key` will be `undefined`. Make sure to check against this case when trying to use the configuration in your application. `payload` is an optional JSON value for arbitrary configuration needs. - -Note that, similar to `isEnabled`, accessing `config` on the object returned by `useFlag()` automatically -generates a `check` event. - -## `` component - -The `` initializes the Reflag SDK, fetches flags and starts listening for automated feedback survey events. The component can be configured using a number of props: - -- `publishableKey` is used to connect the provider to an _environment_ on Reflag. Find your `publishableKey` under [environment settings](https://app.reflag.com/env-current/settings/app-environments) in Reflag, -- `context`: An object containing `user`, `company`, and `other` properties that make up the evaluation context used to determine if a flag is enabled or not. `company` and `user` contexts are automatically transmitted to Reflag servers so the Reflag app can show you which companies have access to which flags etc. -- `company`, `user` and `otherContext` (deprecated): Individual props for context. These are deprecated in favor of the `context` prop and will be removed in the next major version. - - > [!Note] - > If you specify `company` and/or `user` they must have at least the `id` property, otherwise they will be ignored in their entirety. You should also supply anything additional you want to be able to evaluate flag targeting against, - -- `timeoutMs`: Timeout in milliseconds when fetching flags from the server, -- `staleWhileRevalidate`: If set to `true`, stale flags will be returned while refetching flags in the background, -- `expireTimeMs`: If set, flags will be cached between page loads for this duration (in milliseconds), -- `staleTimeMs`: Maximum time (in milliseconds) that stale flags will be returned if `staleWhileRevalidate` is true and new flags cannot be fetched. - -- `enableTracking`: Set to `false` to stop sending tracking events and user/company updates to Reflag. Useful when you're impersonating a user (defaults to `true`), -- `apiBaseUrl`: Optional base URL for the Reflag API. This also controls the SSE origin used for live flag updates and automated feedback, -- `credentials`: Optional fetch credentials mode. Set to `"include"` when proxying through your backend and authenticating with cookies; this also enables credentials for live-update SSE connections. -- `appBaseUrl`: Optional base URL for the Reflag application. Use this to override the default app URL, - -- `debug`: Set to `true` to enable debug logging to the console. If both `logger` and `debug` are provided, `logger` takes precedence, -- `logger`: Optional custom logger implementation (`debug`, `info`, `warn`, `error`) used by the underlying client, -- `toolbar`: Optional [configuration](https://docs.reflag.com/supported-languages/browser-sdk/globals#toolbaroptions) for the Reflag toolbar, -- `feedback`: Optional configuration for feedback collection - -### Loading states - -ReflagProvider lets you define a template to be shown while ReflagProvider is initializing: - -```vue ``` -If you want more control over loading screens, `useIsLoading()` returns a `Ref` which you can use to customize the loading experience. - -## `` component - -The `` component is a specialized version of `ReflagProvider` designed for server-side rendering and preloaded flag scenarios. It uses pre-fetched flags for the initial render, resulting in faster initial page loads and better SSR compatibility. +Provider options forward Browser settings, including cache/storage, fallback values, offline/tracking, feedback, toolbar, credentials and API origins. `logger` takes precedence over `debug`. Live updates default to true; disable with `:enable-live-flag-updates="false"`. For an ordinary client-only Nuxt provider, wrap it in ``. -### Usage +## `useFlag(key, defaultValue?)` ```vue - - -``` - -### Getting bootstrapped flags - -You'll typically generate the `bootstrappedFlags` object on your server using the Node.js SDK or by fetching from the Reflag API. Pass the full object returned by `getFlagsForBootstrap()` directly to ``. It contains: - -- `context`: the evaluation context used on the server -- `flags`: the evaluated raw flags -- `flagStateVersion`: an optional version used to avoid redundant live-update refreshes immediately after bootstrapping - -If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`. - -With `ReflagBootstrappedProvider`, `useOptInFlags()` triggers one flags refresh and reports `isLoading: true` until it settles. No refresh occurs unless the composable is used. - -Here's an example using the Node.js SDK: - -```js -// server.js (Node.js/SSR) -import { ReflagClient } from "@reflag/node-sdk"; - -const client = new ReflagClient({ - secretKey: "your-secret-key", // Use secret key on server -}); -await client.initialize(); +import { useFlag, useIsLoading, useTrack } from "@reflag/vue-sdk"; -// Fetch flags for specific context -const context = { - user: { id: "user123", name: "John Doe", email: "john@acme.com" }, - company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, -}; - -const bootstrappedFlags = client.getFlagsForBootstrap(context); - -// Pass to your Vue app -``` - -### ReflagBootstrappedProvider Props - -`ReflagBootstrappedProvider` accepts all the same props as `ReflagProvider` except: - -- `flags`: The pre-fetched bootstrapped state object containing `context`, evaluated `flags`, and an optional `flagStateVersion` -- All other props available in `ReflagProvider` are supported except `context`, `user`, `company`, and `otherContext` (which are extracted from `flags.context`) - -If the `flags` prop is not provided or is undefined, the provider will not initialize the client and will render in a non-loading state. - -> [!NOTE] -> The on-demand browser refresh and any later live flag updates use the browser-visible context. If your bootstrapped snapshot depends on server-only or secret context that is not available in the browser, refreshed flags may differ. In that case, keep `enableLiveFlagUpdates` disabled. - -## `` component - -The `` is a lower-level component that accepts a pre-initialized `ReflagClient` instance. This is useful for advanced use cases where you need full control over client initialization or want to share a client instance across multiple parts of your application. - -### ReflagClientProvider Usage - -```vue - ``` -### ReflagClientProvider Props - -The `ReflagClientProvider` accepts the following props: +Vue unwraps refs in templates; use `.value` in script. This is a **Vue ref**, not a flag wrapper. There are no `.isEnabled`, `.config`, flag `.track()` or flag loading fields. -- `client`: A pre-initialized `ReflagClient` instance +```ts +useFlag("release"); // ShallowRef +useFlag("model", "quick"); // ShallowRef +useFlag("limit", 10); // ShallowRef +useFlag("settings", { model: "quick" }); // ShallowRef<{ model: string }> +useFlag("nullable-settings", null); // ShallowRef +``` -### Slots +Defaults are positional JSON; literals widen without generated types. Non-null defaults require the same top-level category (Boolean/number/string/array/object), otherwise the default is returned with a payload-safe warning. Null explicitly accepts arbitrary JSON, including authored null; missing returns null. Valid false/zero/empty strings are preserved. Nested payload schemas are not validated by the SDK. Returned values/defaults are independent copies; readonly defaults are accepted. -- `loading`: Optional slot to show while the client is initializing (same as `ReflagProvider`) +The ref updates when evaluated flags change. A composable read registers the requested root through the Browser SDK, even if its result is ignored; refresh-driven rereads are identity-aware rate-limited and delivered in batches. Diagnostics/bootstrap inspection register none. Checks carry genuine selected key/source/snapshot identity, never payloads or invented selections for defaults/overrides. -> [!Note] -> Most applications should use `ReflagProvider` or `ReflagBootstrappedProvider` instead of `ReflagClientProvider`. Only use this component when you need the advanced control it provides. +## Loading, context and client access -## Hooks +- `useIsLoading()` returns a Boolean ref for provider loading; the `#loading` slot and `initialLoading` control initial presentation. +- `useUpdateUser()`, `useUpdateCompany()` and `useUpdateOtherContext()` return async shallow-update functions. +- `useClient()` exposes the Browser client, including `setContext(context)`, `refresh()` and `setFlagOverride(key, value)`. Undefined clears an override; null is a JSON override. +- `getFlags()` returns direct values and registers returned-root checks. Use `getFlagResults()` for tooling without checks. +- `useOnEvent(type, handler, client?)` subscribes on mount and unsubscribes on unmount. An external client may be supplied explicitly. -### `useFlag()` +A context change clears old values while fetching. Late responses for previous contexts or lower snapshot versions cannot replace accepted state. V2 cache/override storage is isolated from v1. Browser fetching uses `/flags/evaluated` and never re-evaluates/caches dependencies locally. -Returns the state of a given flag for the current context. The composable provides access to flags and their configurations. +## Bootstrap -`useFlag()` returns an object with this shape: +On the server, use Node SDK v2 and your framework's safe serialization mechanism: ```ts -{ - isEnabled: boolean, // is the flag enabled - track: () => void, // send a track event when the flag is used - requestFeedback: (...) => void // open up a feedback dialog - config: {key: string, payload: any}, // remote configuration for this flag - isLoading: boolean // if you want to manage loading state at the flag level -} +const flags = serverClient.getFlagsForBootstrap(context); ``` -Example: - ```vue - - - + + + ``` -See the reference docs for details. - -### `useOptInFlags()` and `useSetOptIn()` +Pass the entire envelope (`protocolVersion: 2`, context, evaluated flags and optional snapshot version). Legacy v1 bootstrap is rejected. A snapshot version is required for live updates. Never expose secret keys/server-only attributes in bootstrap data; later refreshes use browser-visible context, so disable live updates when they must not replace server-only evaluations. -Use these composables to build an end-user opt-in UI for flags where opt-in is enabled in Reflag. +`ReflagClientProvider` accepts an externally initialized Browser v2 client. Its lifecycle belongs to the application. -```vue - - - -``` - -By default, `useSetOptIn()` changes the opt-in for the current user, so the current context must include a `user.id`. To manage the current company's opt-in instead, pass `scope: "company"`; the context must then include a `company.id`. - -User and company opt-ins are managed independently. Setting `optedIn` to `false` removes the opt-in only for the selected scope. For example, cancelling a user's opt-in does not change the company's opt-in for the same flag. - -`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. It resolves after the latest flag state has been applied, the requested membership change has been confirmed, and components using `useOptInFlags()` have been notified. Vue schedules the resulting render normally, so it may not yet be committed when the promise resolves. - -`useOptInFlags()` returns `{ flags, isLoading }`, where both values are computed refs. With `ReflagBootstrappedProvider`, the composable fetches opt-in metadata on first use and reports `isLoading: true` until the flags refresh succeeds or fails. - -With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal initial flags request, so `useOptInFlags().isLoading` remains `false`. Use the general `useIsLoading()` composable or the provider's loading slot for that initial loading state. - -### `useTrack()` - -`useTrack()` returns a function which lets you send custom events to Reflag. It takes a string argument with the event name and optionally an object with properties to attach the event. - -Using `track` returned from `useFlag()` calls this track function with the flag key as the event name. - -```vue - - - -``` - -### `useRequestFeedback()` - -Returns a function that lets you open up a dialog to ask for feedback on a specific feature. This is useful for collecting targeted feedback about specific features. - -See [Automated Feedback Surveys](https://docs.reflag.com/product-handbook/live-satisfaction) for how to do this automatically, without code. - -When using the `useRequestFeedback` you must pass the flag key to `requestFeedback`. -The example below shows how to use `position` to ensure the popover appears next to the "Give feedback!" button. - -```vue - - - -``` - -See the [Feedback Documentation](https://github.com/reflagcom/javascript/blob/main/packages/browser-sdk/FEEDBACK.md#manual-feedback-collection) for more information on `requestFeedback` options. - -### `useSendFeedback()` - -Returns a function that lets you send feedback to Reflag. This is useful if you've manually collected feedback through your own UI and want to send it to Reflag. - -```vue - - - -``` - -### `useUpdateUser()`, `useUpdateCompany()` and `useUpdateOtherContext()` - -These composables return functions that let you update the attributes for the currently set user, company, or other context. Updates to user/company are stored remotely and affect flag targeting, while "other" context updates only affect the current session. - -```vue - - - -``` - -Note: To change the `user.id` or `company.id`, you need to update the props passed to `ReflagProvider` instead of using these composables. - -### `useClient()` - -Returns the `ReflagClient` used by the `ReflagProvider`. The client offers more functionality that -is not directly accessible through the other composables. - -```vue - - - +await sendFeedback({ flagKey: "huddles", score: 5 }); ``` -### `useIsLoading()` - -Returns a `Ref` to indicate if Reflag has finished loading. -Initially, the value will be `true` if no bootstrap flags have been provided and the client has not be initialized. +Opt-in metadata arrives with ordinary `/flags/evaluated` responses. Bootstrapped providers fetch missing metadata on demand from the same v2 endpoint without replacing server-derived values or provenance; loading settles on success or failure. User/company grants remain independent; successful mutations confirm versioned evaluation and membership in a single refresh. The metadata list retains presentation/membership fields such as `isEnabled`; ordinary reads remain direct values. -```vue - +`enableTracking: false` disables checks and ordinary tracking. Offline mode skips HTTP/tracking. See [Browser feedback documentation](../browser-sdk/FEEDBACK.md) for prompting/UI options. - -``` +## Optional key registration and migration -### `useOnEvent()` - -Vue composable for listening to Reflag client events. This composable automatically handles mounting and unmounting of event listeners. - -Available events include: - -- `flagsUpdated`: Triggered when flags are updated -- `track`: Triggered when tracking events are sent -- `feedback`: Triggered when feedback is sent - -```vue - - - -``` - -You can also provide a specific client instance if needed: - -```vue - - - +```ts +import "@reflag/vue-sdk"; +declare module "@reflag/vue-sdk" { + interface Flags { + huddles: boolean; + "huddle-settings": { maxParticipants: number }; + } +} ``` -## Content Security Policy (CSP) - -See [CSP](https://github.com/reflagcom/javascript/blob/main/packages/browser-sdk/README.md#content-security-policy-csp) for info on using Reflag React SDK with CSP - -## License - -MIT License +Registration constrains keys, not bare singular return types. Positional defaults determine inferred direct-value types. Old generated wrappers cannot restore wrapper semantics. -Copyright (c) 2025 Bucket ApS +Replace `const { isEnabled } = useFlag(key)` with `const enabled = useFlag(key)` (then `enabled.value` in script). Replace remote-config wrappers with separately authored multivariate flags and positional defaults, and move usage/feedback into their separate composables. Bootstrap both ends with v2. This branch does not publish SDKs or deploy the required server changes. diff --git a/packages/vue-sdk/dev/plain/App.vue b/packages/vue-sdk/dev/plain/App.vue index 77aa4e19b..1c799cc35 100644 --- a/packages/vue-sdk/dev/plain/App.vue +++ b/packages/vue-sdk/dev/plain/App.vue @@ -42,11 +42,14 @@ const isBootstrapped = computed(() => { v-else-if="isBootstrapped" :publishable-key="publishableKey" :flags="{ + protocolVersion: 2, context, flags: { huddles: { - key: 'huddles', - isEnabled: true, + value: true, + resolved: false, + ruleResults: [], + errors: [], }, }, }" diff --git a/packages/vue-sdk/dev/plain/components/FlagsList.vue b/packages/vue-sdk/dev/plain/components/FlagsList.vue index 6912ac6fe..0d6c770f2 100644 --- a/packages/vue-sdk/dev/plain/components/FlagsList.vue +++ b/packages/vue-sdk/dev/plain/components/FlagsList.vue @@ -1,117 +1,40 @@ - - + + diff --git a/packages/vue-sdk/dev/plain/components/StartHuddlesButton.vue b/packages/vue-sdk/dev/plain/components/StartHuddlesButton.vue index e26860c0e..350959836 100644 --- a/packages/vue-sdk/dev/plain/components/StartHuddlesButton.vue +++ b/packages/vue-sdk/dev/plain/components/StartHuddlesButton.vue @@ -1,35 +1,33 @@ + diff --git a/packages/vue-sdk/package.json b/packages/vue-sdk/package.json index 0e10b56f7..a23e0eaf2 100644 --- a/packages/vue-sdk/package.json +++ b/packages/vue-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/vue-sdk", - "version": "1.5.3", + "version": "2.0.0-next.0", "license": "MIT", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dev": "vite", "build": "tsc --project tsconfig.build.json && vite build", "test": "yarn test:types && vitest run", - "test:types": "tsc --project tsconfig.type-tests.json", + "test:types": "tsc --project tsconfig.type-tests.json && tsc --project tsconfig.type-tests-defaults.json", "test:watch": "vitest", "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml", "coverage": "yarn test --coverage", @@ -38,7 +38,7 @@ "fmt:fix": "oxfmt --write ." }, "dependencies": { - "@reflag/browser-sdk": "1.7.0" + "@reflag/browser-sdk": "workspace:^" }, "devDependencies": { "@reflag/tsconfig": "^0.0.2", diff --git a/packages/vue-sdk/src/ReflagBootstrappedProvider.vue b/packages/vue-sdk/src/ReflagBootstrappedProvider.vue index 63216a4fb..af2169204 100644 --- a/packages/vue-sdk/src/ReflagBootstrappedProvider.vue +++ b/packages/vue-sdk/src/ReflagBootstrappedProvider.vue @@ -23,13 +23,14 @@ const client = useReflagClient({ logger, }); -const isLoading = ref( - client.getState() !== "initialized" ? initialLoading : false, -); +let hasInitialized = client.getState() === "initialized"; +const isLoading = ref(hasInitialized ? false : initialLoading); useOnEvent( "stateUpdated", (state) => { - isLoading.value = state === "initializing"; + if (state === "initialized") hasInitialized = true; + isLoading.value = + state === "initializing" && (hasInitialized || initialLoading); }, client, ); diff --git a/packages/vue-sdk/src/hooks.ts b/packages/vue-sdk/src/hooks.ts index 27fc70078..70fdda318 100644 --- a/packages/vue-sdk/src/hooks.ts +++ b/packages/vue-sdk/src/hooks.ts @@ -5,6 +5,8 @@ import { onMounted, onUnmounted, ref, + shallowRef, + type ShallowRef, } from "vue"; import { @@ -20,8 +22,9 @@ import { FlagKey, OptInFlag, ProviderContextType, - RequestFlagFeedbackOptions, - TypedFlags, + FlagDefaultValue, + FlagValue, + FlagValueFromDefault, UseOptInFlagsResult, } from "./types"; import { SDK_VERSION } from "./version"; @@ -67,60 +70,28 @@ export function useReflagClient( return reflagClients.get(publishableKey)!; } -/** - * Vue composable for getting the state of a given flag for the current context. - * - * This composable returns an object with the state of the flag for the current context. - * - * @param key - The key of the flag to get the state of. - * @returns An object with the state of the flag. - * - * @example - * ```ts - * import { useFlag } from '@reflag/vue-sdk'; - * - * const { isEnabled, config, track, requestFeedback } = useFlag("huddles"); - * - * function StartHuddlesButton() { - * const { isEnabled, config: { payload }, track } = useFlag("huddles"); - * if (isEnabled) { - * return ; - * } - * ``` - */ -export function useFlag(key: TKey): TypedFlags[TKey] { +/** Reactive direct values: Boolean/false by default, or infer a positional JSON default. */ +export function useFlag( + key: FlagKey, + defaultValue?: undefined, +): ShallowRef; +export function useFlag( + key: FlagKey, + defaultValue: Default, +): ShallowRef>; +export function useFlag( + key: FlagKey, + defaultValue: FlagDefaultValue = false, +): ShallowRef { const client = useClient(); - const isLoading = useIsLoading(); - - const track = () => client.track(key); - const requestFeedback = (opts: RequestFlagFeedbackOptions) => - client.requestFeedback({ ...opts, flagKey: key }); - - const flag = ref( - client.getFlag(key) || { - isEnabled: false, - config: { key: undefined, payload: undefined }, - }, - ); - + const flag = shallowRef(client.getFlag(key, defaultValue)); const updateFlag = () => { - flag.value = client.getFlag(key); + flag.value = client.getFlag(key, defaultValue); }; - - onMounted(() => { - updateFlag(); - }); - - useOnEvent("flagsUpdated", updateFlag); - - return { - key, - isLoading, - isEnabled: computed(() => flag.value.isEnabled), - config: computed(() => flag.value.config), - track, - requestFeedback, - } as TypedFlags[TKey]; + useOnEvent("flagsUpdated", updateFlag, client); + // Catch updates between setup and listener registration. + onMounted(updateFlag); + return flag; } /** @@ -133,15 +104,15 @@ export function useFlag(key: TKey): TypedFlags[TKey] { */ export function useOptInFlags(): UseOptInFlagsResult { const client = useClient(); - const isBootstrapped = client.getConfig().bootstrapped; + const isLoading = useIsLoading(); const optInFlags = ref(client.getOptInFlags() as OptInFlag[]); const isOptInFlagsLoading = ref(client.getIsLoadingOptInFlags()); const updateOptInFlags = () => { optInFlags.value = client.getOptInFlags() as OptInFlag[]; }; - const updateIsLoading = (isLoading = client.getIsLoadingOptInFlags()) => { - isOptInFlagsLoading.value = isLoading; + const updateIsLoading = (loading = client.getIsLoadingOptInFlags()) => { + isOptInFlagsLoading.value = loading; }; useOnEvent("flagsUpdated", updateOptInFlags, client); @@ -154,7 +125,7 @@ export function useOptInFlags(): UseOptInFlagsResult { return { flags: computed(() => optInFlags.value), - isLoading: computed(() => isBootstrapped && isOptInFlagsLoading.value), + isLoading: computed(() => isLoading.value || isOptInFlagsLoading.value), }; } diff --git a/packages/vue-sdk/src/index.ts b/packages/vue-sdk/src/index.ts index 85b10c946..1cfa56847 100644 --- a/packages/vue-sdk/src/index.ts +++ b/packages/vue-sdk/src/index.ts @@ -20,11 +20,11 @@ export { } from "./hooks"; export type { BootstrappedFlags, - EmptyFlagRemoteConfig, - Flag, + FlagValue, + FlagDefaultValue, + FlagValueFromDefault, FlagKey, Flags, - FlagType, OptInFlag, ReflagBaseProps, ReflagBootstrappedProps, diff --git a/packages/vue-sdk/src/types.ts b/packages/vue-sdk/src/types.ts index b0430aa49..959ef3d9d 100644 --- a/packages/vue-sdk/src/types.ts +++ b/packages/vue-sdk/src/types.ts @@ -6,51 +6,25 @@ import type { InitOptions, Logger, OptInFlag as BrowserOptInFlag, - RawFlags, + FlagValue, + FlagDefaultValue, + FlagValueFromDefault, ReflagClient, ReflagContext, RequestFeedbackData, UserContext, } from "@reflag/browser-sdk"; -export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined }; - -export type FlagType = { - config?: { - payload: any; - }; -}; - -export type FlagRemoteConfig = - | { - key: string; - payload: any; - } - | EmptyFlagRemoteConfig; - -export interface Flag< - TConfig extends FlagType["config"] = EmptyFlagRemoteConfig, -> { - key: string; - isEnabled: Ref; - isLoading: Ref; - config: Ref<({ key: string } & TConfig) | EmptyFlagRemoteConfig>; - track(): Promise | undefined; - requestFeedback: (opts: RequestFlagFeedbackOptions) => void; -} +export type { FlagValue, FlagDefaultValue, FlagValueFromDefault }; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface Flags {} - export type TypedFlags = keyof Flags extends never - ? Record - : { - [TypedFlagKey in keyof Flags]: Flags[TypedFlagKey] extends FlagType - ? Flag - : Flag; - }; - -export type FlagKey = keyof TypedFlags; + ? Record + : { [Key in keyof Flags]: Flags[Key] | undefined }; +export type FlagKey = keyof Flags extends never + ? string + : Extract; export type OptInFlag = Omit & { key: FlagKey; @@ -66,9 +40,7 @@ export interface ProviderContextType { isLoading: Ref; } -export type BootstrappedFlags = BrowserBootstrappedState & { - flags: RawFlags; -}; +export type BootstrappedFlags = BrowserBootstrappedState; export type RequestFlagFeedbackOptions = Omit< RequestFeedbackData, diff --git a/packages/vue-sdk/test/opt-in-types.ts b/packages/vue-sdk/test/opt-in-types.ts index a5c0e05f1..690b95e02 100644 --- a/packages/vue-sdk/test/opt-in-types.ts +++ b/packages/vue-sdk/test/opt-in-types.ts @@ -1,8 +1,9 @@ -import { useOptInFlags, useSetOptIn } from "@reflag/vue-sdk"; +import { useFlag, useOptInFlags, useSetOptIn } from "@reflag/vue-sdk"; declare module "@reflag/vue-sdk" { interface Flags { - "generated-opt-in-flag": {}; + "generated-opt-in-flag": boolean; + legacy: { isEnabled: boolean; config: { payload: string } }; } } @@ -19,5 +20,10 @@ export function GeneratedOptInTypes() { // @ts-expect-error generated flag types still reject unknown literal keys void setOptIn("unknown-flag", { optedIn: true }); - return loading; + const enabled: boolean = useFlag("generated-opt-in-flag").value; + // @ts-expect-error old registered wrappers cannot change the Boolean contract + void useFlag("legacy").value.isEnabled; + // @ts-expect-error registration still constrains singular keys + useFlag("unknown-flag"); + return loading || enabled; } diff --git a/packages/vue-sdk/test/usage.test.ts b/packages/vue-sdk/test/usage.test.ts index 66cf0c19c..5e14dd063 100644 --- a/packages/vue-sdk/test/usage.test.ts +++ b/packages/vue-sdk/test/usage.test.ts @@ -18,14 +18,8 @@ import { beforeAll(() => { vi.spyOn(ReflagClient.prototype, "initialize").mockResolvedValue(undefined); vi.spyOn(ReflagClient.prototype, "stop").mockResolvedValue(undefined); - vi.spyOn(ReflagClient.prototype, "getFlag").mockReturnValue({ - isEnabled: true, - config: { key: "default", payload: { message: "Hello" } }, - track: vi.fn().mockResolvedValue(undefined), - requestFeedback: vi.fn(), - setIsEnabledOverride: vi.fn(), - isEnabledOverride: null, - }); + vi.spyOn(ReflagClient.prototype, "getFlag").mockReturnValue(true); + vi.spyOn(ReflagClient.prototype, "getState").mockReturnValue("initialized"); vi.spyOn(ReflagClient.prototype, "getFlags").mockReturnValue({}); vi.spyOn(ReflagClient.prototype, "getOptInFlags").mockReturnValue([]); vi.spyOn(ReflagClient.prototype, "getIsLoadingOptInFlags").mockReturnValue( @@ -42,6 +36,7 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(ReflagClient.prototype.getFlag).mockReturnValue(true); vi.mocked(ReflagClient.prototype.getOptInFlags).mockReturnValue([]); vi.mocked(ReflagClient.prototype.getIsLoadingOptInFlags).mockReturnValue( false, @@ -60,6 +55,64 @@ function getProvider() { } describe("ReflagProvider", () => { + test("useFlag returns a direct reactive value, forwards positional defaults and unsubscribes", async () => { + let update: (() => void) | undefined; + const unsubscribe = vi.fn(); + vi.mocked(ReflagClient.prototype.on).mockImplementation( + (event, handler) => { + if (event === "flagsUpdated") update = handler as () => void; + return unsubscribe; + }, + ); + vi.mocked(ReflagClient.prototype.getFlag) + .mockReturnValueOnce(false) + .mockReturnValue(false); + const Child = defineComponent({ + setup() { + return { enabled: useFlag("release") }; + }, + template: "{{ enabled }}", + }); + const wrapper = mount(ReflagProvider, { + props: { publishableKey: "reactive-direct-value" }, + slots: { default: () => h(Child) }, + }); + await nextTick(); + expect(wrapper.text()).toBe("false"); + expect(ReflagClient.prototype.getFlag).toHaveBeenCalledWith( + "release", + false, + ); + vi.mocked(ReflagClient.prototype.getFlag).mockReturnValue(true); + update?.(); + await nextTick(); + expect(wrapper.text()).toBe("true"); + wrapper.unmount(); + expect(unsubscribe).toHaveBeenCalled(); + }); + + test("useFlag forwards JSON defaults without wrappers or truthiness fallback", async () => { + vi.mocked(ReflagClient.prototype.getFlag).mockReturnValue(null); + const fallback = { model: "default" }; + const Child = defineComponent({ + setup() { + return { settings: useFlag("settings", fallback) }; + }, + template: "{{ settings === null ? 'off' : settings.model }}", + }); + const wrapper = mount(ReflagProvider, { + props: { publishableKey: "reactive-json-default" }, + slots: { default: () => h(Child) }, + }); + await nextTick(); + expect(ReflagClient.prototype.getFlag).toHaveBeenCalledWith( + "settings", + fallback, + ); + expect(wrapper.text()).toBe("off"); + wrapper.unmount(); + }); + test("provides the client", async () => { const Child = defineComponent({ setup() { @@ -156,7 +209,7 @@ describe("ReflagProvider", () => { await nextTick(); expect(wrapper.findComponent(Child).vm.flags).toEqual(optInFlags); - expect(wrapper.findComponent(Child).vm.isLoading).toBe(false); + expect(wrapper.findComponent(Child).vm.isLoading).toBe(true); expect(ReflagClient.prototype.on).toHaveBeenCalledWith( "flagsUpdated", expect.any(Function), @@ -195,12 +248,16 @@ describe("ReflagProvider", () => { props: { publishableKey: "key-loading-opt-in-flags", flags: { + protocolVersion: 2, context: { user: { id: "user-1" } }, flags: { optInFlag: { - key: "optInFlag", - isEnabled: false, - targetingVersion: 1, + value: false, + resolved: true, + variantKey: "false", + sourceVersionId: "version-1", + ruleResults: [], + errors: [], }, }, }, @@ -348,15 +405,19 @@ describe("ReflagProvider", () => { describe("ReflagBootstrappedProvider", () => { const bootstrappedFlags = { + protocolVersion: 2 as const, context: { user: { id: "test-user" }, company: { id: "test-company" }, }, flags: { "test-flag": { - key: "test-flag", - isEnabled: true, - config: { key: "default", payload: { message: "Hello" } }, + value: true, + resolved: true, + variantKey: "true", + sourceVersionId: "version-1", + ruleResults: [], + errors: [], }, }, flagStateVersion: 1, @@ -382,7 +443,7 @@ describe("ReflagBootstrappedProvider", () => { await nextTick(); expect(wrapper.findComponent(Child).vm.client).toBeDefined(); - expect(wrapper.findComponent(Child).vm.flag.isEnabled.value).toBe(true); + expect(wrapper.findComponent(Child).vm.flag).toBe(true); }); test("enables live flag updates by default for bootstrapped providers", async () => { diff --git a/packages/vue-sdk/tsconfig.type-tests-defaults.json b/packages/vue-sdk/tsconfig.type-tests-defaults.json new file mode 100644 index 000000000..d8f91f7fe --- /dev/null +++ b/packages/vue-sdk/tsconfig.type-tests-defaults.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.type-tests.json", + "compilerOptions": { "noUnusedLocals": false, "noUnusedParameters": false }, + "include": ["src", "type-tests/defaults.ts"] +} diff --git a/packages/vue-sdk/type-tests/defaults.ts b/packages/vue-sdk/type-tests/defaults.ts new file mode 100644 index 000000000..677b698e8 --- /dev/null +++ b/packages/vue-sdk/type-tests/defaults.ts @@ -0,0 +1,23 @@ +import type { ShallowRef } from "vue"; + +import { FlagValue, useFlag } from "@reflag/vue-sdk"; + +export function DefaultContracts() { + const enabled: ShallowRef = useFlag("release"); + const settings = useFlag("settings", { + model: "quick", + limits: [1], + } as const); + settings.value.model = "quality"; + settings.value.limits.push(2); + const _model: ShallowRef = useFlag("model", "quick"); + const _arbitrary: ShallowRef = useFlag("nullable", null); + // @ts-expect-error legacy wrappers are no longer returned + const { isEnabled: _isEnabled } = useFlag("release"); + // @ts-expect-error Boolean refs do not contain legacy wrapper fields + void enabled.value.isEnabled; + // @ts-expect-error JSON defaults cannot contain undefined + useFlag("settings", { invalid: undefined }); + // @ts-expect-error literal defaults widen + const _literal: ShallowRef<"quick"> = useFlag("model", "quick"); +} diff --git a/yarn.lock b/yarn.lock index 2fd37292d..7ab448763 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6888,7 +6888,19 @@ __metadata: languageName: node linkType: hard -"@reflag/browser-sdk@npm:1.7.0, @reflag/browser-sdk@workspace:packages/browser-sdk": +"@reflag/browser-sdk@npm:1.7.0": + version: 1.7.0 + resolution: "@reflag/browser-sdk@npm:1.7.0" + dependencies: + "@floating-ui/dom": "npm:^1.6.8" + fast-equals: "npm:^5.2.2" + js-cookie: "npm:^3.0.5" + preact: "npm:^10.22.1" + checksum: 10c0/c83adf86c666b027e57cdd88d2086ecb962e2ef208cf9e8dde17129e74e1bacc49d8fe0ee773982e175b7d3a63306f80554051273d1311480a6589ab9971eed0 + languageName: node + linkType: hard + +"@reflag/browser-sdk@workspace:^, @reflag/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@reflag/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -7012,7 +7024,7 @@ __metadata: languageName: node linkType: hard -"@reflag/node-sdk@workspace:*, @reflag/node-sdk@workspace:packages/node-sdk": +"@reflag/node-sdk@workspace:*, @reflag/node-sdk@workspace:^, @reflag/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@reflag/node-sdk@workspace:packages/node-sdk" dependencies: @@ -7090,11 +7102,26 @@ __metadata: languageName: unknown linkType: soft -"@reflag/react-sdk@npm:1.7.0, @reflag/react-sdk@workspace:^, @reflag/react-sdk@workspace:packages/react-sdk": +"@reflag/react-sdk@npm:1.7.0": + version: 1.7.0 + resolution: "@reflag/react-sdk@npm:1.7.0" + dependencies: + "@reflag/browser-sdk": "npm:1.7.0" + peerDependencies: + react: "*" + react-dom: "*" + peerDependenciesMeta: + react-dom: + optional: true + checksum: 10c0/7b07e2f9bc5d5ef9f8972694d3b0402bfbd434401f028729097ff127ca8b12f7189f5432e8b576aebcec771128033cb7586a717dfebd9012888e8d057b2c4263 + languageName: node + linkType: hard + +"@reflag/react-sdk@workspace:^, @reflag/react-sdk@workspace:packages/react-sdk": version: 0.0.0-use.local resolution: "@reflag/react-sdk@workspace:packages/react-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.7.0" + "@reflag/browser-sdk": "workspace:^" "@reflag/tsconfig": "npm:^0.0.2" "@testing-library/react": "npm:^15.0.7" "@types/jsdom": "npm:^21.1.6" @@ -7143,7 +7170,7 @@ __metadata: version: 0.0.0-use.local resolution: "@reflag/vue-sdk@workspace:packages/vue-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.7.0" + "@reflag/browser-sdk": "workspace:^" "@reflag/tsconfig": "npm:^0.0.2" "@types/jsdom": "npm:^21.1.6" "@types/node": "npm:^22.12.0" @@ -18610,7 +18637,7 @@ __metadata: version: 0.0.0-use.local resolution: "nextjs-bootstrap-demo@workspace:packages/react-sdk/dev/nextjs-bootstrap-demo" dependencies: - "@reflag/node-sdk-v1": "npm:@reflag/node-sdk@1.8.0" + "@reflag/node-sdk": "workspace:^" "@reflag/react-sdk": "workspace:^" "@types/node": "npm:^22.12.0" "@types/react": "npm:^18" From 3cc369ba3b494ae76a0e60e45ece8ca14a740669 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 5 Nov 2026 14:15:44 +0000 Subject: [PATCH 15/18] fix(node-sdk): check registered flag defaults --- .changeset/node-sdk-registered-defaults.md | 5 ++ packages/node-sdk/README.md | 4 +- packages/node-sdk/src/client.ts | 59 ++++++++------ packages/node-sdk/src/index.ts | 1 + packages/node-sdk/src/types.ts | 17 ++++ .../node-sdk/type-tests/augmented-defaults.ts | 77 ++++++++++++++++++- packages/node-sdk/type-tests/defaults.ts | 28 ++++++- 7 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 .changeset/node-sdk-registered-defaults.md diff --git a/.changeset/node-sdk-registered-defaults.md b/.changeset/node-sdk-registered-defaults.md new file mode 100644 index 000000000..4800434f7 --- /dev/null +++ b/.changeset/node-sdk-registered-defaults.md @@ -0,0 +1,5 @@ +--- +"@reflag/node-sdk": patch +--- + +Check non-null singular-read defaults against the registered flag value type for local, remote, and bound getters. Accept named-interface and readonly defaults, while preserving Boolean bare reads, explicit-null arbitrary JSON, default-inferred widening, and runtime JSON validation/copying. diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index fc95460d2..9a603dda0 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -152,7 +152,7 @@ Overrides are direct JSON values. Pushed layers win over base overrides and can ## Typed flags -Generated declarations are optional. Singular reads infer their return type from the default, or return Boolean when no default is supplied. Optionally augment `Flags` to constrain keys and type batch records: +Generated declarations are optional. Singular reads infer their return type from the default, or return Boolean when no default is supplied. Optionally augment `Flags` to constrain keys, check non-null defaults against each key's value schema, and type batch records: ```ts declare module "@reflag/node-sdk" { @@ -167,7 +167,7 @@ const model = reflag.getFlag(context, "model", "quick"); // string const models = reflag.getFlags(context); // models.model: "quick" | "quality" | undefined ``` -Augmentation does not change the bare-key Boolean contract or validate JSON payloads. The legacy CLI-generated config-wrapper types are not a v2 value schema. Update declarations when migrating. Both destructuring `.isEnabled` and accessing it through an assigned singular value fail type checking, including without generated declarations. See the Express example for a typed direct-value integration. +Augmentation rejects defaults with the wrong category, missing required properties, or incompatible nested types. Named-interface and readonly defaults are supported. Explicit `null` remains an arbitrary-JSON request, and bare/`undefined` reads still return Boolean. Return types continue to be inferred and widened from the default; generated types do not add nested runtime validation. The v2 CLI generates direct-value schemas from current production-environment variants. Regenerate old CLI wrapper declarations when migrating. Both destructuring `.isEnabled` and accessing it through an assigned singular value fail type checking, including without generated declarations. See the Express example for a typed direct-value integration. ## Bootstrap state diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 1d4647aa5..13116b3d7 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -36,7 +36,9 @@ import type { ContextWithTracking, EvaluatedFlagsAPIResponse, FlagCollection, + FlagDefaultFor, FlagDefaultValue, + Flags, FlagEvent, FlagOverrides, FlagOverridesFn, @@ -94,21 +96,21 @@ type BulkEvent = function normalizeOverrides( overrides?: FlagOverrides | FlagOverridesFn, ): FlagOverridesFn { + if (typeof overrides === "function") { + return (context) => { + const result = overrides(context); + ok( + isObject(result) && isFlagValue(result), + "flagOverrides must return a JSON value map", + ); + return result; + }; + } ok( - overrides === undefined || - typeof overrides === "function" || - (isObject(overrides) && isFlagValue(overrides)), + overrides === undefined || (isObject(overrides) && isFlagValue(overrides)), "flagOverrides must be a JSON value map or function", ); - if (typeof overrides !== "function") return () => overrides ?? {}; - return (context) => { - const result = overrides(context); - ok( - isObject(result) && isFlagValue(result), - "flagOverrides must return a JSON value map", - ); - return result; - }; + return () => overrides ?? {}; } function scheduleRefresh(delayMs: number, callback: () => void) { const timer = setTimeout(callback, delayMs); @@ -420,7 +422,10 @@ export class ReflagClient { key: Key, defaultValue?: undefined, ): boolean; - public getFlag( + public getFlag< + Key extends TypedFlagKey, + Default extends FlagDefaultFor, + >( options: ContextWithTracking, key: Key, defaultValue: Default, @@ -628,7 +633,7 @@ export class ReflagClient { ): Promise; public getFlagRemote< Key extends TypedFlagKey, - Default extends FlagDefaultValue, + Default extends FlagDefaultFor, >( key: Key, defaultValue: Default, @@ -876,23 +881,28 @@ export class BoundReflagClient { key: Key, defaultValue?: undefined, ): boolean; - public getFlag( - key: Key, - defaultValue: Default, - ): FlagValueFromDefault; + public getFlag< + Key extends TypedFlagKey, + Default extends FlagDefaultFor, + >(key: Key, defaultValue: Default): FlagValueFromDefault; public getFlag( key: TypedFlagKey, defaultValue: FlagDefaultValue = false, ): FlagValue { - return this.client.getFlag(this.options, key, defaultValue); + // The public overload already checked the key/default relationship. + return this.client.getFlag( + this.options, + key, + defaultValue as FlagDefaultFor, + ) as FlagValue; } - public getFlags() { + public getFlags(): TypedFlags { return this.client.getFlags(this.options); } public getFlagsForBootstrap() { return this.client.getFlagsForBootstrap(this.options); } - public async getFlagsRemote() { + public async getFlagsRemote(): Promise { return this.client.getFlagsRemote(undefined, undefined, this.options); } public getFlagRemote( @@ -901,19 +911,20 @@ export class BoundReflagClient { ): Promise; public getFlagRemote< Key extends TypedFlagKey, - Default extends FlagDefaultValue, + Default extends FlagDefaultFor, >(key: Key, defaultValue: Default): Promise>; public getFlagRemote( key: TypedFlagKey, defaultValue: FlagDefaultValue = false, ): Promise { + // Preserve the relationship checked by the bound public overload. return this.client.getFlagRemote( key, - defaultValue, + defaultValue as FlagDefaultFor, undefined, undefined, this.options, - ); + ) as Promise; } public bindClient(next: ContextWithTracking) { return new BoundReflagClient(this.client, { diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index ad75d01b7..d5f67a5d8 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -35,6 +35,7 @@ export type { ContextWithTracking, FlagCollection, FlagDefinition, + FlagDefaultFor, FlagDefaultValue, FlagEvent, FlagOverrides, diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 4066ca11b..54d44ab15 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -22,6 +22,23 @@ export type FlagDefaultValue = | readonly FlagDefaultValue[] | { readonly [key: string]: FlagDefaultValue }; +// Keep arbitrary JSON opaque instead of recursively expanding its index signature. +type ReadonlyFlagValue = FlagValue extends T + ? FlagDefaultValue | object + : T extends object + ? { readonly [Key in keyof T]: ReadonlyFlagValue } + : T; + +/** Registered defaults match the value schema; null explicitly requests arbitrary JSON. */ +export type FlagDefaultFor< + Registry, + Key extends PropertyKey, +> = keyof Registry extends never + ? FlagDefaultValue | object + : Key extends keyof Registry + ? ReadonlyFlagValue | null + : never; + /** Infer values from defaults, widening literals. Null explicitly requests arbitrary JSON. */ export type FlagValueFromDefault = T extends null ? FlagValue diff --git a/packages/node-sdk/type-tests/augmented-defaults.ts b/packages/node-sdk/type-tests/augmented-defaults.ts index 006ebc4d2..694dc6386 100644 --- a/packages/node-sdk/type-tests/augmented-defaults.ts +++ b/packages/node-sdk/type-tests/augmented-defaults.ts @@ -1,11 +1,26 @@ import { expectTypeOf } from "vitest"; -import { ReflagClient } from "../src"; +import { ReflagClient, type FlagValue } from "../src"; + +interface Prompt { + tone: string; +} +interface Settings { + model: string; + prompt: Prompt; + methods: string[]; +} declare module "../src" { interface Flags { release: boolean; settings: { model: string } | null; + "assistant-settings": Settings; + days: number; + arbitrary: FlagValue; + unknownArray: FlagValue[]; + methods: string[]; + model: "Swift" | "Reasoner"; legacy: { isEnabled: boolean; config: { payload: string } }; } } @@ -17,7 +32,6 @@ expectTypeOf(client.getFlag({}, "legacy")).toEqualTypeOf(); expectTypeOf(bound.getFlag("settings", { model: "Swift" })).toEqualTypeOf<{ model: string; }>(); -// A registry cannot silently change the runtime Boolean contract of a bare read. expectTypeOf(bound.getFlag("settings")).toEqualTypeOf(); expectTypeOf(client.getFlagRemote("settings")).toEqualTypeOf< Promise @@ -25,10 +39,67 @@ expectTypeOf(client.getFlagRemote("settings")).toEqualTypeOf< expectTypeOf(client.getFlags({}).settings).toEqualTypeOf< { model: string } | null | undefined >(); -// @ts-expect-error Registered keys still reject unknown flag names. +expectTypeOf(bound.getFlag("days", null)).toEqualTypeOf(); +expectTypeOf(bound.getFlag("model", "Swift")).toEqualTypeOf(); +expectTypeOf(bound.getFlag("methods", ["standard"] as const)).toEqualTypeOf< + string[] +>(); + +const fallback: Settings = { + model: "Swift", + prompt: { tone: "concise" }, + methods: ["standard"], +}; +expectTypeOf( + client.getFlag({}, "assistant-settings", fallback), +).toEqualTypeOf(); +expectTypeOf( + bound.getFlag("assistant-settings", fallback), +).toEqualTypeOf(); +expectTypeOf(bound.getFlag("arbitrary", fallback)).toEqualTypeOf(); +expectTypeOf(bound.getFlag("unknownArray", [fallback])).toEqualTypeOf< + Settings[] +>(); +expectTypeOf( + client.getFlagRemote("assistant-settings", fallback), +).toEqualTypeOf>(); +expectTypeOf(bound.getFlagRemote("assistant-settings", fallback)).toEqualTypeOf< + Promise +>(); +const frozen = { + model: "Swift", + prompt: { tone: "concise" }, + methods: ["standard"], +} as const; +const mutable = bound.getFlag("assistant-settings", frozen); +mutable.model = "Reasoner"; +mutable.prompt.tone = "detailed"; +mutable.methods.push("express"); + +// @ts-expect-error Unknown keys are rejected. client.getFlag({}, "unknown"); // @ts-expect-error Registered keys also apply to explicit defaults. bound.getFlag("unknown", { model: "Swift" }); +// @ts-expect-error Defaults must match this key, not another registered key's number type. +client.getFlag({}, "settings", 123); +// @ts-expect-error Bound getters check the registered value shape. +bound.getFlag("assistant-settings", { model: "Swift" }); +// @ts-expect-error Nested properties are checked against the registered schema. +bound.getFlag("assistant-settings", { + model: "Swift", + prompt: { tone: 3 }, + methods: [], +}); +// @ts-expect-error Array elements are checked. +bound.getFlag("methods", [123]); +// @ts-expect-error Literal unions constrain defaults even though results widen. +bound.getFlag("model", "Unknown"); +// @ts-expect-error Boolean defaults cannot request an object-valued registered flag. +bound.getFlag("settings", false); +// @ts-expect-error Remote root getters also check the registered schema. +client.getFlagRemote("settings", 123); +// @ts-expect-error Bound remote getters also check the registered schema. +bound.getFlagRemote("days", "three"); // @ts-expect-error Old generated wrapper types cannot restore the singular wrapper API. const { isEnabled: _legacyAlias } = bound.getFlag("legacy"); const legacy = client.getFlag({}, "legacy"); diff --git a/packages/node-sdk/type-tests/defaults.ts b/packages/node-sdk/type-tests/defaults.ts index 1fa488f58..edbba7ed7 100644 --- a/packages/node-sdk/type-tests/defaults.ts +++ b/packages/node-sdk/type-tests/defaults.ts @@ -1,6 +1,6 @@ import { expectTypeOf } from "vitest"; -import { ReflagClient, type FlagValue } from "../src"; +import { ReflagClient, type FlagDefaultValue, type FlagValue } from "../src"; // Deliberately no Flags augmentation or generated declarations. declare const client: ReflagClient; @@ -43,6 +43,13 @@ expectTypeOf(bound.getFlagRemote("settings", { model: "Swift" })).toEqualTypeOf< Promise<{ model: string }> >(); +function forwardDefault(value: Default) { + return bound.getFlag("settings", value); +} +expectTypeOf(forwardDefault({ model: "Swift" })).toEqualTypeOf<{ + model: string; +}>(); + // @ts-expect-error Boolean reads do not return the legacy wrapper, even with aliases. const { isEnabled: _legacyAlias } = client.getFlag({}, "release"); const legacyValue = client.getFlag({}, "release"); @@ -54,5 +61,20 @@ expectTypeOf(bound.getFlag("settings", dictionaryDefault)).toEqualTypeOf< Record >(); -// @ts-expect-error Functions are not JSON defaults. -bound.getFlag("settings", () => false); +interface Prompt { + tone: string; +} +interface Settings { + model: string; + prompt: Prompt; +} +const interfaceDefault: Settings = { + model: "Swift", + prompt: { tone: "concise" }, +}; +expectTypeOf( + bound.getFlag("settings", interfaceDefault), +).toEqualTypeOf(); +expectTypeOf(client.getFlagRemote("settings", interfaceDefault)).toEqualTypeOf< + Promise +>(); From 1729e9e6907e33bef51cbc1091179ba8fc15e491 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 5 Nov 2026 14:19:53 +0000 Subject: [PATCH 16/18] fix: check registered defaults in browser and framework SDKs --- .../browser-framework-registered-defaults.md | 9 +++ packages/browser-sdk/src/client.ts | 2 +- packages/browser-sdk/src/flag/values.ts | 17 +++++ packages/browser-sdk/src/index.ts | 1 + packages/browser-sdk/type-tests/defaults.ts | 8 ++- packages/react-sdk/README.md | 4 +- packages/react-sdk/package.json | 2 +- packages/react-sdk/src/index.tsx | 17 +++-- .../tsconfig.type-tests-registered.json | 5 ++ packages/react-sdk/type-tests/defaults.tsx | 13 +++- .../type-tests/registered-defaults.tsx | 65 +++++++++++++++++++ packages/vue-sdk/README.md | 4 +- packages/vue-sdk/package.json | 2 +- packages/vue-sdk/src/hooks.ts | 10 ++- packages/vue-sdk/src/types.ts | 8 ++- .../tsconfig.type-tests-registered.json | 5 ++ packages/vue-sdk/type-tests/defaults.ts | 12 +++- .../vue-sdk/type-tests/registered-defaults.ts | 65 +++++++++++++++++++ packages/vue-sdk/vite.config.mjs | 2 +- 19 files changed, 230 insertions(+), 21 deletions(-) create mode 100644 .changeset/browser-framework-registered-defaults.md create mode 100644 packages/react-sdk/tsconfig.type-tests-registered.json create mode 100644 packages/react-sdk/type-tests/registered-defaults.tsx create mode 100644 packages/vue-sdk/tsconfig.type-tests-registered.json create mode 100644 packages/vue-sdk/type-tests/registered-defaults.ts diff --git a/.changeset/browser-framework-registered-defaults.md b/.changeset/browser-framework-registered-defaults.md new file mode 100644 index 000000000..32ed1719e --- /dev/null +++ b/.changeset/browser-framework-registered-defaults.md @@ -0,0 +1,9 @@ +--- +"@reflag/browser-sdk": patch +"@reflag/react-sdk": patch +"@reflag/vue-sdk": patch +--- + +Accept named-interface defaults in Browser reads and check non-null React/Vue defaults against registered flag value schemas, including React Suspense reads. Preserve Boolean bare reads, explicit-null arbitrary JSON, readonly-default support, default-inferred widening, and runtime JSON validation/copying. + +Keep Vue declaration imports pointed at the public Vue types so published refs retain their value types rather than degrading to any. diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 2cf570da7..5da62b072 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -1378,7 +1378,7 @@ export class ReflagClient { /** Boolean-first reads; positional defaults select other top-level JSON categories. */ getFlag(key: string, defaultValue?: undefined): boolean; - getFlag( + getFlag( key: string, defaultValue: Default, ): FlagValueFromDefault; diff --git a/packages/browser-sdk/src/flag/values.ts b/packages/browser-sdk/src/flag/values.ts index 0a504375b..5334f271f 100644 --- a/packages/browser-sdk/src/flag/values.ts +++ b/packages/browser-sdk/src/flag/values.ts @@ -14,6 +14,23 @@ export type FlagDefaultValue = | readonly FlagDefaultValue[] | { readonly [key: string]: FlagDefaultValue }; +// Keep arbitrary JSON opaque instead of recursively expanding its index signature. +type ReadonlyFlagValue = FlagValue extends T + ? FlagDefaultValue | object + : T extends object + ? { readonly [Key in keyof T]: ReadonlyFlagValue } + : T; + +/** Registered defaults match the value schema; null explicitly requests arbitrary JSON. */ +export type FlagDefaultFor< + Registry, + Key extends PropertyKey, +> = keyof Registry extends never + ? FlagDefaultValue | object + : Key extends keyof Registry + ? ReadonlyFlagValue | null + : never; + /** Widen literals and return mutable copies. Null requests arbitrary JSON. */ export type FlagValueFromDefault = T extends null ? FlagValue diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 7eb727660..30b349850 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -49,6 +49,7 @@ export type { } from "./flag/flags"; export type { FlagValue, + FlagDefaultFor, FlagDefaultValue, FlagValueFromDefault, } from "./flag/values"; diff --git a/packages/browser-sdk/type-tests/defaults.ts b/packages/browser-sdk/type-tests/defaults.ts index 382a37824..aa49a8772 100644 --- a/packages/browser-sdk/type-tests/defaults.ts +++ b/packages/browser-sdk/type-tests/defaults.ts @@ -14,7 +14,11 @@ const _values: Record = client.getFlags(); // @ts-expect-error bare reads no longer return wrappers const { isEnabled: _isEnabled } = client.getFlag("release"); -// @ts-expect-error JSON defaults cannot contain undefined -client.getFlag("settings", { invalid: undefined }); +interface Settings { + model: string; + limits: number[]; +} +const fallback: Settings = { model: "quick", limits: [1] }; +const _interface: Settings = client.getFlag("settings", fallback); // @ts-expect-error literal defaults widen to string, not a specific authored variant const _literal: "quick" = client.getFlag("model", "quick"); diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 1c8552ff8..0b86a1b53 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -2,7 +2,7 @@ Direct-value feature flags for React, backed by [Browser SDK v2](../browser-sdk/README.md). -> Breaking prerelease: requires compatible v2 server endpoints and **Node SDK v2** for bootstrap. The existing CLI's wrapper-shaped output has not been migrated; useful default-based inference does not require generated declarations. React Native remains on published React/Browser v1 in this repository. +> Breaking prerelease: requires compatible v2 server endpoints and **Node SDK v2** for bootstrap. The v2 CLI generates direct-value declarations; regenerate legacy wrapper-shaped output when migrating. Useful default-based inference does not require generated declarations. React Native remains on published React/Browser v1 in this repository. ## Setup @@ -102,7 +102,7 @@ declare module "@reflag/react-sdk" { } ``` -Registration constrains keys. It does not change a bare singular read into a wrapper or infer a multivariate return type without a default. The positional default determines the returned type. Existing legacy generated wrapper declarations cannot restore `.isEnabled` access on a Boolean read; replace them with direct-value declarations when updating generation. +Registration constrains keys and checks non-null defaults against the registered value schema, including required and nested properties. Named-interface and readonly defaults are supported. Explicit `null` remains an arbitrary-JSON request; bare/`undefined` reads remain Boolean. The positional default still determines the widened return type, and generated declarations do not add nested runtime validation. The same rules apply to `useFlagSuspense`. The v2 CLI generates direct-value declarations from current production-environment variants; regenerate legacy wrapper declarations when migrating. ## Bootstrap and SSR diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index d38fe8cbd..5e84a8e83 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -27,7 +27,7 @@ "dev": "vite", "build": "tsc --project tsconfig.build.json && vite build", "test": "yarn test:types && vitest run", - "test:types": "tsc --project tsconfig.type-tests.json && tsc --project tsconfig.type-tests-defaults.json", + "test:types": "tsc --project tsconfig.type-tests.json && tsc --project tsconfig.type-tests-defaults.json && tsc --project tsconfig.type-tests-registered.json", "test:watch": "vitest", "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml", "coverage": "yarn test --coverage", diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index bfa8ef11f..adbd1d844 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -19,6 +19,7 @@ import { InitOptions, Logger, OptInFlag as BrowserOptInFlag, + FlagDefaultFor, FlagDefaultValue, FlagValue, FlagValueFromDefault, @@ -610,8 +611,12 @@ export type UseOptInFlagsResult = { /** Boolean-first, non-suspending reads. Supply a positional default for JSON values. */ export function useFlag(key: FlagKey, defaultValue?: undefined): boolean; -export function useFlag( - key: FlagKey, +export function useFlag< + Key extends FlagKey, + Default extends FlagDefaultFor, +>(key: Key, defaultValue: Default): FlagValueFromDefault; +export function useFlag( + key: keyof Flags extends never ? string : never, defaultValue: Default, ): FlagValueFromDefault; export function useFlag( @@ -626,8 +631,12 @@ export function useFlagSuspense( key: FlagKey, defaultValue?: undefined, ): boolean; -export function useFlagSuspense( - key: FlagKey, +export function useFlagSuspense< + Key extends FlagKey, + Default extends FlagDefaultFor, +>(key: Key, defaultValue: Default): FlagValueFromDefault; +export function useFlagSuspense( + key: keyof Flags extends never ? string : never, defaultValue: Default, ): FlagValueFromDefault; export function useFlagSuspense( diff --git a/packages/react-sdk/tsconfig.type-tests-registered.json b/packages/react-sdk/tsconfig.type-tests-registered.json new file mode 100644 index 000000000..ed5b06f1a --- /dev/null +++ b/packages/react-sdk/tsconfig.type-tests-registered.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.type-tests.json", + "compilerOptions": { "noUnusedLocals": false, "noUnusedParameters": false }, + "include": ["src", "type-tests/registered-defaults.tsx"] +} diff --git a/packages/react-sdk/type-tests/defaults.tsx b/packages/react-sdk/type-tests/defaults.tsx index e650cfbda..a23d49404 100644 --- a/packages/react-sdk/type-tests/defaults.tsx +++ b/packages/react-sdk/type-tests/defaults.tsx @@ -19,8 +19,17 @@ export function DefaultContracts() { const release = useFlag("release"); // @ts-expect-error assigned reads cannot use legacy wrapper fields either void release.isEnabled; - // @ts-expect-error JSON defaults cannot contain undefined - useFlag("settings", { invalid: undefined }); + interface Settings { + model: string; + limits: number[]; + } + const fallback: Settings = { model: "quick", limits: [1] }; + const _interface: Settings = useFlag("settings", fallback); + const _explicit: Settings = useFlag("settings", fallback); + const _explicitReady: Settings = useFlagSuspense( + "settings", + fallback, + ); // @ts-expect-error literals widen const _literal: "quick" = useFlag("model", "quick"); return null; diff --git a/packages/react-sdk/type-tests/registered-defaults.tsx b/packages/react-sdk/type-tests/registered-defaults.tsx new file mode 100644 index 000000000..21674c79d --- /dev/null +++ b/packages/react-sdk/type-tests/registered-defaults.tsx @@ -0,0 +1,65 @@ +import { FlagValue, useFlag, useFlagSuspense } from "@reflag/react-sdk"; + +interface Prompt { + tone: string; +} +interface Settings { + model: "quick" | "quality"; + prompt: Prompt; + methods: string[]; +} + +declare module "@reflag/react-sdk" { + interface Flags { + enabled: boolean; + settings: Settings; + days: number; + arbitrary: FlagValue; + unknownArray: FlagValue[]; + methods: string[]; + model: "quick" | "quality"; + nullable: Settings | null; + } +} + +export function RegisteredContracts() { + const fallback: Settings = { + model: "quick", + prompt: { tone: "concise" }, + methods: ["standard"], + }; + const _settings = useFlag("settings", fallback); + const _arbitraryShape = useFlag("arbitrary", fallback); + const _unknownArray = useFlag("unknownArray", [fallback]); + const mutable = useFlag("settings", { + model: "quick", + prompt: { tone: "concise" }, + methods: ["standard"], + } as const); + mutable.model = "quality"; + mutable.prompt.tone = "detailed"; + mutable.methods.push("express"); + const _ready = useFlagSuspense("settings", fallback); + const _bare: boolean = useFlag("settings"); + const _model: string = useFlag("model", "quick"); + const _array: string[] = useFlag("methods", ["standard"] as const); + const _arbitrary: FlagValue = useFlag("settings", null); + const _nullable: FlagValue = useFlagSuspense("nullable", null); + // @ts-expect-error Registered keys reject unknown names. + useFlag("unknown", false); + // @ts-expect-error A numeric flag elsewhere in the registry must not permit this mismatch. + useFlag("settings", 123); + // @ts-expect-error Required fields must be present. + useFlag("settings", { model: "quick" }); + // @ts-expect-error Nested property types must match. + useFlag("settings", { model: "quick", prompt: { tone: 3 }, methods: [] }); + // @ts-expect-error Registered literal unions constrain defaults. + useFlag("model", "unknown"); + // @ts-expect-error Array elements must match. + useFlag("methods", [123]); + // @ts-expect-error The readiness-waiting hook applies the same schema check. + useFlagSuspense("settings", false); + // @ts-expect-error An explicit generic must not bypass the key's registered schema. + useFlag("settings", 123); + return null; +} diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index 508267e56..8787ab790 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -2,7 +2,7 @@ Reactive direct-value flags for Vue 3, backed by [Browser SDK v2](../browser-sdk/README.md). -> Breaking prerelease: requires the compatible v2 server protocol. Bootstrap must come from **Node SDK v2**, not v1. Generated declarations are optional; existing CLI wrapper-shaped generation has not been migrated. +> Breaking prerelease: requires the compatible v2 server protocol. Bootstrap must come from **Node SDK v2**, not v1. Generated declarations are optional; the v2 CLI supports `--format vue` and direct-value schemas. Regenerate legacy wrapper-shaped declarations when migrating. ## Setup @@ -122,6 +122,6 @@ declare module "@reflag/vue-sdk" { } ``` -Registration constrains keys, not bare singular return types. Positional defaults determine inferred direct-value types. Old generated wrappers cannot restore wrapper semantics. +Registration constrains keys and checks non-null defaults against the registered value schema, including required and nested properties. Named-interface and readonly defaults are supported. Explicit `null` remains an arbitrary-JSON request; bare/`undefined` reads remain Boolean refs. Positional defaults still determine widened direct-value types, and generated declarations do not add nested runtime validation. The v2 CLI generates these schemas from current production-environment variants with `--format vue`; regenerate old wrapper declarations when migrating. Replace `const { isEnabled } = useFlag(key)` with `const enabled = useFlag(key)` (then `enabled.value` in script). Replace remote-config wrappers with separately authored multivariate flags and positional defaults, and move usage/feedback into their separate composables. Bootstrap both ends with v2. This branch does not publish SDKs or deploy the required server changes. diff --git a/packages/vue-sdk/package.json b/packages/vue-sdk/package.json index a23e0eaf2..978f06abb 100644 --- a/packages/vue-sdk/package.json +++ b/packages/vue-sdk/package.json @@ -25,7 +25,7 @@ "dev": "vite", "build": "tsc --project tsconfig.build.json && vite build", "test": "yarn test:types && vitest run", - "test:types": "tsc --project tsconfig.type-tests.json && tsc --project tsconfig.type-tests-defaults.json", + "test:types": "tsc --project tsconfig.type-tests.json && tsc --project tsconfig.type-tests-defaults.json && tsc --project tsconfig.type-tests-registered.json", "test:watch": "vitest", "test:ci": "yarn test --reporter=default --reporter=junit --outputFile=junit.xml", "coverage": "yarn test --coverage", diff --git a/packages/vue-sdk/src/hooks.ts b/packages/vue-sdk/src/hooks.ts index 70fdda318..9b3f8fbee 100644 --- a/packages/vue-sdk/src/hooks.ts +++ b/packages/vue-sdk/src/hooks.ts @@ -22,6 +22,8 @@ import { FlagKey, OptInFlag, ProviderContextType, + Flags, + FlagDefaultFor, FlagDefaultValue, FlagValue, FlagValueFromDefault, @@ -75,8 +77,12 @@ export function useFlag( key: FlagKey, defaultValue?: undefined, ): ShallowRef; -export function useFlag( - key: FlagKey, +export function useFlag< + Key extends FlagKey, + Default extends FlagDefaultFor, +>(key: Key, defaultValue: Default): ShallowRef>; +export function useFlag( + key: keyof Flags extends never ? string : never, defaultValue: Default, ): ShallowRef>; export function useFlag( diff --git a/packages/vue-sdk/src/types.ts b/packages/vue-sdk/src/types.ts index 959ef3d9d..c5663011d 100644 --- a/packages/vue-sdk/src/types.ts +++ b/packages/vue-sdk/src/types.ts @@ -7,6 +7,7 @@ import type { Logger, OptInFlag as BrowserOptInFlag, FlagValue, + FlagDefaultFor, FlagDefaultValue, FlagValueFromDefault, ReflagClient, @@ -15,7 +16,12 @@ import type { UserContext, } from "@reflag/browser-sdk"; -export type { FlagValue, FlagDefaultValue, FlagValueFromDefault }; +export type { + FlagValue, + FlagDefaultFor, + FlagDefaultValue, + FlagValueFromDefault, +}; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface Flags {} diff --git a/packages/vue-sdk/tsconfig.type-tests-registered.json b/packages/vue-sdk/tsconfig.type-tests-registered.json new file mode 100644 index 000000000..9114cfb03 --- /dev/null +++ b/packages/vue-sdk/tsconfig.type-tests-registered.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.type-tests.json", + "compilerOptions": { "noUnusedLocals": false, "noUnusedParameters": false }, + "include": ["src", "type-tests/registered-defaults.ts"] +} diff --git a/packages/vue-sdk/type-tests/defaults.ts b/packages/vue-sdk/type-tests/defaults.ts index 677b698e8..eed8b6cb4 100644 --- a/packages/vue-sdk/type-tests/defaults.ts +++ b/packages/vue-sdk/type-tests/defaults.ts @@ -16,8 +16,16 @@ export function DefaultContracts() { const { isEnabled: _isEnabled } = useFlag("release"); // @ts-expect-error Boolean refs do not contain legacy wrapper fields void enabled.value.isEnabled; - // @ts-expect-error JSON defaults cannot contain undefined - useFlag("settings", { invalid: undefined }); + interface Settings { + model: string; + limits: number[]; + } + const fallback: Settings = { model: "quick", limits: [1] }; + const _interface: ShallowRef = useFlag("settings", fallback); + const _explicit: ShallowRef = useFlag( + "settings", + fallback, + ); // @ts-expect-error literal defaults widen const _literal: ShallowRef<"quick"> = useFlag("model", "quick"); } diff --git a/packages/vue-sdk/type-tests/registered-defaults.ts b/packages/vue-sdk/type-tests/registered-defaults.ts new file mode 100644 index 000000000..aee065507 --- /dev/null +++ b/packages/vue-sdk/type-tests/registered-defaults.ts @@ -0,0 +1,65 @@ +import type { ShallowRef } from "vue"; + +import { FlagValue, useFlag } from "@reflag/vue-sdk"; + +interface Prompt { + tone: string; +} +interface Settings { + model: "quick" | "quality"; + prompt: Prompt; + methods: string[]; +} + +declare module "@reflag/vue-sdk" { + interface Flags { + enabled: boolean; + settings: Settings; + days: number; + arbitrary: FlagValue; + unknownArray: FlagValue[]; + methods: string[]; + model: "quick" | "quality"; + nullable: Settings | null; + } +} + +export function RegisteredContracts() { + const fallback: Settings = { + model: "quick", + prompt: { tone: "concise" }, + methods: ["standard"], + }; + const _settings = useFlag("settings", fallback); + const _arbitraryShape = useFlag("arbitrary", fallback); + const _unknownArray = useFlag("unknownArray", [fallback]); + const mutable = useFlag("settings", { + model: "quick", + prompt: { tone: "concise" }, + methods: ["standard"], + } as const); + mutable.value.model = "quality"; + mutable.value.prompt.tone = "detailed"; + mutable.value.methods.push("express"); + const _bare: ShallowRef = useFlag("settings"); + const _model: ShallowRef = useFlag("model", "quick"); + const _array: ShallowRef = useFlag("methods", [ + "standard", + ] as const); + const _arbitrary: ShallowRef = useFlag("settings", null); + const _nullable: ShallowRef = useFlag("nullable", null); + // @ts-expect-error Registered keys reject unknown names. + useFlag("unknown", false); + // @ts-expect-error A numeric flag elsewhere in the registry must not permit this mismatch. + useFlag("settings", 123); + // @ts-expect-error Required fields must be present. + useFlag("settings", { model: "quick" }); + // @ts-expect-error Nested property types must match. + useFlag("settings", { model: "quick", prompt: { tone: 3 }, methods: [] }); + // @ts-expect-error Registered literal unions constrain defaults. + useFlag("model", "unknown"); + // @ts-expect-error Array elements must match. + useFlag("methods", [123]); + // @ts-expect-error An explicit generic must not bypass the key's registered schema. + useFlag("settings", 123); +} diff --git a/packages/vue-sdk/vite.config.mjs b/packages/vue-sdk/vite.config.mjs index 7d25d020c..47378ece9 100644 --- a/packages/vue-sdk/vite.config.mjs +++ b/packages/vue-sdk/vite.config.mjs @@ -19,7 +19,7 @@ export default defineConfig({ }, plugins: [ vue(), - dts({ insertTypesEntry: true, exclude: ["dev"] }), + dts({ insertTypesEntry: true, exclude: ["dev"], aliasesExclude: ["vue"] }), preserveDirectives(), ], build: { From 595758d00693eefc677b718c30d8fbb845a2fbad Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 5 Nov 2026 14:39:46 +0000 Subject: [PATCH 17/18] fix(node-sdk): keep internal helpers out of API docs --- docs.sh | 2 +- packages/node-sdk/typedoc.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs.sh b/docs.sh index 9b57fa72b..8cbf72df0 100755 --- a/docs.sh +++ b/docs.sh @@ -24,7 +24,7 @@ do echo "Processing $file..." # Fix anchor links in globals.md files - if [[ "$file" == *"globals.md" ]]; then + if [ "${file##*/}" = "globals.md" ]; then sed -r "$SEDCOMMAND" "$file" > "$file.fixed" rm "$file" mv "$file.fixed" "$file" diff --git a/packages/node-sdk/typedoc.json b/packages/node-sdk/typedoc.json index 3ab82e82b..5981c7607 100644 --- a/packages/node-sdk/typedoc.json +++ b/packages/node-sdk/typedoc.json @@ -1,3 +1,4 @@ { - "tsconfig": "tsconfig.build.json" + "tsconfig": "tsconfig.build.json", + "intentionallyNotExported": ["ReadonlyFlagValue", "LOG_LEVELS"] } From 9f3a0ae027e386747a2d4cf7683a68dcab70b355 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 5 Nov 2026 14:40:43 +0000 Subject: [PATCH 18/18] fix(browser-sdk): document only public default types --- packages/browser-sdk/typedoc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/browser-sdk/typedoc.json b/packages/browser-sdk/typedoc.json index 3ab82e82b..1061f29cc 100644 --- a/packages/browser-sdk/typedoc.json +++ b/packages/browser-sdk/typedoc.json @@ -1,3 +1,4 @@ { - "tsconfig": "tsconfig.build.json" + "tsconfig": "tsconfig.build.json", + "intentionallyNotExported": ["ReadonlyFlagValue"] }