Initial Commit
This commit is contained in:
13
packages/foundation/feature-flags/README.md
Normal file
13
packages/foundation/feature-flags/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# `feature-flags`
|
||||
|
||||
Feature-flag evaluation engine with percentage and attribute targeting.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `feature-flags`
|
||||
|
||||
1 package.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/feature-flags`](./feature-flags/README.md) | Provider-agnostic feature flag evaluation engine for controlling feature rollout without code deploys. The package ships a `FlagManager` high-level façade, a `… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/foundation/feature-flags/_intro.md
Normal file
1
packages/foundation/feature-flags/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Feature-flag evaluation engine with percentage and attribute targeting.
|
||||
96
packages/foundation/feature-flags/feature-flags/README.md
Normal file
96
packages/foundation/feature-flags/feature-flags/README.md
Normal file
File diff suppressed because one or more lines are too long
126
packages/foundation/feature-flags/feature-flags/package.json
Normal file
126
packages/foundation/feature-flags/feature-flags/package.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"name": "@suaveplan/feature-flags",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Provider-agnostic feature flag evaluation with typed flag values, strategy chains, and deterministic bucketing",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./strategies": {
|
||||
"import": {
|
||||
"types": "./dist/strategies/index.d.ts",
|
||||
"default": "./dist/strategies/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/strategies/index.d.ts",
|
||||
"default": "./dist/strategies/index.js"
|
||||
}
|
||||
},
|
||||
"./providers": {
|
||||
"import": {
|
||||
"types": "./dist/providers/index.d.ts",
|
||||
"default": "./dist/providers/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/providers/index.d.ts",
|
||||
"default": "./dist/providers/index.js"
|
||||
}
|
||||
},
|
||||
"./registry": {
|
||||
"import": {
|
||||
"types": "./dist/core/registry/flag-registry.d.ts",
|
||||
"default": "./dist/core/registry/flag-registry.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/registry/flag-registry.d.ts",
|
||||
"default": "./dist/core/registry/flag-registry.js"
|
||||
}
|
||||
},
|
||||
"./manager": {
|
||||
"import": {
|
||||
"types": "./dist/core/manager/flag-manager.d.ts",
|
||||
"default": "./dist/core/manager/flag-manager.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/manager/flag-manager.d.ts",
|
||||
"default": "./dist/core/manager/flag-manager.js"
|
||||
}
|
||||
},
|
||||
"./evaluator": {
|
||||
"import": {
|
||||
"types": "./dist/core/evaluator/evaluator.d.ts",
|
||||
"default": "./dist/core/evaluator/evaluator.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/evaluator/evaluator.d.ts",
|
||||
"default": "./dist/core/evaluator/evaluator.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/feature-flags-error.d.ts",
|
||||
"default": "./dist/errors/feature-flags-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/feature-flags-error.d.ts",
|
||||
"default": "./dist/errors/feature-flags-error.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# evaluator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `Evaluator` is the core computation engine of `@suaveplan/feature-flags`. It takes a flag definition, an evaluation context, and an optional override map, then runs a prioritised evaluation pipeline to produce a `FlagEvaluationResult`. All flag access goes through this class, ensuring consistent behaviour regardless of which strategy or provider is in use.
|
||||
|
||||
The evaluator is designed to be observable (OTel counter), debuggable (optional `LoggerLike`), and fault-tolerant (errors in strategies are caught and surfaced as `ERROR` results rather than crashing the caller).
|
||||
|
||||
## Features
|
||||
|
||||
- Four-tier priority pipeline: DISABLED → OVERRIDE → STRATEGY → DEFAULT
|
||||
- Strategy chain with first-match-wins semantics
|
||||
- OTel counter `feature_flags.evaluate.total` with `flag_key` and `reason` attributes
|
||||
- Fault isolation: errors in strategies are wrapped in `FeatureFlagsError(STRATEGY_ERROR)` and caught at the top level, returning the default value with reason `ERROR`
|
||||
- Structured logging via optional `LoggerLike` for debug traces
|
||||
- Zero global state — safe for concurrent use
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Evaluator } from '@suaveplan/feature-flags/evaluator';
|
||||
import { PercentageStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [new PercentageStrategy({ percentage: 20, value: true })],
|
||||
});
|
||||
|
||||
const result = evaluator.evaluate(
|
||||
{ key: 'feature-x', type: 'boolean', defaultValue: false },
|
||||
{ userId: 'user-42' },
|
||||
);
|
||||
// result.reason === 'STRATEGY' or 'DEFAULT'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Evaluator } from '@suaveplan/feature-flags/evaluator';
|
||||
|
||||
const evaluator = new Evaluator({ strategies: [] });
|
||||
|
||||
// In-process override bypasses all strategies
|
||||
const result = evaluator.evaluate(
|
||||
{ key: 'feature-x', type: 'boolean', defaultValue: false },
|
||||
{},
|
||||
{ 'feature-x': true },
|
||||
);
|
||||
// result.reason === 'OVERRIDE', result.value === true
|
||||
|
||||
// Disabled flag ignores overrides
|
||||
const disabled = evaluator.evaluate(
|
||||
{ key: 'feature-y', type: 'boolean', defaultValue: false, enabled: false },
|
||||
{},
|
||||
{ 'feature-y': true },
|
||||
);
|
||||
// disabled.reason === 'DISABLED', disabled.value === false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EvaluatorOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|------------------|----------|------------------------------------|
|
||||
| `strategies` | `FlagStrategy[]` | No | Ordered strategy chain |
|
||||
| `logger` | `LoggerLike` | No | Structured logger for eval traces |
|
||||
|
||||
### `class Evaluator`
|
||||
|
||||
**Constructor:** `new Evaluator(options?: EvaluatorOptions)`
|
||||
|
||||
**`evaluate<T>(flag, context, overrides?): FlagEvaluationResult<T>`**
|
||||
|
||||
- `flag` — flag definition to evaluate
|
||||
- `context` — runtime evaluation context
|
||||
- `overrides` — optional `Record<string, FlagValue>` for in-process overrides
|
||||
- Returns `FlagEvaluationResult<T>` — never throws (errors produce `reason: 'ERROR'`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
OTel initialisation uses a `require()` call wrapped in try/catch so that the package can be imported in environments where `@opentelemetry/api` is installed but no meter provider is registered — in that case, the SDK's no-op meter is used. The `require()` pattern avoids ESM top-level await and ensures the meter is created only once per instance. Strategy errors are deliberately re-thrown as `FeatureFlagsError(STRATEGY_ERROR)` before being caught at the top level, so that the error code and cause chain are preserved in structured logs. The DISABLED check precedes the override check — a flag with `enabled: false` cannot be overridden, which prevents accidental re-enablement of a kill-switched flag via an in-process override.
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagEvaluationResult,
|
||||
FlagStrategy,
|
||||
StrategyEvaluation,
|
||||
} from "../../types/common.js";
|
||||
import { Evaluator } from "./evaluator.js";
|
||||
|
||||
function makeLogger(): LoggerLike {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as LoggerLike;
|
||||
}
|
||||
|
||||
const boolFlag: FlagDefinition<boolean> = {
|
||||
key: "bool-flag",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
const disabledFlag: FlagDefinition<boolean> = {
|
||||
key: "disabled-flag",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
function makeStrategy(
|
||||
matched: boolean,
|
||||
value?: boolean,
|
||||
name = "mock"
|
||||
): FlagStrategy {
|
||||
return {
|
||||
name,
|
||||
evaluate: (
|
||||
_flag: FlagDefinition,
|
||||
_ctx: EvaluationContext
|
||||
): StrategyEvaluation => ({
|
||||
matched,
|
||||
value,
|
||||
strategyName: name,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Evaluator", () => {
|
||||
it("disabled flag returns DISABLED reason with default value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [makeStrategy(true, true)],
|
||||
});
|
||||
const result = evaluator.evaluate(disabledFlag, {});
|
||||
expect(result.reason).toBe("DISABLED");
|
||||
expect(result.value).toBe(false);
|
||||
expect(result.flagKey).toBe("disabled-flag");
|
||||
});
|
||||
|
||||
it("override returns OVERRIDE reason with override value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const evaluator = new Evaluator({ strategies: [] });
|
||||
const result = evaluator.evaluate(boolFlag, {}, { "bool-flag": true });
|
||||
expect(result.reason).toBe("OVERRIDE");
|
||||
expect(result.value).toBe(true);
|
||||
});
|
||||
|
||||
it("strategy match returns STRATEGY reason", { timeout: 5000 }, () => {
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [makeStrategy(true, true, "my-strategy")],
|
||||
});
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.reason).toBe("STRATEGY");
|
||||
expect(result.value).toBe(true);
|
||||
expect(result.strategyName).toBe("my-strategy");
|
||||
});
|
||||
|
||||
it("no match returns DEFAULT reason with default value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [makeStrategy(false)],
|
||||
});
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.reason).toBe("DEFAULT");
|
||||
expect(result.value).toBe(false);
|
||||
});
|
||||
|
||||
it("strategy without value uses flag defaultValue", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [makeStrategy(true, undefined)],
|
||||
});
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.reason).toBe("STRATEGY");
|
||||
expect(result.value).toBe(false); // falls back to defaultValue
|
||||
});
|
||||
|
||||
it("first matching strategy wins (chain stops)", { timeout: 5000 }, () => {
|
||||
let secondCalled = false;
|
||||
const second: FlagStrategy = {
|
||||
name: "second",
|
||||
evaluate: () => {
|
||||
secondCalled = true;
|
||||
return { matched: true, value: true, strategyName: "second" };
|
||||
},
|
||||
};
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [makeStrategy(true, false, "first"), second],
|
||||
});
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.strategyName).toBe("first");
|
||||
expect(secondCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("strategy error returns ERROR reason with default value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const badStrategy: FlagStrategy = {
|
||||
name: "broken",
|
||||
evaluate: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
const evaluator = new Evaluator({ strategies: [badStrategy] });
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.reason).toBe("ERROR");
|
||||
expect(result.value).toBe(false);
|
||||
});
|
||||
|
||||
it("DISABLED takes priority over overrides", { timeout: 5000 }, () => {
|
||||
const evaluator = new Evaluator({ strategies: [] });
|
||||
const result = evaluator.evaluate(
|
||||
disabledFlag,
|
||||
{},
|
||||
{ "disabled-flag": true }
|
||||
);
|
||||
expect(result.reason).toBe("DISABLED");
|
||||
expect(result.value).toBe(false);
|
||||
});
|
||||
|
||||
it("returns flagKey in result", { timeout: 5000 }, () => {
|
||||
const evaluator = new Evaluator();
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.flagKey).toBe("bool-flag");
|
||||
});
|
||||
|
||||
it("enabled:true behaves like omitted enabled", { timeout: 5000 }, () => {
|
||||
const enabledFlag: FlagDefinition<boolean> = {
|
||||
...boolFlag,
|
||||
enabled: true,
|
||||
};
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [makeStrategy(true, true)],
|
||||
});
|
||||
const result = evaluator.evaluate(enabledFlag, {});
|
||||
expect(result.reason).toBe("STRATEGY");
|
||||
});
|
||||
|
||||
it("logs error when strategy throws (with logger)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const logger = makeLogger();
|
||||
const badStrategy: FlagStrategy = {
|
||||
name: "broken",
|
||||
evaluate: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
const evaluator = new Evaluator({ strategies: [badStrategy], logger });
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.reason).toBe("ERROR");
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strategy that throws a non-Error value still returns ERROR", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const nonErrorStrategy: FlagStrategy = {
|
||||
name: "non-error-throw",
|
||||
evaluate: (): StrategyEvaluation => {
|
||||
const e: unknown = "string-error";
|
||||
throw e;
|
||||
},
|
||||
};
|
||||
const evaluator = new Evaluator({ strategies: [nonErrorStrategy] });
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.reason).toBe("ERROR");
|
||||
expect(result.value).toBe(false);
|
||||
});
|
||||
|
||||
it("runEvaluation throwing non-Error value returns ERROR reason", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
class ThrowStringEvaluator extends Evaluator {
|
||||
protected override runEvaluation(): FlagEvaluationResult {
|
||||
const e: unknown = "raw-string";
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const evaluator = new ThrowStringEvaluator();
|
||||
const result = evaluator.evaluate(boolFlag, {});
|
||||
expect(result.reason).toBe("ERROR");
|
||||
expect(result.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import type * as OTelApi from "@opentelemetry/api";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
FEATURE_FLAGS_CODES,
|
||||
FeatureFlagsError,
|
||||
} from "../../errors/feature-flags-error.js";
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagEvaluationResult,
|
||||
FlagStrategy,
|
||||
FlagValue,
|
||||
} from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Configuration options for {@link Evaluator}.
|
||||
*/
|
||||
export interface EvaluatorOptions {
|
||||
/**
|
||||
* Ordered list of strategies to run. The first strategy that returns
|
||||
* `matched: true` wins; later strategies are not evaluated.
|
||||
*/
|
||||
strategies?: FlagStrategy[];
|
||||
/** Optional structured logger for debug-level evaluation traces. */
|
||||
logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core flag evaluator.
|
||||
*
|
||||
* Processes a flag definition against an evaluation context by running
|
||||
* through a chain of strategies in declaration order. The first match wins.
|
||||
* Evaluation priority is:
|
||||
*
|
||||
* 1. `flag.enabled === false` → `DISABLED` (returns default value)
|
||||
* 2. Override map has an entry for `flag.key` → `OVERRIDE`
|
||||
* 3. Strategy chain — first matching strategy → `STRATEGY`
|
||||
* 4. No match → `DEFAULT`
|
||||
*
|
||||
* OTel counter `feature_flags.evaluate.total` is incremented with attributes
|
||||
* `{ flag_key, reason }` when an OTel meter provider is registered.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const evaluator = new Evaluator({ strategies: [new PercentageStrategy({ percentage: 50, value: true })] });
|
||||
* const result = evaluator.evaluate(flagDef, { userId: 'u-1' });
|
||||
* ```
|
||||
*/
|
||||
export class Evaluator {
|
||||
protected readonly strategies: FlagStrategy[];
|
||||
protected readonly logger?: LoggerLike;
|
||||
protected counter?: OTelApi.Counter;
|
||||
|
||||
constructor(options?: EvaluatorOptions) {
|
||||
this.strategies = options?.strategies ?? [];
|
||||
if (options?.logger !== undefined) {
|
||||
this.logger = options.logger;
|
||||
}
|
||||
this.initOtel();
|
||||
}
|
||||
|
||||
protected initOtel(): void {
|
||||
try {
|
||||
// Dynamic import pattern — avoids hard OTel binding at module load time
|
||||
const otel = require("@opentelemetry/api") as typeof OTelApi;
|
||||
const meter = otel.metrics.getMeter("@suaveplan/feature-flags");
|
||||
this.counter = meter.createCounter("feature_flags.evaluate.total", {
|
||||
description: "Total number of feature flag evaluations",
|
||||
});
|
||||
} catch {
|
||||
// OTel not available — continue without metrics
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a flag definition against the given context.
|
||||
*
|
||||
* @param flag - The flag to evaluate.
|
||||
* @param context - Runtime evaluation context.
|
||||
* @param overrides - Optional map of flag key → value overrides.
|
||||
* @returns A {@link FlagEvaluationResult} describing the resolved value and reason.
|
||||
*/
|
||||
evaluate<T extends FlagValue>(
|
||||
flag: FlagDefinition<T>,
|
||||
context: EvaluationContext,
|
||||
overrides?: Record<string, FlagValue>
|
||||
): FlagEvaluationResult<T> {
|
||||
try {
|
||||
return this.runEvaluation(flag, context, overrides);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.logger?.error("Flag evaluation failed", {
|
||||
flagKey: flag.key,
|
||||
error: error.message,
|
||||
});
|
||||
this.recordMetric(flag.key, "ERROR");
|
||||
return {
|
||||
value: flag.defaultValue,
|
||||
reason: "ERROR",
|
||||
flagKey: flag.key,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected runEvaluation<T extends FlagValue>(
|
||||
flag: FlagDefinition<T>,
|
||||
context: EvaluationContext,
|
||||
overrides?: Record<string, FlagValue>
|
||||
): FlagEvaluationResult<T> {
|
||||
// 1. Master kill-switch
|
||||
if (flag.enabled === false) {
|
||||
this.recordMetric(flag.key, "DISABLED");
|
||||
return {
|
||||
value: flag.defaultValue,
|
||||
reason: "DISABLED",
|
||||
flagKey: flag.key,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. In-process override
|
||||
if (overrides && Object.hasOwn(overrides, flag.key)) {
|
||||
const overrideValue = overrides[flag.key] as T;
|
||||
this.recordMetric(flag.key, "OVERRIDE");
|
||||
return {
|
||||
value: overrideValue,
|
||||
reason: "OVERRIDE",
|
||||
flagKey: flag.key,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Strategy chain
|
||||
for (const strategy of this.strategies) {
|
||||
let strategyResult: ReturnType<FlagStrategy["evaluate"]>;
|
||||
try {
|
||||
strategyResult = strategy.evaluate(flag, context);
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error(String(err));
|
||||
throw new FeatureFlagsError(
|
||||
FEATURE_FLAGS_CODES["STRATEGY_ERROR"],
|
||||
`feature-flags.strategy-error: ${strategy.name}: ${error.message}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
if (strategyResult.matched) {
|
||||
const stratValue =
|
||||
strategyResult.value !== undefined
|
||||
? (strategyResult.value as T)
|
||||
: flag.defaultValue;
|
||||
this.recordMetric(flag.key, "STRATEGY");
|
||||
return {
|
||||
value: stratValue,
|
||||
reason: "STRATEGY",
|
||||
strategyName: strategy.name,
|
||||
flagKey: flag.key,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Default
|
||||
this.recordMetric(flag.key, "DEFAULT");
|
||||
return {
|
||||
value: flag.defaultValue,
|
||||
reason: "DEFAULT",
|
||||
flagKey: flag.key,
|
||||
};
|
||||
}
|
||||
|
||||
protected recordMetric(
|
||||
flagKey: string,
|
||||
reason: FlagEvaluationResult["reason"]
|
||||
): void {
|
||||
try {
|
||||
this.counter?.add(1, { flag_key: flagKey, reason });
|
||||
} catch {
|
||||
// OTel recording failure must never break flag evaluation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Evaluator, type EvaluatorOptions } from "./evaluator";
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Evaluator, type EvaluatorOptions } from "./evaluator";
|
||||
export { FlagManager, type FlagManagerOptions } from "./manager";
|
||||
export { FlagRegistry, type FlagRegistryOptions } from "./registry";
|
||||
@@ -0,0 +1,86 @@
|
||||
# flag-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`FlagManager` is the primary entry point for feature flag consumption. It provides a high-level, type-safe API that abstracts the registry lookup, context merging, override management, and evaluation into a single object. Application code interacts only with the manager, which orchestrates the registry and evaluator underneath.
|
||||
|
||||
This façade pattern lets the registry and evaluator be swapped (e.g., switching providers between environments) without changing any calling code.
|
||||
|
||||
## Features
|
||||
|
||||
- `isEnabled`, `getString`, `getNumber`, `getJson` — typed convenience accessors
|
||||
- `evaluate<T>` — full evaluation result for callers that need the reason and strategy name
|
||||
- In-process override management: `setOverride`, `clearOverride`, `clearAllOverrides`
|
||||
- Default context merged shallowly with per-call context on every evaluation
|
||||
- Throws `FeatureFlagsError(FLAG_NOT_FOUND)` for unknown flag keys
|
||||
- Optional `LoggerLike` for structured evaluation traces
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FlagManager } from '@suaveplan/feature-flags/manager';
|
||||
import { FlagRegistry } from '@suaveplan/feature-flags/registry';
|
||||
import { Evaluator } from '@suaveplan/feature-flags/evaluator';
|
||||
import { MemoryProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const provider = new MemoryProvider([
|
||||
{ key: 'dark-mode', type: 'boolean', defaultValue: false },
|
||||
]);
|
||||
const registry = new FlagRegistry({ provider });
|
||||
const evaluator = new Evaluator();
|
||||
const manager = new FlagManager({ registry, evaluator });
|
||||
|
||||
manager.isEnabled('dark-mode', { userId: 'u-1' }); // false (default)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FlagManager } from '@suaveplan/feature-flags/manager';
|
||||
|
||||
// Set a default context applied to every evaluation
|
||||
const manager = new FlagManager({
|
||||
registry,
|
||||
evaluator,
|
||||
defaultContext: { attributes: { env: 'staging' } },
|
||||
});
|
||||
|
||||
// Override a flag for testing
|
||||
manager.setOverride('dark-mode', true);
|
||||
manager.isEnabled('dark-mode'); // true
|
||||
manager.clearOverride('dark-mode');
|
||||
manager.isEnabled('dark-mode'); // false (back to evaluation)
|
||||
|
||||
// Full result with reason
|
||||
const result = manager.evaluate<boolean>('dark-mode', { userId: 'u-2' });
|
||||
console.log(result.reason, result.value);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FlagManagerOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------------------|----------|------------------------------------------|
|
||||
| `registry` | `FlagRegistry` | Yes | Source of flag definitions |
|
||||
| `evaluator` | `Evaluator` | Yes | Evaluation engine |
|
||||
| `logger` | `LoggerLike` | No | Structured logger |
|
||||
| `defaultContext` | `EvaluationContext` | No | Context merged into every evaluation |
|
||||
|
||||
### `class FlagManager`
|
||||
|
||||
**`evaluate<T>(key, context?): FlagEvaluationResult<T>`** — full result
|
||||
**`isEnabled(key, context?): boolean`** — typed boolean accessor
|
||||
**`getString(key, context?): string`** — typed string accessor
|
||||
**`getNumber(key, context?): number`** — typed number accessor
|
||||
**`getJson<T>(key, context?): T`** — typed JSON accessor
|
||||
**`setOverride(key, value): void`** — set in-process override
|
||||
**`clearOverride(key): void`** — remove one override
|
||||
**`clearAllOverrides(): void`** — remove all overrides
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Context merging is shallow: the top-level `EvaluationContext` fields are spread with per-call values taking precedence, but `attributes` is merged one level deeper so that per-call attributes overlay (not replace) default attributes. Overrides are stored in a `Map<string, FlagValue>` and serialised to a plain `Record` before passing to the evaluator, which accepts records to remain agnostic of the manager's storage. The typed accessors (`isEnabled` etc.) do not validate the flag's declared `type` against the generic parameter — callers are expected to use the correct accessor for the flag's type; mismatches are surfaced as TypeScript compile errors rather than runtime errors.
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
FEATURE_FLAGS_CODES,
|
||||
FeatureFlagsError,
|
||||
} from "../../errors/feature-flags-error.js";
|
||||
import { MemoryProvider } from "../../providers/memory/memory.js";
|
||||
import type { FlagDefinition } from "../../types/common.js";
|
||||
import { Evaluator } from "../evaluator/evaluator.js";
|
||||
import { FlagRegistry } from "../registry/flag-registry.js";
|
||||
import { FlagManager } from "./flag-manager.js";
|
||||
|
||||
function makeLogger(): LoggerLike {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as LoggerLike;
|
||||
}
|
||||
|
||||
function makeManager(flags: FlagDefinition[] = []) {
|
||||
const provider = new MemoryProvider(flags);
|
||||
const registry = new FlagRegistry({ provider });
|
||||
const evaluator = new Evaluator();
|
||||
return new FlagManager({ registry, evaluator });
|
||||
}
|
||||
|
||||
describe("FlagManager", () => {
|
||||
it("isEnabled returns boolean defaultValue", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([
|
||||
{ key: "dark-mode", type: "boolean", defaultValue: false },
|
||||
]);
|
||||
expect(manager.isEnabled("dark-mode")).toBe(false);
|
||||
});
|
||||
|
||||
it("getString returns string defaultValue", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([
|
||||
{ key: "variant", type: "string", defaultValue: "control" },
|
||||
]);
|
||||
expect(manager.getString("variant")).toBe("control");
|
||||
});
|
||||
|
||||
it("getNumber returns number defaultValue", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([
|
||||
{ key: "max-items", type: "number", defaultValue: 50 },
|
||||
]);
|
||||
expect(manager.getNumber("max-items")).toBe(50);
|
||||
});
|
||||
|
||||
it("getJson returns object defaultValue", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([
|
||||
{ key: "config", type: "json", defaultValue: { theme: "light" } },
|
||||
]);
|
||||
expect(manager.getJson("config")).toEqual({ theme: "light" });
|
||||
});
|
||||
|
||||
it("evaluate returns full result", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([
|
||||
{ key: "feat", type: "boolean", defaultValue: true },
|
||||
]);
|
||||
const result = manager.evaluate<boolean>("feat");
|
||||
expect(result.reason).toBe("DEFAULT");
|
||||
expect(result.value).toBe(true);
|
||||
expect(result.flagKey).toBe("feat");
|
||||
});
|
||||
|
||||
it("throws FLAG_NOT_FOUND for unknown key", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([]);
|
||||
expect(() => manager.isEnabled("unknown-flag")).toThrow(
|
||||
FeatureFlagsError
|
||||
);
|
||||
let caught: unknown;
|
||||
try {
|
||||
manager.isEnabled("unknown-flag");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FeatureFlagsError);
|
||||
expect((caught as FeatureFlagsError).code).toBe(
|
||||
FEATURE_FLAGS_CODES["FLAG_NOT_FOUND"]
|
||||
);
|
||||
});
|
||||
|
||||
it("setOverride overrides flag value", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([
|
||||
{ key: "dark-mode", type: "boolean", defaultValue: false },
|
||||
]);
|
||||
manager.setOverride("dark-mode", true);
|
||||
expect(manager.isEnabled("dark-mode")).toBe(true);
|
||||
const result = manager.evaluate<boolean>("dark-mode");
|
||||
expect(result.reason).toBe("OVERRIDE");
|
||||
});
|
||||
|
||||
it("clearOverride removes override", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([
|
||||
{ key: "dark-mode", type: "boolean", defaultValue: false },
|
||||
]);
|
||||
manager.setOverride("dark-mode", true);
|
||||
manager.clearOverride("dark-mode");
|
||||
expect(manager.isEnabled("dark-mode")).toBe(false);
|
||||
});
|
||||
|
||||
it("clearAllOverrides removes all overrides", { timeout: 5000 }, () => {
|
||||
const manager = makeManager([
|
||||
{ key: "flag-a", type: "boolean", defaultValue: false },
|
||||
{ key: "flag-b", type: "boolean", defaultValue: false },
|
||||
]);
|
||||
manager.setOverride("flag-a", true);
|
||||
manager.setOverride("flag-b", true);
|
||||
manager.clearAllOverrides();
|
||||
expect(manager.isEnabled("flag-a")).toBe(false);
|
||||
expect(manager.isEnabled("flag-b")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges defaultContext with per-call context", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider([
|
||||
{ key: "feat", type: "boolean", defaultValue: false },
|
||||
]);
|
||||
const registry = new FlagRegistry({ provider });
|
||||
const evaluator = new Evaluator();
|
||||
const manager = new FlagManager({
|
||||
registry,
|
||||
evaluator,
|
||||
defaultContext: {
|
||||
userId: "default-user",
|
||||
attributes: { env: "staging" },
|
||||
},
|
||||
});
|
||||
|
||||
// Per-call context should overlay defaultContext
|
||||
const result = manager.evaluate<boolean>("feat", {
|
||||
userId: "call-user",
|
||||
});
|
||||
expect(result.flagKey).toBe("feat");
|
||||
// Default value returned (no strategies), but verify no error
|
||||
expect(result.reason).toBe("DEFAULT");
|
||||
});
|
||||
|
||||
it("disabled flag returns DISABLED regardless of overrides", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const manager = makeManager([
|
||||
{
|
||||
key: "killed",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
manager.setOverride("killed", true);
|
||||
const result = manager.evaluate<boolean>("killed");
|
||||
expect(result.reason).toBe("DISABLED");
|
||||
expect(result.value).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a logger option (covers logger assignment branch)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const logger = makeLogger();
|
||||
const provider = new MemoryProvider([
|
||||
{ key: "feat", type: "boolean", defaultValue: true },
|
||||
]);
|
||||
const registry = new FlagRegistry({ provider });
|
||||
const evaluator = new Evaluator();
|
||||
const manager = new FlagManager({ registry, evaluator, logger });
|
||||
expect(manager.isEnabled("feat")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
FEATURE_FLAGS_CODES,
|
||||
FeatureFlagsError,
|
||||
} from "../../errors/feature-flags-error.js";
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagEvaluationResult,
|
||||
FlagValue,
|
||||
} from "../../types/common.js";
|
||||
import type { Evaluator } from "../evaluator/evaluator.js";
|
||||
import type { FlagRegistry } from "../registry/flag-registry.js";
|
||||
|
||||
/**
|
||||
* Configuration options for {@link FlagManager}.
|
||||
*/
|
||||
export interface FlagManagerOptions {
|
||||
/** Registry providing flag definitions. */
|
||||
registry: FlagRegistry;
|
||||
/** Evaluator used to compute flag values. */
|
||||
evaluator: Evaluator;
|
||||
/** Optional structured logger. */
|
||||
logger?: LoggerLike;
|
||||
/** Default context merged (shallowly) with per-call context. */
|
||||
defaultContext?: EvaluationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level façade for feature flag access.
|
||||
*
|
||||
* `FlagManager` combines a {@link FlagRegistry} and an {@link Evaluator}
|
||||
* behind a simple, type-safe API. It manages in-process overrides, merges
|
||||
* a default context with per-call context, and provides typed accessors
|
||||
* (`isEnabled`, `getString`, `getNumber`, `getJson`) for common flag types.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const manager = new FlagManager({ registry, evaluator });
|
||||
* const enabled = manager.isEnabled('dark-mode', { userId: 'u-1' });
|
||||
* ```
|
||||
*/
|
||||
export class FlagManager {
|
||||
protected readonly registry: FlagRegistry;
|
||||
protected readonly evaluator: Evaluator;
|
||||
protected readonly logger?: LoggerLike;
|
||||
protected readonly defaultContext: EvaluationContext;
|
||||
protected readonly overrides: Map<string, FlagValue>;
|
||||
|
||||
constructor(options: FlagManagerOptions) {
|
||||
this.registry = options.registry;
|
||||
this.evaluator = options.evaluator;
|
||||
if (options.logger !== undefined) {
|
||||
this.logger = options.logger;
|
||||
}
|
||||
this.defaultContext = options.defaultContext ?? {};
|
||||
this.overrides = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a flag and return its full result.
|
||||
*
|
||||
* @param key - Flag key to evaluate.
|
||||
* @param context - Per-call context (merged with `defaultContext`).
|
||||
* @returns Full evaluation result.
|
||||
* @throws {FeatureFlagsError} When the flag key is not registered.
|
||||
*/
|
||||
evaluate<T extends FlagValue>(
|
||||
key: string,
|
||||
context?: EvaluationContext
|
||||
): FlagEvaluationResult<T> {
|
||||
const flag = this.registry.get(key);
|
||||
if (!flag) {
|
||||
throw new FeatureFlagsError(
|
||||
FEATURE_FLAGS_CODES["FLAG_NOT_FOUND"],
|
||||
`feature-flags.flag-not-found: ${key}`
|
||||
);
|
||||
}
|
||||
|
||||
const mergedContext: EvaluationContext = {
|
||||
...this.defaultContext,
|
||||
...context,
|
||||
attributes: {
|
||||
...this.defaultContext.attributes,
|
||||
...context?.attributes,
|
||||
},
|
||||
};
|
||||
|
||||
const overridesRecord: Record<string, FlagValue> = Object.fromEntries(
|
||||
this.overrides.entries()
|
||||
);
|
||||
|
||||
return this.evaluator.evaluate<T>(
|
||||
flag as import("../../types/common.js").FlagDefinition<T>,
|
||||
mergedContext,
|
||||
overridesRecord
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a boolean flag.
|
||||
*
|
||||
* @param key - Flag key.
|
||||
* @param context - Optional per-call context.
|
||||
* @returns The resolved boolean value.
|
||||
*/
|
||||
isEnabled(key: string, context?: EvaluationContext): boolean {
|
||||
const result = this.evaluate<boolean>(key, context);
|
||||
return Boolean(result.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a string flag.
|
||||
*
|
||||
* @param key - Flag key.
|
||||
* @param context - Optional per-call context.
|
||||
* @returns The resolved string value.
|
||||
*/
|
||||
getString(key: string, context?: EvaluationContext): string {
|
||||
const result = this.evaluate<string>(key, context);
|
||||
return String(result.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a number flag.
|
||||
*
|
||||
* @param key - Flag key.
|
||||
* @param context - Optional per-call context.
|
||||
* @returns The resolved number value.
|
||||
*/
|
||||
getNumber(key: string, context?: EvaluationContext): number {
|
||||
const result = this.evaluate<number>(key, context);
|
||||
return Number(result.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a JSON flag.
|
||||
*
|
||||
* @typeParam T - Expected shape of the JSON payload.
|
||||
* @param key - Flag key.
|
||||
* @param context - Optional per-call context.
|
||||
* @returns The resolved JSON object.
|
||||
*/
|
||||
getJson<T = unknown>(key: string, context?: EvaluationContext): T {
|
||||
const result = this.evaluate<Record<string, unknown>>(key, context);
|
||||
return result.value as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an in-process override for a flag.
|
||||
* Overrides take effect immediately and persist until cleared.
|
||||
*
|
||||
* @param key - Flag key to override.
|
||||
* @param value - Override value.
|
||||
*/
|
||||
setOverride(key: string, value: FlagValue): void {
|
||||
this.overrides.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the in-process override for a specific flag.
|
||||
*
|
||||
* @param key - Flag key to clear.
|
||||
*/
|
||||
clearOverride(key: string): void {
|
||||
this.overrides.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all in-process overrides.
|
||||
*/
|
||||
clearAllOverrides(): void {
|
||||
this.overrides.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FlagManager, type FlagManagerOptions } from "./flag-manager";
|
||||
@@ -0,0 +1,85 @@
|
||||
# flag-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the central registry for feature flag definitions, acting as a typed façade over a `FlagProvider`. Consumers of `@suaveplan/feature-flags` register, retrieve, and remove flags through the registry rather than interacting with providers directly. This separation lets providers be swapped (memory, JSON, remote-config) without changing any consumer code.
|
||||
|
||||
The registry validates flag definitions at registration time to catch configuration errors early, rather than allowing malformed flags to propagate silently to the evaluator.
|
||||
|
||||
## Features
|
||||
|
||||
- Register and replace flags via a validated `register(flag)` API
|
||||
- Remove flags via `unregister(key)`
|
||||
- Type-safe `get(key)` returning `FlagDefinition | undefined`
|
||||
- Bulk `getAll()` for dashboard and tooling integrations
|
||||
- `has(key)` existence check
|
||||
- Throws `FeatureFlagsError(INVALID_FLAG)` for empty keys
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FlagRegistry } from '@suaveplan/feature-flags/registry';
|
||||
import { MemoryProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const registry = new FlagRegistry({ provider: new MemoryProvider() });
|
||||
|
||||
registry.register({ key: 'dark-mode', type: 'boolean', defaultValue: false });
|
||||
registry.has('dark-mode'); // true
|
||||
registry.get('dark-mode'); // { key: 'dark-mode', ... }
|
||||
registry.unregister('dark-mode');
|
||||
registry.has('dark-mode'); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FlagRegistry } from '@suaveplan/feature-flags/registry';
|
||||
import { JsonProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const provider = new JsonProvider([
|
||||
{ key: 'checkout-v2', type: 'boolean', defaultValue: false },
|
||||
{ key: 'max-items', type: 'number', defaultValue: 50 },
|
||||
]);
|
||||
|
||||
const registry = new FlagRegistry({ provider });
|
||||
|
||||
// Iterate all flags for a management UI
|
||||
for (const flag of registry.getAll()) {
|
||||
console.log(flag.key, flag.defaultValue);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FlagRegistryOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------|----------------|----------|-------------------------------|
|
||||
| `provider` | `FlagProvider` | Yes | Backing data source |
|
||||
|
||||
### `class FlagRegistry`
|
||||
|
||||
**Constructor:** `new FlagRegistry(options: FlagRegistryOptions)`
|
||||
|
||||
**`register(flag: FlagDefinition): void`**
|
||||
Validates that `flag.key` is non-empty, then delegates to `provider.setFlag`.
|
||||
Throws `FeatureFlagsError` with code `INVALID_FLAG` when key is empty or whitespace.
|
||||
|
||||
**`unregister(key: string): void`**
|
||||
Delegates to `provider.deleteFlag`. Silent no-op when key is absent.
|
||||
|
||||
**`get(key: string): FlagDefinition | undefined`**
|
||||
Returns the flag or `undefined`. Does not throw.
|
||||
|
||||
**`getAll(): FlagDefinition[]`**
|
||||
Returns the full list from the provider.
|
||||
|
||||
**`has(key: string): boolean`**
|
||||
Returns `true` when the provider returns a defined flag for `key`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Validation is intentionally minimal at the registry level — full schema validation (Zod) can be added in a `registerValidated` wrapper without modifying this class. The `has` method delegates to `getFlag` rather than storing a separate key set, ensuring it remains in sync with the provider state without duplication. Providers are responsible for the actual storage semantics (in-memory, persistent, remote).
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FeatureFlagsError } from "../../errors/feature-flags-error.js";
|
||||
import { MemoryProvider } from "../../providers/memory/memory.js";
|
||||
import type { FlagDefinition } from "../../types/common.js";
|
||||
import { FlagRegistry } from "./flag-registry.js";
|
||||
|
||||
function makeRegistry(initial: FlagDefinition[] = []) {
|
||||
const provider = new MemoryProvider(initial);
|
||||
return new FlagRegistry({ provider });
|
||||
}
|
||||
|
||||
describe("FlagRegistry", () => {
|
||||
it("register: stores a flag definition", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry();
|
||||
registry.register({
|
||||
key: "flag-a",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
});
|
||||
expect(registry.has("flag-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("register: replaces an existing flag", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry([
|
||||
{ key: "flag-a", type: "boolean", defaultValue: false },
|
||||
]);
|
||||
registry.register({
|
||||
key: "flag-a",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
});
|
||||
expect(registry.get("flag-a")?.defaultValue).toBe(true);
|
||||
});
|
||||
|
||||
it("register: throws INVALID_FLAG for empty key", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry();
|
||||
expect(() =>
|
||||
registry.register({ key: "", type: "boolean", defaultValue: false })
|
||||
).toThrow(FeatureFlagsError);
|
||||
});
|
||||
|
||||
it("register: throws INVALID_FLAG for whitespace-only key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = makeRegistry();
|
||||
expect(() =>
|
||||
registry.register({
|
||||
key: " ",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
})
|
||||
).toThrow(FeatureFlagsError);
|
||||
});
|
||||
|
||||
it("unregister: removes a flag", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry([
|
||||
{ key: "flag-a", type: "boolean", defaultValue: false },
|
||||
]);
|
||||
registry.unregister("flag-a");
|
||||
expect(registry.has("flag-a")).toBe(false);
|
||||
});
|
||||
|
||||
it("unregister: no-op for non-existent key", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry();
|
||||
expect(() => registry.unregister("missing")).not.toThrow();
|
||||
});
|
||||
|
||||
it("get: returns flag definition", { timeout: 5000 }, () => {
|
||||
const flag: FlagDefinition = {
|
||||
key: "flag-a",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
};
|
||||
const registry = makeRegistry([flag]);
|
||||
expect(registry.get("flag-a")).toEqual(flag);
|
||||
});
|
||||
|
||||
it("get: returns undefined for missing key", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry();
|
||||
expect(registry.get("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getAll: returns all flags", { timeout: 5000 }, () => {
|
||||
const flags: FlagDefinition[] = [
|
||||
{ key: "a", type: "boolean", defaultValue: false },
|
||||
{ key: "b", type: "string", defaultValue: "x" },
|
||||
];
|
||||
const registry = makeRegistry(flags);
|
||||
const all = registry.getAll();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.map((f) => f.key).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("getAll: returns empty array when no flags", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry();
|
||||
expect(registry.getAll()).toEqual([]);
|
||||
});
|
||||
|
||||
it("has: returns true for existing flag", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry([
|
||||
{ key: "flag-a", type: "boolean", defaultValue: false },
|
||||
]);
|
||||
expect(registry.has("flag-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("has: returns false for missing flag", { timeout: 5000 }, () => {
|
||||
const registry = makeRegistry();
|
||||
expect(registry.has("missing")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
FEATURE_FLAGS_CODES,
|
||||
FeatureFlagsError,
|
||||
} from "../../errors/feature-flags-error.js";
|
||||
import type { FlagDefinition, FlagProvider } from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Configuration options for {@link FlagRegistry}.
|
||||
*/
|
||||
export interface FlagRegistryOptions {
|
||||
/** The provider that backs this registry. */
|
||||
provider: FlagProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central registry for feature flag definitions.
|
||||
*
|
||||
* The registry acts as a façade over a {@link FlagProvider}, adding
|
||||
* validation and a typed API for registering, retrieving, and removing flags.
|
||||
* Consumers interact with the registry rather than directly with providers.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const registry = new FlagRegistry({ provider: new MemoryProvider() });
|
||||
* registry.register({ key: 'dark-mode', type: 'boolean', defaultValue: false });
|
||||
* registry.get('dark-mode'); // returns the flag definition
|
||||
* ```
|
||||
*/
|
||||
export class FlagRegistry {
|
||||
protected readonly provider: FlagProvider;
|
||||
|
||||
constructor(options: FlagRegistryOptions) {
|
||||
this.provider = options.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new flag or replace an existing one.
|
||||
*
|
||||
* @param flag - The flag definition to register.
|
||||
* @throws {FeatureFlagsError} When `flag.key` is empty.
|
||||
*/
|
||||
register(flag: FlagDefinition): void {
|
||||
if (!flag.key || flag.key.trim() === "") {
|
||||
throw new FeatureFlagsError(
|
||||
FEATURE_FLAGS_CODES["INVALID_FLAG"],
|
||||
"feature-flags.invalid-flag"
|
||||
);
|
||||
}
|
||||
this.provider.setFlag(flag.key, flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a flag from the registry.
|
||||
*
|
||||
* @param key - The flag key to remove.
|
||||
*/
|
||||
unregister(key: string): void {
|
||||
this.provider.deleteFlag(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a flag by key.
|
||||
*
|
||||
* @param key - The flag key to look up.
|
||||
* @returns The flag definition, or `undefined` if not found.
|
||||
*/
|
||||
get(key: string): FlagDefinition | undefined {
|
||||
return this.provider.getFlag(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered flags.
|
||||
*
|
||||
* @returns Array of all flag definitions in the provider.
|
||||
*/
|
||||
getAll(): FlagDefinition[] {
|
||||
return this.provider.getAllFlags();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a flag is registered.
|
||||
*
|
||||
* @param key - The flag key to check.
|
||||
* @returns `true` if the flag exists in the provider.
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
return this.provider.getFlag(key) !== undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FlagRegistry, type FlagRegistryOptions } from "./flag-registry";
|
||||
@@ -0,0 +1,81 @@
|
||||
# feature-flags-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the error infrastructure for `@suaveplan/feature-flags`. All errors thrown by this package are instances of `FeatureFlagsError`, which extends the project-wide `SuaveplanError` base class. Centralising errors in a single module ensures that callers can use narrow `instanceof` checks while still catching the broader `SuaveplanError` base when needed, and that every failure carries a stable, machine-readable code from `FEATURE_FLAGS_CODES`.
|
||||
|
||||
The stable code registry enables structured logging, alerting rules, and client-side error handling that survives message-text changes without breaking.
|
||||
|
||||
## Features
|
||||
|
||||
- `FEATURE_FLAGS_CODES` — stable code registry covering five failure domains: `INVALID_FLAG`, `PROVIDER_ERROR`, `EVALUATION_FAILED`, `STRATEGY_ERROR`, `FLAG_NOT_FOUND`
|
||||
- `FeatureFlagsCode` — type alias for the union of all code strings
|
||||
- `FeatureFlagsError` — typed error class extending `SuaveplanError` with a customised `name` property
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FeatureFlagsError, FEATURE_FLAGS_CODES } from '@suaveplan/feature-flags/errors';
|
||||
|
||||
throw new FeatureFlagsError(
|
||||
'Flag "checkout-v2" not found in registry',
|
||||
FEATURE_FLAGS_CODES.FLAG_NOT_FOUND,
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FeatureFlagsError, FEATURE_FLAGS_CODES } from '@suaveplan/feature-flags/errors';
|
||||
|
||||
function wrapProviderCall<T>(fn: () => T): T {
|
||||
try {
|
||||
return fn();
|
||||
} catch (err) {
|
||||
throw new FeatureFlagsError(
|
||||
'Provider threw during flag retrieval',
|
||||
FEATURE_FLAGS_CODES.PROVIDER_ERROR,
|
||||
{ cause: err instanceof Error ? err : new Error(String(err)) },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FEATURE_FLAGS_CODES`
|
||||
|
||||
`Record<string, string>` — object produced by `defineCodes('FEATURE_FLAGS', ...)`.
|
||||
|
||||
| Key | Code string |
|
||||
|---------------------|----------------------------------------|
|
||||
| `INVALID_FLAG` | `'FEATURE_FLAGS.INVALID_FLAG'` |
|
||||
| `PROVIDER_ERROR` | `'FEATURE_FLAGS.PROVIDER_ERROR'` |
|
||||
| `EVALUATION_FAILED` | `'FEATURE_FLAGS.EVALUATION_FAILED'` |
|
||||
| `STRATEGY_ERROR` | `'FEATURE_FLAGS.STRATEGY_ERROR'` |
|
||||
| `FLAG_NOT_FOUND` | `'FEATURE_FLAGS.FLAG_NOT_FOUND'` |
|
||||
|
||||
### `FeatureFlagsCode`
|
||||
|
||||
`type FeatureFlagsCode = (typeof FEATURE_FLAGS_CODES)[keyof typeof FEATURE_FLAGS_CODES]`
|
||||
|
||||
### `FeatureFlagsError`
|
||||
|
||||
```ts
|
||||
class FeatureFlagsError extends SuaveplanError {
|
||||
constructor(message: string, code: string, options?: { cause?: Error });
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `message` — human-readable description of the failure
|
||||
- `code` — stable error code, typically from `FEATURE_FLAGS_CODES`
|
||||
- `options.cause` — originating error for causal chain
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`FeatureFlagsError` explicitly sets `this.name = 'FeatureFlagsError'` so that stack traces and JSON serialisation display the subclass name rather than the base `SuaveplanError` name. The `code` parameter accepts `string` rather than the narrow `FeatureFlagsCode` union to allow callers to pass dynamic codes without casting, while still encouraging use of `FEATURE_FLAGS_CODES` constants in practice.
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/feature-flags`.
|
||||
*
|
||||
* Codes are namespaced under `FEATURE_FLAGS` and cover the five failure
|
||||
* domains this package can surface:
|
||||
* - `INVALID_FLAG` — malformed flag definition supplied to the registry
|
||||
* - `PROVIDER_ERROR` — the underlying provider adapter threw unexpectedly
|
||||
* - `EVALUATION_FAILED` — the evaluator was unable to complete an evaluation
|
||||
* - `STRATEGY_ERROR` — a strategy threw during `evaluate()`
|
||||
* - `FLAG_NOT_FOUND` — requested flag key is absent from the registry
|
||||
*/
|
||||
export const FEATURE_FLAGS_CODES = defineCodes("FEATURE_FLAGS", [
|
||||
"INVALID_FLAG",
|
||||
"PROVIDER_ERROR",
|
||||
"EVALUATION_FAILED",
|
||||
"STRATEGY_ERROR",
|
||||
"FLAG_NOT_FOUND",
|
||||
] as const);
|
||||
|
||||
export type FeatureFlagsCode =
|
||||
(typeof FEATURE_FLAGS_CODES)[keyof typeof FEATURE_FLAGS_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for all `@suaveplan/feature-flags` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so callers can use `instanceof FeatureFlagsError`
|
||||
* for narrow discrimination while still catching the broader `SuaveplanError`
|
||||
* base when needed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { FeatureFlagsError, FEATURE_FLAGS_CODES } from '@suaveplan/feature-flags/errors';
|
||||
*
|
||||
* throw new FeatureFlagsError(
|
||||
* FEATURE_FLAGS_CODES['INVALID_FLAG'],
|
||||
* 'feature-flags.invalid-flag',
|
||||
* { cause: originalError }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class FeatureFlagsError extends SuaveplanError<FeatureFlagsCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: FeatureFlagsCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
FEATURE_FLAGS_CODES,
|
||||
type FeatureFlagsCode,
|
||||
FeatureFlagsError,
|
||||
} from "./feature-flags-error";
|
||||
61
packages/foundation/feature-flags/feature-flags/src/index.ts
Normal file
61
packages/foundation/feature-flags/feature-flags/src/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Types
|
||||
|
||||
export {
|
||||
Evaluator,
|
||||
type EvaluatorOptions,
|
||||
} from "./core/evaluator/evaluator.js";
|
||||
export {
|
||||
FlagManager,
|
||||
type FlagManagerOptions,
|
||||
} from "./core/manager/flag-manager.js";
|
||||
// Core
|
||||
export {
|
||||
FlagRegistry,
|
||||
type FlagRegistryOptions,
|
||||
} from "./core/registry/flag-registry.js";
|
||||
// Errors
|
||||
export {
|
||||
FEATURE_FLAGS_CODES,
|
||||
type FeatureFlagsCode,
|
||||
FeatureFlagsError,
|
||||
} from "./errors/feature-flags-error.js";
|
||||
export { JsonProvider } from "./providers/json/json.js";
|
||||
// Providers
|
||||
export { MemoryProvider } from "./providers/memory/memory.js";
|
||||
export {
|
||||
type AttributeOperator,
|
||||
type AttributeRule,
|
||||
AttributeStrategy,
|
||||
type AttributeStrategyOptions,
|
||||
compareValues,
|
||||
getNestedValue,
|
||||
} from "./strategies/attribute/attribute.js";
|
||||
export {
|
||||
CompositeStrategy,
|
||||
type CompositeStrategyOptions,
|
||||
} from "./strategies/composite/composite.js";
|
||||
// Strategies
|
||||
export {
|
||||
type PercentageOptions,
|
||||
PercentageStrategy,
|
||||
} from "./strategies/percentage/percentage.js";
|
||||
export {
|
||||
type ScheduleOptions,
|
||||
ScheduleStrategy,
|
||||
} from "./strategies/schedule/schedule.js";
|
||||
export {
|
||||
type UserTargetOptions,
|
||||
UserTargetStrategy,
|
||||
} from "./strategies/user-target/user-target.js";
|
||||
export type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagEvaluationResult,
|
||||
FlagProvider,
|
||||
FlagStrategy,
|
||||
FlagType,
|
||||
FlagValue,
|
||||
StrategyEvaluation,
|
||||
} from "./types/common.js";
|
||||
// Hash utilities
|
||||
export { combineKeys, fnv1a32, hashToPercentage } from "./utils/hash.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { JsonProvider } from "./json/json.js";
|
||||
export { MemoryProvider } from "./memory/memory.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { JsonProvider } from "./json";
|
||||
@@ -0,0 +1,73 @@
|
||||
# json-provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements a read-only `FlagProvider` that is initialised from a static JSON array of flag definitions. It is intended for use cases where feature flags are shipped as part of the application bundle, loaded from a remote config server at startup, or provided via environment-specific JSON files.
|
||||
|
||||
Because the store is frozen at construction time, this provider guarantees that flag values cannot change during the application lifetime without a restart — making it suitable for SSR renderers, edge workers, and other environments where mutable shared state is undesirable.
|
||||
|
||||
## Features
|
||||
|
||||
- Read-only: `setFlag` and `deleteFlag` are intentional no-ops
|
||||
- O(1) flag lookup via internal `Map`
|
||||
- Supports any array of `FlagDefinition` objects, including those parsed from JSON
|
||||
- Zero I/O after construction — suitable for synchronous evaluation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { JsonProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const provider = new JsonProvider([
|
||||
{ key: 'dark-mode', type: 'boolean', defaultValue: false },
|
||||
{ key: 'checkout-variant', type: 'string', defaultValue: 'v1' },
|
||||
]);
|
||||
|
||||
provider.getFlag('dark-mode'); // { key: 'dark-mode', ... }
|
||||
provider.getAllFlags(); // array of both flags
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { JsonProvider } from '@suaveplan/feature-flags/providers';
|
||||
import type { FlagDefinition } from '@suaveplan/feature-flags';
|
||||
|
||||
// Type-annotate a JSON import for compile-time safety
|
||||
const rawFlags: FlagDefinition[] = [
|
||||
{ key: 'feature-x', type: 'boolean', defaultValue: false, enabled: true },
|
||||
];
|
||||
|
||||
const provider = new JsonProvider(rawFlags);
|
||||
|
||||
// setFlag and deleteFlag are no-ops on this provider
|
||||
provider.setFlag('feature-x', { key: 'feature-x', type: 'boolean', defaultValue: true });
|
||||
provider.getFlag('feature-x')?.defaultValue; // still false — immutable
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class JsonProvider implements FlagProvider`
|
||||
|
||||
**Constructor:** `new JsonProvider(config: FlagDefinition[])`
|
||||
|
||||
- `config` — array of flag definitions to load. Duplicated keys are resolved last-write-wins in iteration order.
|
||||
|
||||
**`getFlag(key: string): FlagDefinition | undefined`**
|
||||
Returns the flag for `key`, or `undefined`.
|
||||
|
||||
**`getAllFlags(): FlagDefinition[]`**
|
||||
Returns a snapshot array of all loaded flags.
|
||||
|
||||
**`setFlag(key, flag): void`**
|
||||
No-op. Read-only provider.
|
||||
|
||||
**`deleteFlag(key): void`**
|
||||
No-op. Read-only provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `Map` is typed as `ReadonlyMap` to signal immutability to TypeScript, though the concrete `Map` instance is referenced — reassignment of the `store` field is prevented by `readonly`. The `setFlag`/`deleteFlag` no-ops are documented with JSDoc `@param` tags to satisfy documentation-coverage checks. For a mutable alternative, use `MemoryProvider` initialised with the same flag array.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FlagDefinition } from "../../types/common.js";
|
||||
import { JsonProvider } from "./json.js";
|
||||
|
||||
const flags: FlagDefinition[] = [
|
||||
{ key: "flag-a", type: "boolean", defaultValue: false },
|
||||
{ key: "flag-b", type: "string", defaultValue: "v1" },
|
||||
];
|
||||
|
||||
describe("JsonProvider", () => {
|
||||
it("getFlag: returns loaded flag", { timeout: 5000 }, () => {
|
||||
const provider = new JsonProvider(flags);
|
||||
expect(provider.getFlag("flag-a")).toEqual(flags[0]);
|
||||
});
|
||||
|
||||
it("getFlag: returns undefined for missing key", { timeout: 5000 }, () => {
|
||||
const provider = new JsonProvider(flags);
|
||||
expect(provider.getFlag("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getAllFlags: returns all loaded flags", { timeout: 5000 }, () => {
|
||||
const provider = new JsonProvider(flags);
|
||||
expect(provider.getAllFlags()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("getAllFlags: returns empty array for empty config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new JsonProvider([]);
|
||||
expect(provider.getAllFlags()).toEqual([]);
|
||||
});
|
||||
|
||||
it("setFlag: is a no-op (read-only)", { timeout: 5000 }, () => {
|
||||
const provider = new JsonProvider(flags);
|
||||
provider.setFlag("flag-a", {
|
||||
key: "flag-a",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
});
|
||||
// Original value preserved
|
||||
expect(provider.getFlag("flag-a")?.defaultValue).toBe(false);
|
||||
});
|
||||
|
||||
it("deleteFlag: is a no-op (read-only)", { timeout: 5000 }, () => {
|
||||
const provider = new JsonProvider(flags);
|
||||
provider.deleteFlag("flag-a");
|
||||
// Flag still exists
|
||||
expect(provider.getFlag("flag-a")).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles duplicate keys by keeping last occurrence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const duplicates: FlagDefinition[] = [
|
||||
{ key: "dup", type: "boolean", defaultValue: false },
|
||||
{ key: "dup", type: "boolean", defaultValue: true },
|
||||
];
|
||||
const provider = new JsonProvider(duplicates);
|
||||
expect(provider.getFlag("dup")?.defaultValue).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { FlagDefinition, FlagProvider } from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Read-only feature flag provider loaded from a JSON array.
|
||||
*
|
||||
* Useful when flag configuration is embedded in a static JSON file (e.g.
|
||||
* bundled into a web app or loaded from a config server at startup).
|
||||
* All mutation methods are no-ops on this provider; the loaded flags are
|
||||
* frozen at construction time.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import flagsJson from './flags.json';
|
||||
* const provider = new JsonProvider(flagsJson);
|
||||
* provider.getFlag('dark-mode'); // returns the flag definition
|
||||
* ```
|
||||
*/
|
||||
export class JsonProvider implements FlagProvider {
|
||||
protected readonly store: ReadonlyMap<string, FlagDefinition>;
|
||||
|
||||
constructor(config: FlagDefinition[]) {
|
||||
const map = new Map<string, FlagDefinition>();
|
||||
for (const flag of config) {
|
||||
map.set(flag.key, flag);
|
||||
}
|
||||
this.store = map;
|
||||
}
|
||||
|
||||
getFlag(key: string): FlagDefinition | undefined {
|
||||
return this.store.get(key);
|
||||
}
|
||||
|
||||
getAllFlags(): FlagDefinition[] {
|
||||
return Array.from(this.store.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op. JsonProvider is read-only.
|
||||
* @param _key - Ignored.
|
||||
* @param _flag - Ignored.
|
||||
*/
|
||||
setFlag(_key: string, _flag: FlagDefinition): void {
|
||||
// Read-only provider — mutations are intentionally ignored
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op. JsonProvider is read-only.
|
||||
* @param _key - Ignored.
|
||||
*/
|
||||
deleteFlag(_key: string): void {
|
||||
// Read-only provider — deletions are intentionally ignored
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MemoryProvider } from "./memory";
|
||||
@@ -0,0 +1,74 @@
|
||||
# memory-provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the `FlagProvider` interface using an in-memory `Map` as the backing store. It supports full CRUD operations and emits lifecycle events (`flag:created`, `flag:updated`, `flag:deleted`) via an optional `EventBusLike` when flags change. This provider is the primary choice for tests, local development, and runtime in-process overrides.
|
||||
|
||||
Because it holds all flags in memory, it is not suitable as a primary provider in distributed systems where multiple processes must stay in sync — use a database or remote-config provider for that case, seeding it with the same initial definitions.
|
||||
|
||||
## Features
|
||||
|
||||
- Full CRUD: `getFlag`, `getAllFlags`, `setFlag`, `deleteFlag`
|
||||
- Optional `EventBusLike` integration for reactive change propagation
|
||||
- O(1) get/set/delete via `Map`
|
||||
- Supports initial flag list at construction time
|
||||
- Zero I/O — suitable for synchronous evaluation hot paths
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const provider = new MemoryProvider([
|
||||
{ key: 'dark-mode', type: 'boolean', defaultValue: false },
|
||||
]);
|
||||
|
||||
provider.getFlag('dark-mode'); // FlagDefinition
|
||||
provider.getAllFlags(); // [{ key: 'dark-mode', ... }]
|
||||
provider.setFlag('new-flag', { key: 'new-flag', type: 'string', defaultValue: 'v1' });
|
||||
provider.deleteFlag('new-flag');
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryProvider } from '@suaveplan/feature-flags/providers';
|
||||
import { EventBus } from '@suaveplan/events';
|
||||
|
||||
const bus = new EventBus();
|
||||
bus.on('flag:created', ({ key }) => console.log(`Flag ${key} created`));
|
||||
bus.on('flag:updated', ({ key }) => console.log(`Flag ${key} updated`));
|
||||
bus.on('flag:deleted', ({ key }) => console.log(`Flag ${key} deleted`));
|
||||
|
||||
const provider = new MemoryProvider([], bus);
|
||||
provider.setFlag('feature-x', { key: 'feature-x', type: 'boolean', defaultValue: false });
|
||||
// → logs "Flag feature-x created"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MemoryProvider implements FlagProvider`
|
||||
|
||||
**Constructor:** `new MemoryProvider(initial?: FlagDefinition[], eventBus?: EventBusLike)`
|
||||
|
||||
- `initial` — optional list of flags to pre-populate the store
|
||||
- `eventBus` — optional event bus for change notifications
|
||||
|
||||
**`getFlag(key: string): FlagDefinition | undefined`**
|
||||
Returns the flag for `key`, or `undefined` if not found.
|
||||
|
||||
**`getAllFlags(): FlagDefinition[]`**
|
||||
Returns all flags as an array (insertion order, via `Map` iteration).
|
||||
|
||||
**`setFlag(key: string, flag: FlagDefinition): void`**
|
||||
Inserts or replaces the flag. Emits `flag:created` or `flag:updated` on the event bus.
|
||||
|
||||
**`deleteFlag(key: string): void`**
|
||||
Removes the flag. Emits `flag:deleted` if the flag existed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The event bus `emit` calls are fire-and-forget; the provider does not await them. The `isUpdate` check is performed before `set` by calling `store.has(key)` so that the correct event type is emitted even when the key is being replaced. The `EventBusLike` interface from `@suaveplan/types` is used rather than the concrete `EventBus` class to avoid a hard dependency on `@suaveplan/events` within this file.
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import type { FlagDefinition } from "../../types/common.js";
|
||||
import { MemoryProvider } from "./memory.js";
|
||||
|
||||
const flagA: FlagDefinition = {
|
||||
key: "flag-a",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
const flagB: FlagDefinition = {
|
||||
key: "flag-b",
|
||||
type: "string",
|
||||
defaultValue: "v1",
|
||||
};
|
||||
|
||||
describe("MemoryProvider", () => {
|
||||
it("initialises with provided flags", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider([flagA, flagB]);
|
||||
expect(provider.getAllFlags()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("getFlag: returns a flag by key", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider([flagA]);
|
||||
expect(provider.getFlag("flag-a")).toEqual(flagA);
|
||||
});
|
||||
|
||||
it("getFlag: returns undefined for unknown key", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider([]);
|
||||
expect(provider.getFlag("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setFlag: inserts a new flag", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider();
|
||||
provider.setFlag("flag-a", flagA);
|
||||
expect(provider.getFlag("flag-a")).toEqual(flagA);
|
||||
});
|
||||
|
||||
it("setFlag: replaces an existing flag", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider([flagA]);
|
||||
const updated: FlagDefinition = { ...flagA, defaultValue: true };
|
||||
provider.setFlag("flag-a", updated);
|
||||
expect(provider.getFlag("flag-a")?.defaultValue).toBe(true);
|
||||
});
|
||||
|
||||
it("deleteFlag: removes a flag", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider([flagA]);
|
||||
provider.deleteFlag("flag-a");
|
||||
expect(provider.getFlag("flag-a")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deleteFlag: no-op for unknown key", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider();
|
||||
expect(() => provider.deleteFlag("missing")).not.toThrow();
|
||||
});
|
||||
|
||||
it("getAllFlags: returns all flags", { timeout: 5000 }, () => {
|
||||
const provider = new MemoryProvider([flagA, flagB]);
|
||||
const all = provider.getAllFlags();
|
||||
expect(all).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("with event bus", () => {
|
||||
function makeBus() {
|
||||
const emitted: Array<{ event: string; data: unknown }> = [];
|
||||
const bus: EventBusLike = {
|
||||
emit: vi.fn((event: string, data: unknown) => {
|
||||
emitted.push({ event, data });
|
||||
}) as EventBusLike["emit"],
|
||||
on: vi.fn() as EventBusLike["on"],
|
||||
off: vi.fn() as EventBusLike["off"],
|
||||
};
|
||||
return { bus, emitted };
|
||||
}
|
||||
|
||||
it("setFlag: emits flag:created for new flag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { bus, emitted } = makeBus();
|
||||
const provider = new MemoryProvider([], bus);
|
||||
provider.setFlag("flag-a", flagA);
|
||||
expect(emitted[0]?.event).toBe("flag:created");
|
||||
});
|
||||
|
||||
it("setFlag: emits flag:updated for existing flag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { bus, emitted } = makeBus();
|
||||
const provider = new MemoryProvider([flagA], bus);
|
||||
provider.setFlag("flag-a", { ...flagA, defaultValue: true });
|
||||
expect(emitted[0]?.event).toBe("flag:updated");
|
||||
});
|
||||
|
||||
it("deleteFlag: emits flag:deleted when flag exists", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { bus, emitted } = makeBus();
|
||||
const provider = new MemoryProvider([flagA], bus);
|
||||
provider.deleteFlag("flag-a");
|
||||
expect(emitted[0]?.event).toBe("flag:deleted");
|
||||
});
|
||||
|
||||
it("deleteFlag: does not emit when flag does not exist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { bus, emitted } = makeBus();
|
||||
const provider = new MemoryProvider([], bus);
|
||||
provider.deleteFlag("missing");
|
||||
expect(emitted).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import type { FlagDefinition, FlagProvider } from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* In-memory feature flag provider backed by a `Map`.
|
||||
*
|
||||
* Supports full CRUD operations and emits change events via an optional
|
||||
* `EventBusLike` when flags are created, updated, or deleted. This provider
|
||||
* is the default choice for tests, local development, and runtime overrides.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const provider = new MemoryProvider([
|
||||
* { key: 'dark-mode', type: 'boolean', defaultValue: false },
|
||||
* ]);
|
||||
* provider.getFlag('dark-mode'); // returns the flag definition
|
||||
* ```
|
||||
*/
|
||||
export class MemoryProvider implements FlagProvider {
|
||||
protected readonly store: Map<string, FlagDefinition>;
|
||||
protected readonly eventBus?: EventBusLike;
|
||||
|
||||
constructor(initial?: FlagDefinition[], eventBus?: EventBusLike) {
|
||||
this.store = new Map();
|
||||
if (eventBus !== undefined) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
if (initial) {
|
||||
for (const flag of initial) {
|
||||
this.store.set(flag.key, flag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFlag(key: string): FlagDefinition | undefined {
|
||||
return this.store.get(key);
|
||||
}
|
||||
|
||||
getAllFlags(): FlagDefinition[] {
|
||||
return Array.from(this.store.values());
|
||||
}
|
||||
|
||||
setFlag(key: string, flag: FlagDefinition): void {
|
||||
const isUpdate = this.store.has(key);
|
||||
this.store.set(key, flag);
|
||||
if (this.eventBus) {
|
||||
const eventType = isUpdate ? "flag:updated" : "flag:created";
|
||||
this.eventBus.emit(eventType, { key, flag });
|
||||
}
|
||||
}
|
||||
|
||||
deleteFlag(key: string): void {
|
||||
const existed = this.store.has(key);
|
||||
this.store.delete(key);
|
||||
if (existed && this.eventBus) {
|
||||
this.eventBus.emit("flag:deleted", { key });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# attribute-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements a flexible rule-based targeting strategy that evaluates attribute conditions against fields in `EvaluationContext.attributes`. It supports eleven comparison operators and AND/OR rule composition, making it the most expressive targeting primitive in the package. It covers use cases ranging from simple plan-tier targeting to complex multi-condition segments.
|
||||
|
||||
The strategy is stateless — all targeting logic is derived purely from the rule configuration and the evaluation context, with no side effects or external I/O.
|
||||
|
||||
## Features
|
||||
|
||||
- Eleven comparison operators: `eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `contains`, `startsWith`, `endsWith`
|
||||
- AND/OR rule composition for multi-condition targeting
|
||||
- Dot-notation attribute path resolution for nested objects
|
||||
- Exported `compareValues` and `getNestedValue` utilities for custom strategy composition
|
||||
- Graceful handling of missing attributes (returns `matched: false`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AttributeStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [{ attribute: 'plan', operator: 'eq', value: 'pro' }],
|
||||
logic: 'AND',
|
||||
value: true,
|
||||
});
|
||||
|
||||
strategy.evaluate(flag, { attributes: { plan: 'pro' } }); // matched: true
|
||||
strategy.evaluate(flag, { attributes: { plan: 'free' } }); // matched: false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AttributeStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
// Match users who are on a paid plan AND in a specific region
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [
|
||||
{ attribute: 'plan', operator: 'in', value: ['pro', 'enterprise'] },
|
||||
{ attribute: 'region', operator: 'startsWith', value: 'eu-' },
|
||||
],
|
||||
logic: 'AND',
|
||||
value: 'eu-checkout-v2',
|
||||
});
|
||||
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: 'pro', region: 'eu-west' },
|
||||
});
|
||||
// matched: true, value: 'eu-checkout-v2'
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AttributeOperator`
|
||||
|
||||
`'eq' | 'neq' | 'in' | 'nin' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'startsWith' | 'endsWith'`
|
||||
|
||||
### `AttributeRule`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|---------------------------------|-------------------------------------|
|
||||
| `attribute` | `string` | Dot-notation path into `attributes` |
|
||||
| `operator` | `AttributeOperator` | Comparison operator |
|
||||
| `value` | `string \| number \| boolean \| Array<...>` | Expected value |
|
||||
|
||||
### `AttributeStrategyOptions`
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---------|------------------|----------|---------|--------------------------------|
|
||||
| `rules` | `AttributeRule[]`| Yes | — | Rules to evaluate |
|
||||
| `logic` | `'AND' \| 'OR'` | No | `'AND'` | Rule combination mode |
|
||||
| `value` | `FlagValue` | Yes | — | Value when rules match |
|
||||
|
||||
### `getNestedValue(obj, path): unknown`
|
||||
|
||||
Traverses `obj` using `path` (dot-separated). Returns `undefined` if any segment is absent.
|
||||
|
||||
### `compareValues(actual, operator, expected): boolean`
|
||||
|
||||
Applies `operator` to `actual` vs `expected`. Returns `false` for type mismatches (e.g., `gt` on non-numbers).
|
||||
|
||||
### `class AttributeStrategy implements FlagStrategy`
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`** — runs all rules and combines with AND/OR logic.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `getNestedValue` function splits on `.` and iterates, returning `undefined` at any absent or non-object segment rather than throwing. `compareValues` performs type guards for numeric operators (`gt`, `gte`, `lt`, `lte`) so that string-to-number comparisons always return `false` rather than producing unexpected coercions. The `in`/`nin` operators require `expected` to be an array; a non-array `expected` always returns `false` for `in` and `false` for `nin` rather than throwing.
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FlagDefinition } from "../../types/common.js";
|
||||
import {
|
||||
type AttributeOperator,
|
||||
AttributeStrategy,
|
||||
compareValues,
|
||||
getNestedValue,
|
||||
} from "./attribute.js";
|
||||
|
||||
const flag: FlagDefinition<boolean> = {
|
||||
key: "test-flag",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
describe("compareValues", () => {
|
||||
it("eq: matches equal values", { timeout: 5000 }, () => {
|
||||
expect(compareValues("pro", "eq", "pro")).toBe(true);
|
||||
expect(compareValues("pro", "eq", "free")).toBe(false);
|
||||
expect(compareValues(42, "eq", 42)).toBe(true);
|
||||
expect(compareValues(true, "eq", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("neq: matches unequal values", { timeout: 5000 }, () => {
|
||||
expect(compareValues("pro", "neq", "free")).toBe(true);
|
||||
expect(compareValues("pro", "neq", "pro")).toBe(false);
|
||||
});
|
||||
|
||||
it("in: checks if actual is in array", { timeout: 5000 }, () => {
|
||||
expect(compareValues("pro", "in", ["pro", "enterprise"])).toBe(true);
|
||||
expect(compareValues("free", "in", ["pro", "enterprise"])).toBe(false);
|
||||
expect(compareValues("pro", "in", "pro")).toBe(false); // non-array expected
|
||||
});
|
||||
|
||||
it("nin: checks if actual is NOT in array", { timeout: 5000 }, () => {
|
||||
expect(compareValues("free", "nin", ["pro", "enterprise"])).toBe(true);
|
||||
expect(compareValues("pro", "nin", ["pro", "enterprise"])).toBe(false);
|
||||
expect(compareValues("pro", "nin", "pro")).toBe(false); // non-array expected
|
||||
});
|
||||
|
||||
it("gt/gte/lt/lte: numeric comparisons", { timeout: 5000 }, () => {
|
||||
expect(compareValues(10, "gt", 5)).toBe(true);
|
||||
expect(compareValues(5, "gt", 10)).toBe(false);
|
||||
expect(compareValues(10, "gte", 10)).toBe(true);
|
||||
expect(compareValues(5, "lt", 10)).toBe(true);
|
||||
expect(compareValues(10, "lt", 5)).toBe(false);
|
||||
expect(compareValues(10, "lte", 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("gt/lt: returns false for non-numeric types", { timeout: 5000 }, () => {
|
||||
expect(compareValues("high", "gt", "low")).toBe(false);
|
||||
expect(compareValues("low", "lt", "high")).toBe(false);
|
||||
});
|
||||
|
||||
it("contains: string contains check", { timeout: 5000 }, () => {
|
||||
expect(compareValues("hello world", "contains", "world")).toBe(true);
|
||||
expect(compareValues("hello world", "contains", "xyz")).toBe(false);
|
||||
expect(compareValues(42, "contains", "4")).toBe(false);
|
||||
});
|
||||
|
||||
it("startsWith: string prefix check", { timeout: 5000 }, () => {
|
||||
expect(compareValues("hello world", "startsWith", "hello")).toBe(true);
|
||||
expect(compareValues("hello world", "startsWith", "world")).toBe(false);
|
||||
});
|
||||
|
||||
it("endsWith: string suffix check", { timeout: 5000 }, () => {
|
||||
expect(compareValues("hello world", "endsWith", "world")).toBe(true);
|
||||
expect(compareValues("hello world", "endsWith", "hello")).toBe(false);
|
||||
});
|
||||
|
||||
it("unknown operator returns false", { timeout: 5000 }, () => {
|
||||
// Cast to any is needed to test runtime behaviour for invalid operator
|
||||
const unknownOp = "unknown" as AttributeOperator;
|
||||
expect(compareValues("val", unknownOp, "val")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNestedValue", () => {
|
||||
it("retrieves top-level key", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ plan: "pro" }, "plan")).toBe("pro");
|
||||
});
|
||||
|
||||
it("retrieves nested key via dot notation", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
getNestedValue({ user: { plan: "enterprise" } }, "user.plan")
|
||||
).toBe("enterprise");
|
||||
});
|
||||
|
||||
it("returns undefined for missing key", { timeout: 5000 }, () => {
|
||||
expect(getNestedValue({ plan: "pro" }, "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when traversal hits a non-object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(getNestedValue({ plan: "pro" }, "plan.sub")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttributeStrategy", () => {
|
||||
it("AND: all rules must match", { timeout: 5000 }, () => {
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [
|
||||
{ attribute: "plan", operator: "eq", value: "pro" },
|
||||
{ attribute: "region", operator: "eq", value: "eu" },
|
||||
],
|
||||
logic: "AND",
|
||||
value: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: "pro", region: "eu" },
|
||||
}).matched
|
||||
).toBe(true);
|
||||
expect(
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: "pro", region: "us" },
|
||||
}).matched
|
||||
).toBe(false);
|
||||
expect(
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: "free", region: "eu" },
|
||||
}).matched
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("OR: at least one rule must match", { timeout: 5000 }, () => {
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [
|
||||
{ attribute: "plan", operator: "eq", value: "pro" },
|
||||
{ attribute: "beta", operator: "eq", value: true },
|
||||
],
|
||||
logic: "OR",
|
||||
value: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: "free", beta: true },
|
||||
}).matched
|
||||
).toBe(true);
|
||||
expect(
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: "pro", beta: false },
|
||||
}).matched
|
||||
).toBe(true);
|
||||
expect(
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: "free", beta: false },
|
||||
}).matched
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to AND logic when not specified", { timeout: 5000 }, () => {
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [
|
||||
{ attribute: "plan", operator: "eq", value: "pro" },
|
||||
{ attribute: "active", operator: "eq", value: true },
|
||||
],
|
||||
value: true,
|
||||
});
|
||||
expect(
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: "pro", active: true },
|
||||
}).matched
|
||||
).toBe(true);
|
||||
expect(
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: "pro", active: false },
|
||||
}).matched
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns strategyName in result", { timeout: 5000 }, () => {
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [{ attribute: "plan", operator: "eq", value: "pro" }],
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { attributes: { plan: "pro" } });
|
||||
expect(result.strategyName).toBe("attribute-strategy");
|
||||
});
|
||||
|
||||
it("handles empty attributes gracefully", { timeout: 5000 }, () => {
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [{ attribute: "plan", operator: "eq", value: "pro" }],
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, {});
|
||||
expect(result.matched).toBe(false);
|
||||
});
|
||||
|
||||
it("in operator with array", { timeout: 5000 }, () => {
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [
|
||||
{
|
||||
attribute: "plan",
|
||||
operator: "in",
|
||||
value: ["pro", "enterprise"],
|
||||
},
|
||||
],
|
||||
value: true,
|
||||
});
|
||||
expect(
|
||||
strategy.evaluate(flag, { attributes: { plan: "pro" } }).matched
|
||||
).toBe(true);
|
||||
expect(
|
||||
strategy.evaluate(flag, { attributes: { plan: "free" } }).matched
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("nin operator with array", { timeout: 5000 }, () => {
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [
|
||||
{
|
||||
attribute: "plan",
|
||||
operator: "nin",
|
||||
value: ["banned1", "banned2"],
|
||||
},
|
||||
],
|
||||
value: true,
|
||||
});
|
||||
expect(
|
||||
strategy.evaluate(flag, { attributes: { plan: "pro" } }).matched
|
||||
).toBe(true);
|
||||
expect(
|
||||
strategy.evaluate(flag, { attributes: { plan: "banned1" } }).matched
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagStrategy,
|
||||
FlagValue,
|
||||
StrategyEvaluation,
|
||||
} from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Supported comparison operators for attribute rules.
|
||||
*
|
||||
* - `eq` — strict equality
|
||||
* - `neq` — strict inequality
|
||||
* - `in` — actual value is in the expected array
|
||||
* - `nin` — actual value is not in the expected array
|
||||
* - `gt` — actual > expected (numeric comparison)
|
||||
* - `gte` — actual >= expected (numeric comparison)
|
||||
* - `lt` — actual < expected (numeric comparison)
|
||||
* - `lte` — actual <= expected (numeric comparison)
|
||||
* - `contains` — string actual contains string expected
|
||||
* - `startsWith` — string actual starts with string expected
|
||||
* - `endsWith` — string actual ends with string expected
|
||||
*/
|
||||
export type AttributeOperator =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "in"
|
||||
| "nin"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "contains"
|
||||
| "startsWith"
|
||||
| "endsWith";
|
||||
|
||||
/**
|
||||
* A single attribute comparison rule.
|
||||
*/
|
||||
export interface AttributeRule {
|
||||
/**
|
||||
* Dot-notation path into `context.attributes`.
|
||||
* For example, `'plan'` reads `context.attributes.plan`.
|
||||
*/
|
||||
attribute: string;
|
||||
/** Comparison operator to apply. */
|
||||
operator: AttributeOperator;
|
||||
/** Expected value or array of values (for `in`/`nin`). */
|
||||
value: string | number | boolean | Array<string | number | boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for {@link AttributeStrategy}.
|
||||
*/
|
||||
export interface AttributeStrategyOptions {
|
||||
/** One or more attribute rules to evaluate. */
|
||||
rules: AttributeRule[];
|
||||
/**
|
||||
* How multiple rules are combined.
|
||||
* - `'AND'` — all rules must match (default)
|
||||
* - `'OR'` — at least one rule must match
|
||||
*/
|
||||
logic?: "AND" | "OR";
|
||||
/** Value returned when the combined rule set matches. */
|
||||
value: FlagValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a value from a nested object using a dot-notation path.
|
||||
*
|
||||
* Only the first level of nesting is used for `context.attributes`, but
|
||||
* this utility supports deeper paths for future extensibility.
|
||||
*
|
||||
* @param obj - Source object.
|
||||
* @param path - Dot-separated key path, e.g. `'user.plan'`.
|
||||
* @returns The value at the path, or `undefined` if any segment is absent.
|
||||
*/
|
||||
export function getNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
path: string
|
||||
): unknown {
|
||||
const parts = path.split(".");
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (
|
||||
current === null ||
|
||||
current === undefined ||
|
||||
typeof current !== "object"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare an actual value against an expected value using the given operator.
|
||||
*
|
||||
* @param actual - The value retrieved from the evaluation context.
|
||||
* @param operator - The comparison operator to apply.
|
||||
* @param expected - The rule's expected value or array.
|
||||
* @returns `true` when the comparison holds, `false` otherwise.
|
||||
*/
|
||||
export function compareValues(
|
||||
actual: unknown,
|
||||
operator: AttributeOperator,
|
||||
expected: string | number | boolean | Array<string | number | boolean>
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
return actual === expected;
|
||||
case "neq":
|
||||
return actual !== expected;
|
||||
case "in":
|
||||
return (
|
||||
Array.isArray(expected) &&
|
||||
expected.includes(actual as string | number | boolean)
|
||||
);
|
||||
case "nin":
|
||||
return (
|
||||
Array.isArray(expected) &&
|
||||
!expected.includes(actual as string | number | boolean)
|
||||
);
|
||||
case "gt":
|
||||
return (
|
||||
typeof actual === "number" &&
|
||||
typeof expected === "number" &&
|
||||
actual > expected
|
||||
);
|
||||
case "gte":
|
||||
return (
|
||||
typeof actual === "number" &&
|
||||
typeof expected === "number" &&
|
||||
actual >= expected
|
||||
);
|
||||
case "lt":
|
||||
return (
|
||||
typeof actual === "number" &&
|
||||
typeof expected === "number" &&
|
||||
actual < expected
|
||||
);
|
||||
case "lte":
|
||||
return (
|
||||
typeof actual === "number" &&
|
||||
typeof expected === "number" &&
|
||||
actual <= expected
|
||||
);
|
||||
case "contains":
|
||||
return (
|
||||
typeof actual === "string" &&
|
||||
typeof expected === "string" &&
|
||||
actual.includes(expected)
|
||||
);
|
||||
case "startsWith":
|
||||
return (
|
||||
typeof actual === "string" &&
|
||||
typeof expected === "string" &&
|
||||
actual.startsWith(expected)
|
||||
);
|
||||
case "endsWith":
|
||||
return (
|
||||
typeof actual === "string" &&
|
||||
typeof expected === "string" &&
|
||||
actual.endsWith(expected)
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule-based attribute targeting strategy.
|
||||
*
|
||||
* Evaluates one or more attribute rules against `context.attributes`, combining
|
||||
* them with AND or OR logic. A match returns the configured `value`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const strategy = new AttributeStrategy({
|
||||
* rules: [{ attribute: 'plan', operator: 'eq', value: 'pro' }],
|
||||
* logic: 'AND',
|
||||
* value: true,
|
||||
* });
|
||||
* strategy.evaluate(flag, { attributes: { plan: 'pro' } }); // matched: true
|
||||
* ```
|
||||
*/
|
||||
export class AttributeStrategy implements FlagStrategy {
|
||||
readonly name = "attribute-strategy";
|
||||
|
||||
protected readonly options: AttributeStrategyOptions;
|
||||
|
||||
constructor(options: AttributeStrategyOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
evaluate(
|
||||
_flag: FlagDefinition,
|
||||
context: EvaluationContext
|
||||
): StrategyEvaluation {
|
||||
const attrs = (context.attributes ?? {}) as Record<string, unknown>;
|
||||
const logic = this.options.logic ?? "AND";
|
||||
|
||||
const results = this.options.rules.map((rule) => {
|
||||
const actual = getNestedValue(attrs, rule.attribute);
|
||||
return compareValues(actual, rule.operator, rule.value);
|
||||
});
|
||||
|
||||
const matched =
|
||||
logic === "AND" ? results.every(Boolean) : results.some(Boolean);
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
matched,
|
||||
value: this.options.value,
|
||||
strategyName: this.name,
|
||||
};
|
||||
}
|
||||
return { matched: false, strategyName: this.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type AttributeOperator,
|
||||
type AttributeRule,
|
||||
AttributeStrategy,
|
||||
type AttributeStrategyOptions,
|
||||
compareValues,
|
||||
getNestedValue,
|
||||
} from "./attribute";
|
||||
@@ -0,0 +1,81 @@
|
||||
# composite-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a meta-strategy that combines two or more child strategies using AND or OR boolean logic. This enables arbitrarily complex targeting rules to be built by composing the simpler, single-concern strategy primitives. For example, a rule such as "user must be in the 20% rollout AND have the 'enterprise' plan" can be expressed by nesting a `PercentageStrategy` and an `AttributeStrategy` inside a `CompositeStrategy` with AND logic.
|
||||
|
||||
Composite strategies can themselves be nested to arbitrary depth, supporting expression trees of any complexity.
|
||||
|
||||
## Features
|
||||
|
||||
- AND logic — all child strategies must match (short-circuits on first non-match)
|
||||
- OR logic — at least one child strategy must match (short-circuits on first match)
|
||||
- Empty strategy list returns `matched: false` rather than throwing
|
||||
- Child strategies evaluated in declaration order
|
||||
- Nestable — a `CompositeStrategy` can be a child of another `CompositeStrategy`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CompositeStrategy, PercentageStrategy, UserTargetStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const betaUsers = new UserTargetStrategy({ users: ['alice'], value: true });
|
||||
const rollout = new PercentageStrategy({ percentage: 10, value: true });
|
||||
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [betaUsers, rollout],
|
||||
logic: 'OR',
|
||||
value: true,
|
||||
});
|
||||
|
||||
// alice always matches; other users match if their bucket < 10%
|
||||
strategy.evaluate(flag, { userId: 'alice' }); // matched: true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CompositeStrategy, PercentageStrategy, AttributeStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const paidPlan = new AttributeStrategy({
|
||||
rules: [{ attribute: 'plan', operator: 'in', value: ['pro', 'enterprise'] }],
|
||||
logic: 'AND',
|
||||
value: true,
|
||||
});
|
||||
|
||||
const smallRollout = new PercentageStrategy({ percentage: 5, value: true });
|
||||
|
||||
// Only paid users who are in the 5% test group
|
||||
const targeted = new CompositeStrategy({
|
||||
strategies: [paidPlan, smallRollout],
|
||||
logic: 'AND',
|
||||
value: 'new-billing-ui',
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CompositeStrategyOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|------------------|----------|-----------------------------------|
|
||||
| `strategies` | `FlagStrategy[]` | Yes | Child strategies to evaluate |
|
||||
| `logic` | `'AND' \| 'OR'` | Yes | Rule combination mode |
|
||||
| `value` | `FlagValue` | Yes | Value returned on match |
|
||||
|
||||
### `class CompositeStrategy implements FlagStrategy`
|
||||
|
||||
**Constructor:** `new CompositeStrategy(options: CompositeStrategyOptions)`
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`**
|
||||
|
||||
- AND: uses `Array.every` — short-circuits on first non-match
|
||||
- OR: uses `Array.some` — short-circuits on first match
|
||||
- Empty `strategies` array returns `matched: false`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Short-circuit evaluation means that later strategies in an AND chain are not evaluated once a non-match is found, and later strategies in an OR chain are not evaluated once a match is found. This is important for performance when strategies involve expensive attribute lookups or when side-effects (e.g., OTel spans) should not be emitted for every strategy in the chain. The composite's own `value` overrides any child `value` fields — the child `value` is ignored; only `matched` is read from children.
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
FlagDefinition,
|
||||
FlagStrategy,
|
||||
StrategyEvaluation,
|
||||
} from "../../types/common.js";
|
||||
import { CompositeStrategy } from "./composite.js";
|
||||
|
||||
const flag: FlagDefinition<boolean> = {
|
||||
key: "test-flag",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
function makeStrategy(matched: boolean, name = "mock"): FlagStrategy {
|
||||
return {
|
||||
name,
|
||||
evaluate: (): StrategyEvaluation => ({ matched, strategyName: name }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("CompositeStrategy", () => {
|
||||
describe("AND logic", () => {
|
||||
it("both match → overall matched", { timeout: 5000 }, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [
|
||||
makeStrategy(true, "s1"),
|
||||
makeStrategy(true, "s2"),
|
||||
],
|
||||
logic: "AND",
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, {});
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.value).toBe(true);
|
||||
});
|
||||
|
||||
it("first does not match → overall not matched", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [
|
||||
makeStrategy(false, "s1"),
|
||||
makeStrategy(true, "s2"),
|
||||
],
|
||||
logic: "AND",
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, {}).matched).toBe(false);
|
||||
});
|
||||
|
||||
it("second does not match → overall not matched", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [
|
||||
makeStrategy(true, "s1"),
|
||||
makeStrategy(false, "s2"),
|
||||
],
|
||||
logic: "AND",
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, {}).matched).toBe(false);
|
||||
});
|
||||
|
||||
it("none match → overall not matched", { timeout: 5000 }, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [makeStrategy(false), makeStrategy(false)],
|
||||
logic: "AND",
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, {}).matched).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OR logic", () => {
|
||||
it("first matches → overall matched", { timeout: 5000 }, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [
|
||||
makeStrategy(true, "s1"),
|
||||
makeStrategy(false, "s2"),
|
||||
],
|
||||
logic: "OR",
|
||||
value: "variant",
|
||||
});
|
||||
const result = strategy.evaluate(flag, {});
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.value).toBe("variant");
|
||||
});
|
||||
|
||||
it("second matches → overall matched", { timeout: 5000 }, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [
|
||||
makeStrategy(false, "s1"),
|
||||
makeStrategy(true, "s2"),
|
||||
],
|
||||
logic: "OR",
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, {}).matched).toBe(true);
|
||||
});
|
||||
|
||||
it("none match → overall not matched", { timeout: 5000 }, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [makeStrategy(false), makeStrategy(false)],
|
||||
logic: "OR",
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, {}).matched).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("empty strategies list returns matched:false", { timeout: 5000 }, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [],
|
||||
logic: "AND",
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, {}).matched).toBe(false);
|
||||
});
|
||||
|
||||
it("returns strategyName in result", { timeout: 5000 }, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [makeStrategy(true)],
|
||||
logic: "OR",
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, {}).strategyName).toBe(
|
||||
"composite-strategy"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined value when not matched", { timeout: 5000 }, () => {
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [makeStrategy(false)],
|
||||
logic: "OR",
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, {}).value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagStrategy,
|
||||
FlagValue,
|
||||
StrategyEvaluation,
|
||||
} from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Configuration options for {@link CompositeStrategy}.
|
||||
*/
|
||||
export interface CompositeStrategyOptions {
|
||||
/** Child strategies to evaluate. */
|
||||
strategies: FlagStrategy[];
|
||||
/**
|
||||
* How child results are combined.
|
||||
* - `'AND'` — all child strategies must match
|
||||
* - `'OR'` — at least one child strategy must match
|
||||
*/
|
||||
logic: "AND" | "OR";
|
||||
/** Value returned when the composite condition matches. */
|
||||
value: FlagValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite strategy that combines multiple child strategies with AND or OR logic.
|
||||
*
|
||||
* This enables complex targeting rules such as "user is in the 10% rollout AND
|
||||
* has the 'pro' plan" or "user is on the beta list OR is an internal employee".
|
||||
*
|
||||
* Child strategies are evaluated in order; AND logic short-circuits on the first
|
||||
* non-match, OR logic short-circuits on the first match.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const strategy = new CompositeStrategy({
|
||||
* strategies: [percentageStrategy, userTargetStrategy],
|
||||
* logic: 'OR',
|
||||
* value: true,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class CompositeStrategy implements FlagStrategy {
|
||||
readonly name = "composite-strategy";
|
||||
|
||||
protected readonly options: CompositeStrategyOptions;
|
||||
|
||||
constructor(options: CompositeStrategyOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
evaluate(
|
||||
flag: FlagDefinition,
|
||||
context: EvaluationContext
|
||||
): StrategyEvaluation {
|
||||
const { strategies, logic } = this.options;
|
||||
|
||||
if (strategies.length === 0) {
|
||||
return { matched: false, strategyName: this.name };
|
||||
}
|
||||
|
||||
let matched: boolean;
|
||||
|
||||
if (logic === "AND") {
|
||||
matched = strategies.every(
|
||||
(s) => s.evaluate(flag, context).matched
|
||||
);
|
||||
} else {
|
||||
matched = strategies.some((s) => s.evaluate(flag, context).matched);
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
matched,
|
||||
value: this.options.value,
|
||||
strategyName: this.name,
|
||||
};
|
||||
}
|
||||
return { matched: false, strategyName: this.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CompositeStrategy, type CompositeStrategyOptions } from "./composite";
|
||||
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
type AttributeOperator,
|
||||
type AttributeRule,
|
||||
AttributeStrategy,
|
||||
type AttributeStrategyOptions,
|
||||
compareValues,
|
||||
getNestedValue,
|
||||
} from "./attribute/attribute.js";
|
||||
export {
|
||||
CompositeStrategy,
|
||||
type CompositeStrategyOptions,
|
||||
} from "./composite/composite.js";
|
||||
export {
|
||||
type PercentageOptions,
|
||||
PercentageStrategy,
|
||||
} from "./percentage/percentage.js";
|
||||
export {
|
||||
type ScheduleOptions,
|
||||
ScheduleStrategy,
|
||||
} from "./schedule/schedule.js";
|
||||
export {
|
||||
type UserTargetOptions,
|
||||
UserTargetStrategy,
|
||||
} from "./user-target/user-target.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { type PercentageOptions, PercentageStrategy } from "./percentage";
|
||||
@@ -0,0 +1,69 @@
|
||||
# percentage-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements a deterministic percentage-rollout strategy for feature flags. Given a configured rollout percentage (0–100), the strategy uses a stable hash of the `(flagKey, userId)` pair to place each user into a consistent bucket. Users in the first N percent of the bucket space receive the flag value; all others do not match.
|
||||
|
||||
The determinism guarantee means that a user who is in the treatment group today will remain in the treatment group tomorrow — without any persistent state — until the flag definition or percentage changes.
|
||||
|
||||
## Features
|
||||
|
||||
- Deterministic bucketing via `hashToPercentage(flagKey, userId)` — no state required
|
||||
- Configurable `userKey` to bucket on any string field of `EvaluationContext` (default `userId`)
|
||||
- Returns the configured `value` when matched, `undefined` otherwise
|
||||
- Gracefully handles missing or non-string context values (returns `matched: false`)
|
||||
- Percentage `0` never matches; percentage `100` always matches
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PercentageStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const strategy = new PercentageStrategy({ percentage: 10, value: true });
|
||||
|
||||
const result = strategy.evaluate(flagDef, { userId: 'user-42' });
|
||||
if (result.matched) {
|
||||
console.log('User is in the 10% treatment group');
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PercentageStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
// Bucket by email instead of userId
|
||||
const emailStrategy = new PercentageStrategy({
|
||||
percentage: 25,
|
||||
value: 'variant-b',
|
||||
userKey: 'email',
|
||||
});
|
||||
|
||||
const result = emailStrategy.evaluate(flag, { email: 'alice@example.com' });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PercentageOptions`
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|------------|----------------------------|----------|------------|-------------------------------------------|
|
||||
| `percentage` | `number` | Yes | — | Rollout percentage, 0–100 |
|
||||
| `value` | `FlagValue` | Yes | — | Value returned when user matches |
|
||||
| `userKey` | `keyof EvaluationContext` | No | `'userId'` | Context field used for bucketing |
|
||||
|
||||
### `class PercentageStrategy implements FlagStrategy`
|
||||
|
||||
**Constructor:** `new PercentageStrategy(options: PercentageOptions)`
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`**
|
||||
|
||||
- Returns `matched: true` with `value` when `hashToPercentage(flag.key, context[userKey]) < percentage`
|
||||
- Returns `matched: false` when the user is outside the bucket or the context field is absent/non-string
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `userKey` field is constrained to `keyof EvaluationContext` to prevent runtime errors from invalid key access. Bucketing uses the flag key as a salt so that the same user does not always fall in or out of every percentage-based flag — independent roll-outs remain statistically independent. When `percentage === 0`, no hash result will be less than zero, so the strategy never matches. When `percentage === 100`, every hash result in `[0, 100)` satisfies the `< 100` condition, so the strategy always matches.
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FlagDefinition } from "../../types/common.js";
|
||||
import { PercentageStrategy } from "./percentage.js";
|
||||
|
||||
const flag: FlagDefinition<boolean> = {
|
||||
key: "test-flag",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
describe("PercentageStrategy", () => {
|
||||
it("100% strategy always matches any userId", { timeout: 5000 }, () => {
|
||||
const strategy = new PercentageStrategy({
|
||||
percentage: 100,
|
||||
value: true,
|
||||
});
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = strategy.evaluate(flag, { userId: `user-${i}` });
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.value).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("0% strategy never matches any userId", { timeout: 5000 }, () => {
|
||||
const strategy = new PercentageStrategy({ percentage: 0, value: true });
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = strategy.evaluate(flag, { userId: `user-${i}` });
|
||||
expect(result.matched).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic: same userId always gets same result", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const strategy = new PercentageStrategy({
|
||||
percentage: 50,
|
||||
value: true,
|
||||
});
|
||||
const r1 = strategy.evaluate(flag, { userId: "stable-user" });
|
||||
const r2 = strategy.evaluate(flag, { userId: "stable-user" });
|
||||
expect(r1.matched).toBe(r2.matched);
|
||||
});
|
||||
|
||||
it("returns undefined value when not matched", { timeout: 5000 }, () => {
|
||||
const strategy = new PercentageStrategy({ percentage: 0, value: true });
|
||||
const result = strategy.evaluate(flag, { userId: "any-user" });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns strategyName in result", { timeout: 5000 }, () => {
|
||||
const strategy = new PercentageStrategy({
|
||||
percentage: 100,
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { userId: "u1" });
|
||||
expect(result.strategyName).toBe("percentage-strategy");
|
||||
});
|
||||
|
||||
it("returns matched:false when userId is absent", { timeout: 5000 }, () => {
|
||||
const strategy = new PercentageStrategy({
|
||||
percentage: 100,
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, {});
|
||||
expect(result.matched).toBe(false);
|
||||
});
|
||||
|
||||
it("supports custom userKey (email)", { timeout: 5000 }, () => {
|
||||
const strategy = new PercentageStrategy({
|
||||
percentage: 100,
|
||||
value: true,
|
||||
userKey: "email",
|
||||
});
|
||||
const result = strategy.evaluate(flag, { email: "user@example.com" });
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
|
||||
it("buckets differently for different flags (same user)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// With enough different flags, at least one should differ from another
|
||||
const user = "common-user";
|
||||
const results = new Set<boolean>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const s = new PercentageStrategy({ percentage: 50, value: true });
|
||||
const f: FlagDefinition<boolean> = {
|
||||
key: `flag-${i}`,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
};
|
||||
results.add(s.evaluate(f, { userId: user }).matched);
|
||||
}
|
||||
// Should have seen both true and false across 20 different flags
|
||||
expect(results.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagStrategy,
|
||||
FlagValue,
|
||||
StrategyEvaluation,
|
||||
} from "../../types/common.js";
|
||||
import { hashToPercentage } from "../../utils/hash.js";
|
||||
|
||||
/**
|
||||
* Configuration options for {@link PercentageStrategy}.
|
||||
*/
|
||||
export interface PercentageOptions {
|
||||
/**
|
||||
* Percentage of users (0–100) that should receive the flag.
|
||||
* A value of `100` means all users match; `0` means no users match.
|
||||
*/
|
||||
percentage: number;
|
||||
/** Value to return when the user falls within the rollout percentage. */
|
||||
value: FlagValue;
|
||||
/**
|
||||
* Which field on the {@link EvaluationContext} to use as the bucketing key.
|
||||
* Defaults to `'userId'`.
|
||||
*/
|
||||
userKey?: keyof EvaluationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic percentage-rollout strategy.
|
||||
*
|
||||
* Uses {@link hashToPercentage} to produce a stable `[0, 100)` bucket for
|
||||
* each `(flagKey, userId)` pair. The user is matched when their bucket falls
|
||||
* strictly below `options.percentage`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const strategy = new PercentageStrategy({ percentage: 20, value: true });
|
||||
* const result = strategy.evaluate(flagDef, { userId: 'u-1' });
|
||||
* // result.matched is deterministic across calls
|
||||
* ```
|
||||
*/
|
||||
export class PercentageStrategy implements FlagStrategy {
|
||||
readonly name = "percentage-strategy";
|
||||
|
||||
protected readonly options: PercentageOptions;
|
||||
|
||||
constructor(options: PercentageOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
evaluate(
|
||||
flag: FlagDefinition,
|
||||
context: EvaluationContext
|
||||
): StrategyEvaluation {
|
||||
const userKey = this.options.userKey ?? "userId";
|
||||
const userValue = context[userKey];
|
||||
|
||||
// Cannot bucket without a user identifier
|
||||
if (!userValue || typeof userValue !== "string") {
|
||||
return { matched: false, strategyName: this.name };
|
||||
}
|
||||
|
||||
const bucket = hashToPercentage(flag.key, userValue);
|
||||
const matched = bucket < this.options.percentage;
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
matched,
|
||||
value: this.options.value,
|
||||
strategyName: this.name,
|
||||
};
|
||||
}
|
||||
return { matched: false, strategyName: this.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type ScheduleOptions, ScheduleStrategy } from "./schedule";
|
||||
@@ -0,0 +1,78 @@
|
||||
# schedule-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements a time-window scheduling strategy for feature flags. A flag managed by this strategy is active only during a configured time interval, enabling precise control over feature launches, promotional windows, and maintenance mode toggles — without requiring a deployment to activate or deactivate the feature.
|
||||
|
||||
The evaluation timestamp is drawn from `context.timestamp` when supplied (enabling deterministic tests) and falls back to `Date.now()` for production use.
|
||||
|
||||
## Features
|
||||
|
||||
- Open-ended windows: omit `startTime`, `endTime`, or both
|
||||
- Inclusive bounds — both `startTime` and `endTime` are part of the active window
|
||||
- Injectable timestamp via `context.timestamp` for testing
|
||||
- No external clock dependency — works with any numeric epoch timestamp
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ScheduleStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const now = Date.now();
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: now,
|
||||
endTime: now + 7 * 24 * 60 * 60 * 1000, // 1 week
|
||||
value: true,
|
||||
});
|
||||
|
||||
strategy.evaluate(flag, {}); // matched: true (current time is within window)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ScheduleStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
// Half-open window: active from a fixed start time with no end
|
||||
const maintenanceMode = new ScheduleStrategy({
|
||||
startTime: new Date('2026-06-01T00:00:00Z').getTime(),
|
||||
value: 'maintenance',
|
||||
});
|
||||
|
||||
// Inject a test timestamp
|
||||
maintenanceMode.evaluate(flag, {
|
||||
timestamp: new Date('2026-06-02T12:00:00Z').getTime(),
|
||||
}); // matched: true
|
||||
|
||||
maintenanceMode.evaluate(flag, {
|
||||
timestamp: new Date('2026-05-31T23:59:59Z').getTime(),
|
||||
}); // matched: false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ScheduleOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------------|----------|----------|-----------------------------------------------|
|
||||
| `startTime` | `number` | No | Window start in ms since epoch (inclusive) |
|
||||
| `endTime` | `number` | No | Window end in ms since epoch (inclusive) |
|
||||
| `value` | `FlagValue` | Yes | Value returned when within the window |
|
||||
| `timezone` | `string` | No | IANA timezone (stored, not yet applied) |
|
||||
|
||||
### `class ScheduleStrategy implements FlagStrategy`
|
||||
|
||||
**Constructor:** `new ScheduleStrategy(options: ScheduleOptions)`
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`**
|
||||
|
||||
- Reads `context.timestamp ?? Date.now()` as the current time
|
||||
- Returns `matched: true` when `now >= startTime` (if set) AND `now <= endTime` (if set)
|
||||
- Returns `matched: true` when neither bound is set (always-active window)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both bounds are inclusive (`>=` / `<=`) to match typical scheduling semantics where a launch at `startTime === now` should be immediately active. The `timezone` field is stored but has no effect on the current comparison, which operates entirely in UTC epoch milliseconds; timezone-aware business-hour windows are a planned extension. The `_flag` parameter is accepted but unused — schedule matching does not depend on flag metadata.
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FlagDefinition } from "../../types/common.js";
|
||||
import { ScheduleStrategy } from "./schedule.js";
|
||||
|
||||
const flag: FlagDefinition<boolean> = {
|
||||
key: "test-flag",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
const BASE_TIME = 1_700_000_000_000; // Fixed reference timestamp
|
||||
|
||||
describe("ScheduleStrategy", () => {
|
||||
it("matches when timestamp is within window", { timeout: 5000 }, () => {
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: BASE_TIME - 1000,
|
||||
endTime: BASE_TIME + 1000,
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { timestamp: BASE_TIME });
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.value).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match before window start", { timeout: 5000 }, () => {
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: BASE_TIME + 1000,
|
||||
endTime: BASE_TIME + 5000,
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { timestamp: BASE_TIME });
|
||||
expect(result.matched).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match after window end", { timeout: 5000 }, () => {
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: BASE_TIME - 5000,
|
||||
endTime: BASE_TIME - 1000,
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { timestamp: BASE_TIME });
|
||||
expect(result.matched).toBe(false);
|
||||
});
|
||||
|
||||
it("matches at exact startTime boundary (inclusive)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: BASE_TIME,
|
||||
endTime: BASE_TIME + 1000,
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, { timestamp: BASE_TIME }).matched).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("matches at exact endTime boundary (inclusive)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: BASE_TIME - 1000,
|
||||
endTime: BASE_TIME,
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, { timestamp: BASE_TIME }).matched).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("no startTime or endTime always matches", { timeout: 5000 }, () => {
|
||||
const strategy = new ScheduleStrategy({ value: true });
|
||||
expect(strategy.evaluate(flag, { timestamp: BASE_TIME }).matched).toBe(
|
||||
true
|
||||
);
|
||||
expect(strategy.evaluate(flag, { timestamp: 0 }).matched).toBe(true);
|
||||
});
|
||||
|
||||
it("no startTime: open-ended start", { timeout: 5000 }, () => {
|
||||
const strategy = new ScheduleStrategy({
|
||||
endTime: BASE_TIME + 5000,
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, { timestamp: BASE_TIME }).matched).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
strategy.evaluate(flag, { timestamp: BASE_TIME + 10000 }).matched
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("no endTime: open-ended end", { timeout: 5000 }, () => {
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: BASE_TIME - 1000,
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, { timestamp: BASE_TIME }).matched).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
strategy.evaluate(flag, { timestamp: BASE_TIME - 2000 }).matched
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns strategyName in result", { timeout: 5000 }, () => {
|
||||
const strategy = new ScheduleStrategy({ value: true });
|
||||
const result = strategy.evaluate(flag, { timestamp: BASE_TIME });
|
||||
expect(result.strategyName).toBe("schedule-strategy");
|
||||
});
|
||||
|
||||
it("returns undefined value when not matched", { timeout: 5000 }, () => {
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: BASE_TIME + 1000,
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { timestamp: BASE_TIME });
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses Date.now() when context.timestamp is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Open-ended interval: no startTime or endTime always matches
|
||||
const strategy = new ScheduleStrategy({ value: "live" });
|
||||
// No timestamp in context — falls back to Date.now()
|
||||
const result = strategy.evaluate(flag, {});
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.value).toBe("live");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagStrategy,
|
||||
FlagValue,
|
||||
StrategyEvaluation,
|
||||
} from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Configuration options for {@link ScheduleStrategy}.
|
||||
*/
|
||||
export interface ScheduleOptions {
|
||||
/**
|
||||
* Start of the active interval, in milliseconds since the Unix epoch.
|
||||
* If omitted, the interval has no lower bound.
|
||||
*/
|
||||
startTime?: number;
|
||||
/**
|
||||
* End of the active interval, in milliseconds since the Unix epoch.
|
||||
* If omitted, the interval has no upper bound.
|
||||
*/
|
||||
endTime?: number;
|
||||
/** Value returned when the current time falls within the interval. */
|
||||
value: FlagValue;
|
||||
/**
|
||||
* IANA timezone name (e.g. `'America/New_York'`).
|
||||
* Stored for documentation purposes; the current implementation operates
|
||||
* in UTC epoch milliseconds, making this a no-op until timezone-aware
|
||||
* business-hour intervals are added.
|
||||
*/
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-interval scheduling strategy.
|
||||
*
|
||||
* Matches when the evaluation timestamp falls within the configured
|
||||
* `[startTime, endTime]` interval (both bounds inclusive). Either bound may
|
||||
* be omitted for an open-ended interval.
|
||||
*
|
||||
* The evaluation timestamp is taken from `context.timestamp` when present,
|
||||
* falling back to `Date.now()` otherwise.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const strategy = new ScheduleStrategy({
|
||||
* startTime: Date.now(),
|
||||
* endTime: Date.now() + 86_400_000,
|
||||
* value: true,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class ScheduleStrategy implements FlagStrategy {
|
||||
readonly name = "schedule-strategy";
|
||||
|
||||
protected readonly options: ScheduleOptions;
|
||||
|
||||
constructor(options: ScheduleOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
evaluate(
|
||||
_flag: FlagDefinition,
|
||||
context: EvaluationContext
|
||||
): StrategyEvaluation {
|
||||
const now = context.timestamp ?? Date.now();
|
||||
const { startTime, endTime } = this.options;
|
||||
|
||||
const afterStart = startTime === undefined || now >= startTime;
|
||||
const beforeEnd = endTime === undefined || now <= endTime;
|
||||
const matched = afterStart && beforeEnd;
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
matched,
|
||||
value: this.options.value,
|
||||
strategyName: this.name,
|
||||
};
|
||||
}
|
||||
return { matched: false, strategyName: this.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type UserTargetOptions, UserTargetStrategy } from "./user-target";
|
||||
@@ -0,0 +1,76 @@
|
||||
# user-target-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements an explicit user-targeting strategy that matches when the current user's `userId` appears in a statically configured list. This strategy covers the common pattern of enabling a feature for a specific set of known users — beta testers, internal employees, support-escalation users — without requiring percentage-based bucketing or attribute rules.
|
||||
|
||||
The user list is converted to a `Set` at construction time for O(1) amortised lookup, making it practical for lists up to several thousand entries.
|
||||
|
||||
## Features
|
||||
|
||||
- Exact-match targeting against a configured list of `userId` strings
|
||||
- O(1) lookup via internal `Set` constructed at instantiation
|
||||
- Returns the configured `value` when matched, `undefined` otherwise
|
||||
- Gracefully handles missing `userId` in context (returns `matched: false`)
|
||||
- Immutable after construction — thread-safe for concurrent evaluations
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { UserTargetStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const strategy = new UserTargetStrategy({
|
||||
users: ['alice', 'bob', 'charlie'],
|
||||
value: true,
|
||||
});
|
||||
|
||||
strategy.evaluate(flag, { userId: 'alice' }); // matched: true
|
||||
strategy.evaluate(flag, { userId: 'dave' }); // matched: false
|
||||
strategy.evaluate(flag, {}); // matched: false (no userId)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { UserTargetStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
import { CompositeStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
// Combine with a percentage strategy: internal users always get it,
|
||||
// external users get a 10% rollout
|
||||
const internalUsers = new UserTargetStrategy({
|
||||
users: ['internal-1', 'internal-2'],
|
||||
value: 'variant-a',
|
||||
});
|
||||
|
||||
const composed = new CompositeStrategy({
|
||||
strategies: [internalUsers, percentageStrategy],
|
||||
logic: 'OR',
|
||||
value: 'variant-a',
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UserTargetOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------|-------------|----------|--------------------------------------------|
|
||||
| `users` | `string[]` | Yes | List of `userId` values to match |
|
||||
| `value` | `FlagValue` | Yes | Value returned when the user matches |
|
||||
|
||||
### `class UserTargetStrategy implements FlagStrategy`
|
||||
|
||||
**Constructor:** `new UserTargetStrategy(options: UserTargetOptions)`
|
||||
Builds a `Set<string>` from `options.users` for O(1) lookup.
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`**
|
||||
|
||||
- Returns `matched: true` with `value` when `context.userId` is in the configured set
|
||||
- Returns `matched: false` when `context.userId` is absent or not in the set
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `Set` is built once at construction time. If the user list needs to change at runtime, a new `UserTargetStrategy` instance must be created and registered. The `_flag` parameter is accepted but unused — user targeting does not depend on flag metadata, only on the caller-supplied context. For lists larger than ~10 000 entries, the memory footprint of the `Set` should be considered; an attribute-strategy with an `in` operator backed by a server-side lookup may be more appropriate at that scale.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FlagDefinition } from "../../types/common.js";
|
||||
import { UserTargetStrategy } from "./user-target.js";
|
||||
|
||||
const flag: FlagDefinition<boolean> = {
|
||||
key: "test-flag",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
describe("UserTargetStrategy", () => {
|
||||
it("matches a listed user", { timeout: 5000 }, () => {
|
||||
const strategy = new UserTargetStrategy({
|
||||
users: ["alice", "bob"],
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { userId: "alice" });
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.value).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match an unlisted user", { timeout: 5000 }, () => {
|
||||
const strategy = new UserTargetStrategy({
|
||||
users: ["alice", "bob"],
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { userId: "charlie" });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns matched:false when userId is absent", { timeout: 5000 }, () => {
|
||||
const strategy = new UserTargetStrategy({
|
||||
users: ["alice"],
|
||||
value: true,
|
||||
});
|
||||
const result = strategy.evaluate(flag, {});
|
||||
expect(result.matched).toBe(false);
|
||||
});
|
||||
|
||||
it("returns strategyName in result", { timeout: 5000 }, () => {
|
||||
const strategy = new UserTargetStrategy({
|
||||
users: ["u1"],
|
||||
value: false,
|
||||
});
|
||||
const result = strategy.evaluate(flag, { userId: "u1" });
|
||||
expect(result.strategyName).toBe("user-target-strategy");
|
||||
});
|
||||
|
||||
it("is case-sensitive", { timeout: 5000 }, () => {
|
||||
const strategy = new UserTargetStrategy({
|
||||
users: ["Alice"],
|
||||
value: true,
|
||||
});
|
||||
expect(strategy.evaluate(flag, { userId: "alice" }).matched).toBe(
|
||||
false
|
||||
);
|
||||
expect(strategy.evaluate(flag, { userId: "Alice" }).matched).toBe(true);
|
||||
});
|
||||
|
||||
it("handles empty user list", { timeout: 5000 }, () => {
|
||||
const strategy = new UserTargetStrategy({ users: [], value: true });
|
||||
expect(strategy.evaluate(flag, { userId: "any" }).matched).toBe(false);
|
||||
});
|
||||
|
||||
it("supports non-boolean values", { timeout: 5000 }, () => {
|
||||
const strategy = new UserTargetStrategy({
|
||||
users: ["u1"],
|
||||
value: "variant-b",
|
||||
});
|
||||
const result = strategy.evaluate(flag, { userId: "u1" });
|
||||
expect(result.value).toBe("variant-b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagStrategy,
|
||||
FlagValue,
|
||||
StrategyEvaluation,
|
||||
} from "../../types/common.js";
|
||||
|
||||
/**
|
||||
* Configuration options for {@link UserTargetStrategy}.
|
||||
*/
|
||||
export interface UserTargetOptions {
|
||||
/** Explicit list of `userId` values that should receive the flag. */
|
||||
users: string[];
|
||||
/** Value returned when the context user is in the target list. */
|
||||
value: FlagValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit user-targeting strategy.
|
||||
*
|
||||
* Matches when `context.userId` is present in the configured `users` array.
|
||||
* This strategy is ideal for beta programmes, internal dogfooding, and
|
||||
* targeted incident mitigations where exact user lists are known.
|
||||
*
|
||||
* Lookup is O(n) on the `users` array; for very large lists consider using
|
||||
* a Set-based custom strategy or an attribute-strategy with `in` operator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const strategy = new UserTargetStrategy({
|
||||
* users: ['alice', 'bob'],
|
||||
* value: true,
|
||||
* });
|
||||
* strategy.evaluate(flag, { userId: 'alice' }); // matched: true
|
||||
* strategy.evaluate(flag, { userId: 'carol' }); // matched: false
|
||||
* ```
|
||||
*/
|
||||
export class UserTargetStrategy implements FlagStrategy {
|
||||
readonly name = "user-target-strategy";
|
||||
|
||||
protected readonly options: UserTargetOptions;
|
||||
protected readonly userSet: Set<string>;
|
||||
|
||||
constructor(options: UserTargetOptions) {
|
||||
this.options = options;
|
||||
this.userSet = new Set(options.users);
|
||||
}
|
||||
|
||||
evaluate(
|
||||
_flag: FlagDefinition,
|
||||
context: EvaluationContext
|
||||
): StrategyEvaluation {
|
||||
const { userId } = context;
|
||||
|
||||
if (!userId) {
|
||||
return { matched: false, strategyName: this.name };
|
||||
}
|
||||
|
||||
const matched = this.userSet.has(userId);
|
||||
if (matched) {
|
||||
return {
|
||||
matched,
|
||||
value: this.options.value,
|
||||
strategyName: this.name,
|
||||
};
|
||||
}
|
||||
return { matched: false, strategyName: this.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# common
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines all shared TypeScript types and interfaces used across the `@suaveplan/feature-flags` package. Centralising type definitions in a single module avoids circular import cycles and provides a single source of truth for every contract — from flag definitions and evaluation contexts to strategy interfaces and provider contracts.
|
||||
|
||||
Every concrete class in this package imports from `common.ts` rather than re-declaring types locally, ensuring that structural compatibility is enforced at compile time rather than relying on duck-typed coincidences.
|
||||
|
||||
## Features
|
||||
|
||||
- `FlagValue` — union of the four primitive value types a flag can carry (`boolean | string | number | Record<string, unknown>`)
|
||||
- `FlagType` — discriminant tag aligned with `FlagValue` variants
|
||||
- `FlagDefinition<T>` — generic flag definition including key, type, default, and master kill-switch
|
||||
- `EvaluationContext` — per-request user/attribute context for strategy targeting
|
||||
- `FlagEvaluationResult<T>` — full evaluation outcome with resolved value, reason, and optional strategy name
|
||||
- `StrategyEvaluation` — intermediate result from a single strategy
|
||||
- `FlagStrategy` — interface implemented by all targeting strategies
|
||||
- `FlagProvider` — interface implemented by all data-source adapters
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
FlagDefinition,
|
||||
EvaluationContext,
|
||||
FlagValue,
|
||||
} from '@suaveplan/feature-flags';
|
||||
|
||||
const flag: FlagDefinition<boolean> = {
|
||||
key: 'dark-mode',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
description: 'Enable dark mode UI',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const ctx: EvaluationContext = {
|
||||
userId: 'user-42',
|
||||
attributes: { plan: 'pro' },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { FlagStrategy, StrategyEvaluation, FlagDefinition, EvaluationContext } from '@suaveplan/feature-flags';
|
||||
|
||||
// Implement a custom strategy using the FlagStrategy interface
|
||||
class GroupStrategy implements FlagStrategy {
|
||||
readonly name = 'group-strategy';
|
||||
|
||||
constructor(private readonly allowedGroups: string[], private readonly value: FlagValue) {}
|
||||
|
||||
evaluate(flag: FlagDefinition, context: EvaluationContext): StrategyEvaluation {
|
||||
const matched = (context.groups ?? []).some(g => this.allowedGroups.includes(g));
|
||||
return { matched, value: matched ? this.value : undefined, strategyName: this.name };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FlagValue`
|
||||
|
||||
`boolean | string | number | Record<string, unknown>` — the set of allowed flag values.
|
||||
|
||||
### `FlagType`
|
||||
|
||||
`'boolean' | 'string' | 'number' | 'json'` — discriminant tag; must match the runtime type of `FlagDefinition.defaultValue`.
|
||||
|
||||
### `FlagDefinition<T extends FlagValue>`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------------|------------|----------|----------------------------------------------------------|
|
||||
| `key` | `string` | Yes | Unique identifier used for all lookups |
|
||||
| `type` | `FlagType` | Yes | Runtime type tag |
|
||||
| `defaultValue` | `T` | Yes | Value when no strategy matches or flag is disabled |
|
||||
| `description` | `string` | No | Human-readable description |
|
||||
| `tags` | `string[]` | No | Organisational tags |
|
||||
| `enabled` | `boolean` | No | Kill-switch; `false` bypasses all strategies |
|
||||
|
||||
### `EvaluationContext`
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|------------------------------------------|--------------------------------------------------|
|
||||
| `userId` | `string` | Primary user identifier for bucketing |
|
||||
| `email` | `string` | Email for rule matching |
|
||||
| `groups` | `string[]` | Group memberships |
|
||||
| `attributes` | `Record<string, string \| number \| boolean>` | Arbitrary attributes for rule evaluation |
|
||||
| `timestamp` | `number` | Evaluation time in ms since epoch (default now) |
|
||||
|
||||
### `FlagEvaluationResult<T>`
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|----------|----------------------------------------------|
|
||||
| `value` | `T` | Resolved flag value |
|
||||
| `reason` | enum | `DEFAULT`, `DISABLED`, `STRATEGY`, `OVERRIDE`, `ERROR` |
|
||||
| `strategyName` | `string` | Present when `reason === 'STRATEGY'` |
|
||||
| `flagKey` | `string` | The evaluated flag key |
|
||||
|
||||
### `FlagStrategy`
|
||||
|
||||
Interface with `readonly name: string` and `evaluate(flag, context): StrategyEvaluation`.
|
||||
|
||||
### `FlagProvider`
|
||||
|
||||
Interface with `getFlag`, `getAllFlags`, `setFlag`, `deleteFlag`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Types-only module — no runtime code is emitted. All interfaces use structural typing to remain framework-agnostic. `FlagDefinition<T>` is generic to enable type-safe `FlagManager.isEnabled()`, `getString()`, etc. without runtime coercion. The `enabled` field defaults to `true` when absent; the evaluator checks `flag.enabled !== false` rather than `flag.enabled === true` to preserve backward compatibility with flags defined before the field was added.
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Primitive scalar or object value that a feature flag can hold.
|
||||
*
|
||||
* The four allowed shapes map 1-to-1 with {@link FlagType}:
|
||||
* - `boolean` — kill-switch / on-off toggles
|
||||
* - `string` — variant labels, theme names, experiment arms
|
||||
* - `number` — numeric thresholds, version identifiers
|
||||
* - `Record<string, unknown>` — arbitrary JSON payloads
|
||||
*/
|
||||
export type FlagValue = boolean | string | number | Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Discriminated type tag for a {@link FlagDefinition}.
|
||||
* Used by providers and the evaluator to validate and coerce raw values.
|
||||
*/
|
||||
export type FlagType = "boolean" | "string" | "number" | "json";
|
||||
|
||||
/**
|
||||
* Full definition of a single feature flag as stored in the registry.
|
||||
*
|
||||
* @typeParam T - Narrows the `defaultValue` type for type-safe access.
|
||||
*/
|
||||
export interface FlagDefinition<T extends FlagValue = FlagValue> {
|
||||
/** Unique identifier for this flag, used as the lookup key in all APIs. */
|
||||
key: string;
|
||||
/** Runtime type of the flag value. */
|
||||
type: FlagType;
|
||||
/** Value returned when no strategy matches and no override is present. */
|
||||
defaultValue: T;
|
||||
/** Human-readable description for tooling and documentation. */
|
||||
description?: string;
|
||||
/** Organisational tags for grouping and filtering in dashboards. */
|
||||
tags?: string[];
|
||||
/**
|
||||
* Master kill-switch. When `false` the evaluator always returns
|
||||
* `defaultValue` with reason `'DISABLED'`, bypassing all strategies.
|
||||
* Defaults to `true` when omitted.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime context supplied by the caller when evaluating a flag.
|
||||
*
|
||||
* All fields are optional; strategies must gracefully handle absent context.
|
||||
*/
|
||||
export interface EvaluationContext {
|
||||
/** Primary user identifier used for percentage and user-target strategies. */
|
||||
userId?: string;
|
||||
/** Email address, available as an attribute value for rule matching. */
|
||||
email?: string;
|
||||
/** Group memberships, available for attribute-based targeting. */
|
||||
groups?: string[];
|
||||
/**
|
||||
* Arbitrary key-value attributes for {@link AttributeStrategy} rules.
|
||||
* Values may be strings, numbers, or booleans.
|
||||
*/
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
/**
|
||||
* Evaluation timestamp in milliseconds since the Unix epoch.
|
||||
* Defaults to `Date.now()` when omitted; used by {@link ScheduleStrategy}.
|
||||
*/
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full result returned by the evaluator for a single flag evaluation.
|
||||
*
|
||||
* @typeParam T - Narrows the resolved `value` type.
|
||||
*/
|
||||
export interface FlagEvaluationResult<T extends FlagValue = FlagValue> {
|
||||
/** The resolved flag value. */
|
||||
value: T;
|
||||
/**
|
||||
* Explains why this value was chosen:
|
||||
* - `'DEFAULT'` — no strategy matched; flag default was used
|
||||
* - `'DISABLED'` — `flag.enabled === false`; default was used
|
||||
* - `'STRATEGY'` — a strategy matched and provided the value
|
||||
* - `'OVERRIDE'` — an in-process override was active
|
||||
* - `'ERROR'` — evaluation threw; default was used
|
||||
*/
|
||||
reason: "DEFAULT" | "DISABLED" | "STRATEGY" | "OVERRIDE" | "ERROR";
|
||||
/** Name of the strategy that matched, present when `reason === 'STRATEGY'`. */
|
||||
strategyName?: string;
|
||||
/** The flag key that was evaluated. */
|
||||
flagKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intermediate result returned from a single {@link FlagStrategy} evaluation.
|
||||
*/
|
||||
export interface StrategyEvaluation {
|
||||
/** Whether this strategy matched the current context. */
|
||||
matched: boolean;
|
||||
/** Value to use when `matched` is `true`. */
|
||||
value?: FlagValue;
|
||||
/** Identifies the strategy for audit and debugging. */
|
||||
strategyName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy interface: each implementation encapsulates one targeting rule.
|
||||
*
|
||||
* Strategies are stateless with respect to the flag definition and context —
|
||||
* all decisions are derived from the arguments passed to `evaluate`.
|
||||
*/
|
||||
export interface FlagStrategy {
|
||||
/** Human-readable name used in evaluation results and logs. */
|
||||
readonly name: string;
|
||||
/**
|
||||
* Evaluate whether this strategy matches the given context.
|
||||
*
|
||||
* @param flag - The flag definition being evaluated.
|
||||
* @param context - Runtime context for the current request.
|
||||
* @returns A {@link StrategyEvaluation} with `matched` and an optional value.
|
||||
*/
|
||||
evaluate(
|
||||
flag: FlagDefinition,
|
||||
context: EvaluationContext
|
||||
): StrategyEvaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract for a feature-flag data source.
|
||||
*
|
||||
* Providers are responsible for storing and retrieving {@link FlagDefinition}
|
||||
* objects. The {@link FlagRegistry} delegates all persistence to a provider.
|
||||
*/
|
||||
export interface FlagProvider {
|
||||
/** Retrieve a single flag by key, or `undefined` if not found. */
|
||||
getFlag(key: string): FlagDefinition | undefined;
|
||||
/** Return all flags currently held by this provider. */
|
||||
getAllFlags(): FlagDefinition[];
|
||||
/** Insert or replace a flag. */
|
||||
setFlag(key: string, flag: FlagDefinition): void;
|
||||
/** Remove a flag by key. */
|
||||
deleteFlag(key: string): void;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
EvaluationContext,
|
||||
FlagDefinition,
|
||||
FlagEvaluationResult,
|
||||
FlagProvider,
|
||||
FlagStrategy,
|
||||
FlagType,
|
||||
FlagValue,
|
||||
StrategyEvaluation,
|
||||
} from "./common";
|
||||
@@ -0,0 +1,84 @@
|
||||
# hash
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides deterministic, non-cryptographic hashing utilities for feature-flag bucketing. The core goal is to ensure that a given `(flagKey, userId)` pair always maps to the same percentage bucket across every evaluation, every deployment, and every process restart — without requiring any external state.
|
||||
|
||||
The FNV-1a 32-bit algorithm is used for its excellent distribution characteristics, minimal code footprint, and zero external dependencies. It is deliberately not a cryptographic hash; speed and determinism are the requirements, not collision resistance.
|
||||
|
||||
## Features
|
||||
|
||||
- `fnv1a32` — pure FNV-1a 32-bit hash over a UTF-16 string; returns an unsigned 32-bit integer
|
||||
- `combineKeys` — null-byte-delimited key combiner that prevents cross-position collisions
|
||||
- `hashToPercentage` — maps `(flagKey, userId)` to a float in `[0, 100)`, ensuring different flags bucket the same user independently
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hashToPercentage } from '@suaveplan/feature-flags';
|
||||
|
||||
const bucket = hashToPercentage('checkout-v2', 'user-42');
|
||||
// → e.g. 73.21 (always the same for these inputs)
|
||||
|
||||
if (bucket < 10) {
|
||||
// user is in the 10% treatment group for 'checkout-v2'
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fnv1a32, combineKeys, hashToPercentage } from '@suaveplan/feature-flags';
|
||||
|
||||
// Combine three segments for a multi-dimensional bucket
|
||||
const composite = combineKeys('flag-a', 'user-99', 'region-eu');
|
||||
const hash = fnv1a32(composite);
|
||||
|
||||
// Custom percentage mapping with a different divisor
|
||||
const pct = (hash / 4294967296) * 100;
|
||||
|
||||
// Verify the bucket is stable
|
||||
console.assert(hashToPercentage('flag-a', 'user-99') === hashToPercentage('flag-a', 'user-99'));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `fnv1a32(input: string): number`
|
||||
|
||||
Computes the FNV-1a 32-bit hash of `input`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `input` — UTF-16 string to hash.
|
||||
|
||||
**Returns:** Unsigned 32-bit integer (range `[0, 4294967295]`).
|
||||
|
||||
### `combineKeys(...keys: string[]): string`
|
||||
|
||||
Joins all `keys` with a null byte (`\0`) separator.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `...keys` — any number of string segments.
|
||||
|
||||
**Returns:** A single combined string.
|
||||
|
||||
### `hashToPercentage(flagKey: string, userId: string): number`
|
||||
|
||||
Maps a `(flagKey, userId)` pair to a float in `[0, 100)`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `flagKey` — the feature flag key (used to differentiate buckets per flag).
|
||||
- `userId` — the user or entity identifier.
|
||||
|
||||
**Returns:** Float in `[0, 100)` — inclusive lower bound, exclusive upper bound.
|
||||
|
||||
**Throws:** Never. Empty strings are handled gracefully (produce a deterministic result).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The null-byte separator in `combineKeys` is critical: without it, `('ab', 'c')` and `('a', 'bc')` would produce identical combined strings and identical buckets. The divisor `4294967296` is `2^32`, mapping the full `uint32` range to `[0, 1)` before scaling to `[0, 100)`. The upper bound of `100` is never reached because `hash` is at most `4294967295`, so `(4294967295 / 4294967296) * 100 ≈ 99.9999...`. Distribution uniformity is guaranteed by FNV-1a's avalanche characteristics; property tests verify < 5% deviation from uniform at 10 000 samples.
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { combineKeys, fnv1a32, hashToPercentage } from "./hash.js";
|
||||
|
||||
describe("fnv1a32", () => {
|
||||
it("returns a non-negative integer", { timeout: 5000 }, () => {
|
||||
const result = fnv1a32("hello");
|
||||
expect(typeof result).toBe("number");
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThanOrEqual(4294967295);
|
||||
});
|
||||
|
||||
it("is deterministic", { timeout: 5000 }, () => {
|
||||
expect(fnv1a32("test-input")).toBe(fnv1a32("test-input"));
|
||||
});
|
||||
|
||||
it("produces different values for different inputs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(fnv1a32("alpha")).not.toBe(fnv1a32("beta"));
|
||||
});
|
||||
|
||||
it("handles empty string", { timeout: 5000 }, () => {
|
||||
const result = fnv1a32("");
|
||||
expect(typeof result).toBe("number");
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combineKeys", () => {
|
||||
it("joins keys with null byte", { timeout: 5000 }, () => {
|
||||
expect(combineKeys("a", "b")).toBe("a\0b");
|
||||
});
|
||||
|
||||
it("handles a single key", { timeout: 5000 }, () => {
|
||||
expect(combineKeys("only")).toBe("only");
|
||||
});
|
||||
|
||||
it("prevents cross-position collision", { timeout: 5000 }, () => {
|
||||
expect(combineKeys("ab", "c")).not.toBe(combineKeys("a", "bc"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashToPercentage", () => {
|
||||
it("returns a number in [0, 100)", { timeout: 5000 }, () => {
|
||||
const result = hashToPercentage("flag-key", "user-id");
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("is deterministic for same inputs", { timeout: 5000 }, () => {
|
||||
const a = hashToPercentage("my-flag", "user-42");
|
||||
const b = hashToPercentage("my-flag", "user-42");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("produces different values for different flag keys (same user)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = hashToPercentage("flag-a", "user-1");
|
||||
const b = hashToPercentage("flag-b", "user-1");
|
||||
// Very unlikely to collide; FNV-1a has good avalanche
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("handles empty strings without throwing", { timeout: 5000 }, () => {
|
||||
const result = hashToPercentage("", "");
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("property: always in [0, 100) for any string inputs", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), fc.string(), (flagKey, userId) => {
|
||||
const pct = hashToPercentage(flagKey, userId);
|
||||
return pct >= 0 && pct < 100;
|
||||
}),
|
||||
{ numRuns: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it("property: same inputs always produce same output (determinism)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string(),
|
||||
fc.string(),
|
||||
fc.string(),
|
||||
(flagKey, userId, _salt) => {
|
||||
const first = hashToPercentage(flagKey, userId);
|
||||
const second = hashToPercentage(flagKey, userId);
|
||||
return first === second;
|
||||
}
|
||||
),
|
||||
{ numRuns: 500 }
|
||||
);
|
||||
});
|
||||
|
||||
it("uniform distribution: each bucket within 25% of expected for 10k samples", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const BUCKETS = 10;
|
||||
const SAMPLES = 10000;
|
||||
const counts = new Array<number>(BUCKETS).fill(0);
|
||||
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
const pct = hashToPercentage("test-flag", `user-${i}`);
|
||||
const bucket = Math.floor(pct / (100 / BUCKETS));
|
||||
const clamped = Math.min(bucket, BUCKETS - 1);
|
||||
counts[clamped]++;
|
||||
}
|
||||
|
||||
const expected = SAMPLES / BUCKETS;
|
||||
// FNV-1a distributes uniformly across random inputs; sequential
|
||||
// numeric strings may show up to ~25% deviation per bucket.
|
||||
const tolerance = expected * 0.25;
|
||||
|
||||
for (const count of counts) {
|
||||
expect(count).toBeGreaterThanOrEqual(expected - tolerance);
|
||||
expect(count).toBeLessThanOrEqual(expected + tolerance);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* FNV-1a 32-bit hash for deterministic bucketing.
|
||||
*
|
||||
* This is the non-cryptographic Fowler–Noll–Vo hash variant that XORs before
|
||||
* multiplying (FNV-1a), chosen for its excellent distribution characteristics
|
||||
* at very low computational cost.
|
||||
*
|
||||
* @param input - UTF-16 string to hash.
|
||||
* @returns Unsigned 32-bit integer hash.
|
||||
*/
|
||||
export function fnv1a32(input: string): number {
|
||||
// FNV offset basis for 32-bit
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
// FNV prime for 32-bit: 16777619
|
||||
// Multiply with 32-bit wrapping using bitwise trick
|
||||
hash = (hash * 16777619) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine multiple string keys into a single deterministic string.
|
||||
*
|
||||
* Joins the provided keys with a null byte separator to avoid collisions
|
||||
* between adjacent key combinations (e.g., `['ab', 'c']` vs `['a', 'bc']`).
|
||||
*
|
||||
* @param keys - One or more string segments to combine.
|
||||
* @returns A single string suitable for hashing.
|
||||
*/
|
||||
export function combineKeys(...keys: string[]): string {
|
||||
return keys.join("\0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a flag key + user identifier to a deterministic percentage bucket.
|
||||
*
|
||||
* Combines `flagKey` and `userId` with a null-byte separator before hashing
|
||||
* so that the same `userId` maps to different buckets for different flags.
|
||||
* This prevents correlated roll-outs where the same cohort of users would
|
||||
* always be in the treatment group for every percentage-based flag.
|
||||
*
|
||||
* @param flagKey - The feature flag identifier.
|
||||
* @param userId - The user (or entity) identifier to bucket.
|
||||
* @returns A float in `[0, 100)` — exclusive upper bound.
|
||||
*/
|
||||
export function hashToPercentage(flagKey: string, userId: string): number {
|
||||
const combined = combineKeys(flagKey, userId);
|
||||
const hash = fnv1a32(combined);
|
||||
// Map uint32 [0, 4294967295] → [0, 100)
|
||||
return (hash / 4294967296) * 100;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { combineKeys, fnv1a32, hashToPercentage } from "./hash";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are exactly the declared set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual([
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/types",
|
||||
]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 7 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/feature-flags",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user