first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

File diff suppressed because one or more lines are too long

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