Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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 -->

View File

@@ -0,0 +1 @@
Feature-flag evaluation engine with percentage and attribute targeting.

File diff suppressed because one or more lines are too long

View 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"
]
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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
}
}
}

View File

@@ -0,0 +1 @@
export { Evaluator, type EvaluatorOptions } from "./evaluator";

View File

@@ -0,0 +1,3 @@
export { Evaluator, type EvaluatorOptions } from "./evaluator";
export { FlagManager, type FlagManagerOptions } from "./manager";
export { FlagRegistry, type FlagRegistryOptions } from "./registry";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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();
}
}

View File

@@ -0,0 +1 @@
export { FlagManager, type FlagManagerOptions } from "./flag-manager";

View File

@@ -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).

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
export { FlagRegistry, type FlagRegistryOptions } from "./flag-registry";

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
export {
FEATURE_FLAGS_CODES,
type FeatureFlagsCode,
FeatureFlagsError,
} from "./feature-flags-error";

View 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";

View File

@@ -0,0 +1,2 @@
export { JsonProvider } from "./json/json.js";
export { MemoryProvider } from "./memory/memory.js";

View File

@@ -0,0 +1 @@
export { JsonProvider } from "./json";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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
}
}

View File

@@ -0,0 +1 @@
export { MemoryProvider } from "./memory";

View File

@@ -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.

View 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);
});
});
});

View File

@@ -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 });
}
}
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,8 @@
export {
type AttributeOperator,
type AttributeRule,
AttributeStrategy,
type AttributeStrategyOptions,
compareValues,
getNestedValue,
} from "./attribute";

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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 };
}
}

View File

@@ -0,0 +1 @@
export { CompositeStrategy, type CompositeStrategyOptions } from "./composite";

View File

@@ -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";

View File

@@ -0,0 +1 @@
export { type PercentageOptions, PercentageStrategy } from "./percentage";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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 };
}
}

View File

@@ -0,0 +1 @@
export { type ScheduleOptions, ScheduleStrategy } from "./schedule";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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 };
}
}

View File

@@ -0,0 +1 @@
export { type UserTargetOptions, UserTargetStrategy } from "./user-target";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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 };
}
}

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
export type {
EvaluationContext,
FlagDefinition,
FlagEvaluationResult,
FlagProvider,
FlagStrategy,
FlagType,
FlagValue,
StrategyEvaluation,
} from "./common";

View File

@@ -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.

View File

@@ -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);
}
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export { combineKeys, fnv1a32, hashToPercentage } from "./hash";

View File

@@ -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",
]);
});
});

View File

@@ -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([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -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);
});
});

View File

@@ -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"]
}

View File

@@ -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:/,
],
},
},
});

View File

@@ -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,
},
});