first commit
This commit is contained in:
96
docs/foundation/feature-flags/feature-flags/README.md
Normal file
96
docs/foundation/feature-flags/feature-flags/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
|
||||
# evaluator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `Evaluator` is the core computation engine of `@suaveplan/feature-flags`. It takes a flag definition, an evaluation context, and an optional override map, then runs a prioritised evaluation pipeline to produce a `FlagEvaluationResult`. All flag access goes through this class, ensuring consistent behaviour regardless of which strategy or provider is in use.
|
||||
|
||||
The evaluator is designed to be observable (OTel counter), debuggable (optional `LoggerLike`), and fault-tolerant (errors in strategies are caught and surfaced as `ERROR` results rather than crashing the caller).
|
||||
|
||||
## Features
|
||||
|
||||
- Four-tier priority pipeline: DISABLED → OVERRIDE → STRATEGY → DEFAULT
|
||||
- Strategy chain with first-match-wins semantics
|
||||
- OTel counter `feature_flags.evaluate.total` with `flag_key` and `reason` attributes
|
||||
- Fault isolation: errors in strategies are wrapped in `FeatureFlagsError(STRATEGY_ERROR)` and caught at the top level, returning the default value with reason `ERROR`
|
||||
- Structured logging via optional `LoggerLike` for debug traces
|
||||
- Zero global state — safe for concurrent use
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Evaluator } from '@suaveplan/feature-flags/evaluator';
|
||||
import { PercentageStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const evaluator = new Evaluator({
|
||||
strategies: [new PercentageStrategy({ percentage: 20, value: true })],
|
||||
});
|
||||
|
||||
const result = evaluator.evaluate(
|
||||
{ key: 'feature-x', type: 'boolean', defaultValue: false },
|
||||
{ userId: 'user-42' },
|
||||
);
|
||||
// result.reason === 'STRATEGY' or 'DEFAULT'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Evaluator } from '@suaveplan/feature-flags/evaluator';
|
||||
|
||||
const evaluator = new Evaluator({ strategies: [] });
|
||||
|
||||
// In-process override bypasses all strategies
|
||||
const result = evaluator.evaluate(
|
||||
{ key: 'feature-x', type: 'boolean', defaultValue: false },
|
||||
{},
|
||||
{ 'feature-x': true },
|
||||
);
|
||||
// result.reason === 'OVERRIDE', result.value === true
|
||||
|
||||
// Disabled flag ignores overrides
|
||||
const disabled = evaluator.evaluate(
|
||||
{ key: 'feature-y', type: 'boolean', defaultValue: false, enabled: false },
|
||||
{},
|
||||
{ 'feature-y': true },
|
||||
);
|
||||
// disabled.reason === 'DISABLED', disabled.value === false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EvaluatorOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|------------------|----------|------------------------------------|
|
||||
| `strategies` | `FlagStrategy[]` | No | Ordered strategy chain |
|
||||
| `logger` | `LoggerLike` | No | Structured logger for eval traces |
|
||||
|
||||
### `class Evaluator`
|
||||
|
||||
**Constructor:** `new Evaluator(options?: EvaluatorOptions)`
|
||||
|
||||
**`evaluate<T>(flag, context, overrides?): FlagEvaluationResult<T>`**
|
||||
|
||||
- `flag` — flag definition to evaluate
|
||||
- `context` — runtime evaluation context
|
||||
- `overrides` — optional `Record<string, FlagValue>` for in-process overrides
|
||||
- Returns `FlagEvaluationResult<T>` — never throws (errors produce `reason: 'ERROR'`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
OTel initialisation uses a `require()` call wrapped in try/catch so that the package can be imported in environments where `@opentelemetry/api` is installed but no meter provider is registered — in that case, the SDK's no-op meter is used. The `require()` pattern avoids ESM top-level await and ensures the meter is created only once per instance. Strategy errors are deliberately re-thrown as `FeatureFlagsError(STRATEGY_ERROR)` before being caught at the top level, so that the error code and cause chain are preserved in structured logs. The DISABLED check precedes the override check — a flag with `enabled: false` cannot be overridden, which prevents accidental re-enablement of a kill-switched flag via an in-process override.
|
||||
@@ -0,0 +1,86 @@
|
||||
# flag-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`FlagManager` is the primary entry point for feature flag consumption. It provides a high-level, type-safe API that abstracts the registry lookup, context merging, override management, and evaluation into a single object. Application code interacts only with the manager, which orchestrates the registry and evaluator underneath.
|
||||
|
||||
This façade pattern lets the registry and evaluator be swapped (e.g., switching providers between environments) without changing any calling code.
|
||||
|
||||
## Features
|
||||
|
||||
- `isEnabled`, `getString`, `getNumber`, `getJson` — typed convenience accessors
|
||||
- `evaluate<T>` — full evaluation result for callers that need the reason and strategy name
|
||||
- In-process override management: `setOverride`, `clearOverride`, `clearAllOverrides`
|
||||
- Default context merged shallowly with per-call context on every evaluation
|
||||
- Throws `FeatureFlagsError(FLAG_NOT_FOUND)` for unknown flag keys
|
||||
- Optional `LoggerLike` for structured evaluation traces
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FlagManager } from '@suaveplan/feature-flags/manager';
|
||||
import { FlagRegistry } from '@suaveplan/feature-flags/registry';
|
||||
import { Evaluator } from '@suaveplan/feature-flags/evaluator';
|
||||
import { MemoryProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const provider = new MemoryProvider([
|
||||
{ key: 'dark-mode', type: 'boolean', defaultValue: false },
|
||||
]);
|
||||
const registry = new FlagRegistry({ provider });
|
||||
const evaluator = new Evaluator();
|
||||
const manager = new FlagManager({ registry, evaluator });
|
||||
|
||||
manager.isEnabled('dark-mode', { userId: 'u-1' }); // false (default)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FlagManager } from '@suaveplan/feature-flags/manager';
|
||||
|
||||
// Set a default context applied to every evaluation
|
||||
const manager = new FlagManager({
|
||||
registry,
|
||||
evaluator,
|
||||
defaultContext: { attributes: { env: 'staging' } },
|
||||
});
|
||||
|
||||
// Override a flag for testing
|
||||
manager.setOverride('dark-mode', true);
|
||||
manager.isEnabled('dark-mode'); // true
|
||||
manager.clearOverride('dark-mode');
|
||||
manager.isEnabled('dark-mode'); // false (back to evaluation)
|
||||
|
||||
// Full result with reason
|
||||
const result = manager.evaluate<boolean>('dark-mode', { userId: 'u-2' });
|
||||
console.log(result.reason, result.value);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FlagManagerOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------------------|----------|------------------------------------------|
|
||||
| `registry` | `FlagRegistry` | Yes | Source of flag definitions |
|
||||
| `evaluator` | `Evaluator` | Yes | Evaluation engine |
|
||||
| `logger` | `LoggerLike` | No | Structured logger |
|
||||
| `defaultContext` | `EvaluationContext` | No | Context merged into every evaluation |
|
||||
|
||||
### `class FlagManager`
|
||||
|
||||
**`evaluate<T>(key, context?): FlagEvaluationResult<T>`** — full result
|
||||
**`isEnabled(key, context?): boolean`** — typed boolean accessor
|
||||
**`getString(key, context?): string`** — typed string accessor
|
||||
**`getNumber(key, context?): number`** — typed number accessor
|
||||
**`getJson<T>(key, context?): T`** — typed JSON accessor
|
||||
**`setOverride(key, value): void`** — set in-process override
|
||||
**`clearOverride(key): void`** — remove one override
|
||||
**`clearAllOverrides(): void`** — remove all overrides
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Context merging is shallow: the top-level `EvaluationContext` fields are spread with per-call values taking precedence, but `attributes` is merged one level deeper so that per-call attributes overlay (not replace) default attributes. Overrides are stored in a `Map<string, FlagValue>` and serialised to a plain `Record` before passing to the evaluator, which accepts records to remain agnostic of the manager's storage. The typed accessors (`isEnabled` etc.) do not validate the flag's declared `type` against the generic parameter — callers are expected to use the correct accessor for the flag's type; mismatches are surfaced as TypeScript compile errors rather than runtime errors.
|
||||
@@ -0,0 +1,85 @@
|
||||
# flag-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the central registry for feature flag definitions, acting as a typed façade over a `FlagProvider`. Consumers of `@suaveplan/feature-flags` register, retrieve, and remove flags through the registry rather than interacting with providers directly. This separation lets providers be swapped (memory, JSON, remote-config) without changing any consumer code.
|
||||
|
||||
The registry validates flag definitions at registration time to catch configuration errors early, rather than allowing malformed flags to propagate silently to the evaluator.
|
||||
|
||||
## Features
|
||||
|
||||
- Register and replace flags via a validated `register(flag)` API
|
||||
- Remove flags via `unregister(key)`
|
||||
- Type-safe `get(key)` returning `FlagDefinition | undefined`
|
||||
- Bulk `getAll()` for dashboard and tooling integrations
|
||||
- `has(key)` existence check
|
||||
- Throws `FeatureFlagsError(INVALID_FLAG)` for empty keys
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FlagRegistry } from '@suaveplan/feature-flags/registry';
|
||||
import { MemoryProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const registry = new FlagRegistry({ provider: new MemoryProvider() });
|
||||
|
||||
registry.register({ key: 'dark-mode', type: 'boolean', defaultValue: false });
|
||||
registry.has('dark-mode'); // true
|
||||
registry.get('dark-mode'); // { key: 'dark-mode', ... }
|
||||
registry.unregister('dark-mode');
|
||||
registry.has('dark-mode'); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FlagRegistry } from '@suaveplan/feature-flags/registry';
|
||||
import { JsonProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const provider = new JsonProvider([
|
||||
{ key: 'checkout-v2', type: 'boolean', defaultValue: false },
|
||||
{ key: 'max-items', type: 'number', defaultValue: 50 },
|
||||
]);
|
||||
|
||||
const registry = new FlagRegistry({ provider });
|
||||
|
||||
// Iterate all flags for a management UI
|
||||
for (const flag of registry.getAll()) {
|
||||
console.log(flag.key, flag.defaultValue);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FlagRegistryOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------|----------------|----------|-------------------------------|
|
||||
| `provider` | `FlagProvider` | Yes | Backing data source |
|
||||
|
||||
### `class FlagRegistry`
|
||||
|
||||
**Constructor:** `new FlagRegistry(options: FlagRegistryOptions)`
|
||||
|
||||
**`register(flag: FlagDefinition): void`**
|
||||
Validates that `flag.key` is non-empty, then delegates to `provider.setFlag`.
|
||||
Throws `FeatureFlagsError` with code `INVALID_FLAG` when key is empty or whitespace.
|
||||
|
||||
**`unregister(key: string): void`**
|
||||
Delegates to `provider.deleteFlag`. Silent no-op when key is absent.
|
||||
|
||||
**`get(key: string): FlagDefinition | undefined`**
|
||||
Returns the flag or `undefined`. Does not throw.
|
||||
|
||||
**`getAll(): FlagDefinition[]`**
|
||||
Returns the full list from the provider.
|
||||
|
||||
**`has(key: string): boolean`**
|
||||
Returns `true` when the provider returns a defined flag for `key`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Validation is intentionally minimal at the registry level — full schema validation (Zod) can be added in a `registerValidated` wrapper without modifying this class. The `has` method delegates to `getFlag` rather than storing a separate key set, ensuring it remains in sync with the provider state without duplication. Providers are responsible for the actual storage semantics (in-memory, persistent, remote).
|
||||
@@ -0,0 +1,81 @@
|
||||
# feature-flags-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the error infrastructure for `@suaveplan/feature-flags`. All errors thrown by this package are instances of `FeatureFlagsError`, which extends the project-wide `SuaveplanError` base class. Centralising errors in a single module ensures that callers can use narrow `instanceof` checks while still catching the broader `SuaveplanError` base when needed, and that every failure carries a stable, machine-readable code from `FEATURE_FLAGS_CODES`.
|
||||
|
||||
The stable code registry enables structured logging, alerting rules, and client-side error handling that survives message-text changes without breaking.
|
||||
|
||||
## Features
|
||||
|
||||
- `FEATURE_FLAGS_CODES` — stable code registry covering five failure domains: `INVALID_FLAG`, `PROVIDER_ERROR`, `EVALUATION_FAILED`, `STRATEGY_ERROR`, `FLAG_NOT_FOUND`
|
||||
- `FeatureFlagsCode` — type alias for the union of all code strings
|
||||
- `FeatureFlagsError` — typed error class extending `SuaveplanError` with a customised `name` property
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FeatureFlagsError, FEATURE_FLAGS_CODES } from '@suaveplan/feature-flags/errors';
|
||||
|
||||
throw new FeatureFlagsError(
|
||||
'Flag "checkout-v2" not found in registry',
|
||||
FEATURE_FLAGS_CODES.FLAG_NOT_FOUND,
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FeatureFlagsError, FEATURE_FLAGS_CODES } from '@suaveplan/feature-flags/errors';
|
||||
|
||||
function wrapProviderCall<T>(fn: () => T): T {
|
||||
try {
|
||||
return fn();
|
||||
} catch (err) {
|
||||
throw new FeatureFlagsError(
|
||||
'Provider threw during flag retrieval',
|
||||
FEATURE_FLAGS_CODES.PROVIDER_ERROR,
|
||||
{ cause: err instanceof Error ? err : new Error(String(err)) },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FEATURE_FLAGS_CODES`
|
||||
|
||||
`Record<string, string>` — object produced by `defineCodes('FEATURE_FLAGS', ...)`.
|
||||
|
||||
| Key | Code string |
|
||||
|---------------------|----------------------------------------|
|
||||
| `INVALID_FLAG` | `'FEATURE_FLAGS.INVALID_FLAG'` |
|
||||
| `PROVIDER_ERROR` | `'FEATURE_FLAGS.PROVIDER_ERROR'` |
|
||||
| `EVALUATION_FAILED` | `'FEATURE_FLAGS.EVALUATION_FAILED'` |
|
||||
| `STRATEGY_ERROR` | `'FEATURE_FLAGS.STRATEGY_ERROR'` |
|
||||
| `FLAG_NOT_FOUND` | `'FEATURE_FLAGS.FLAG_NOT_FOUND'` |
|
||||
|
||||
### `FeatureFlagsCode`
|
||||
|
||||
`type FeatureFlagsCode = (typeof FEATURE_FLAGS_CODES)[keyof typeof FEATURE_FLAGS_CODES]`
|
||||
|
||||
### `FeatureFlagsError`
|
||||
|
||||
```ts
|
||||
class FeatureFlagsError extends SuaveplanError {
|
||||
constructor(message: string, code: string, options?: { cause?: Error });
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `message` — human-readable description of the failure
|
||||
- `code` — stable error code, typically from `FEATURE_FLAGS_CODES`
|
||||
- `options.cause` — originating error for causal chain
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`FeatureFlagsError` explicitly sets `this.name = 'FeatureFlagsError'` so that stack traces and JSON serialisation display the subclass name rather than the base `SuaveplanError` name. The `code` parameter accepts `string` rather than the narrow `FeatureFlagsCode` union to allow callers to pass dynamic codes without casting, while still encouraging use of `FEATURE_FLAGS_CODES` constants in practice.
|
||||
@@ -0,0 +1,73 @@
|
||||
# json-provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements a read-only `FlagProvider` that is initialised from a static JSON array of flag definitions. It is intended for use cases where feature flags are shipped as part of the application bundle, loaded from a remote config server at startup, or provided via environment-specific JSON files.
|
||||
|
||||
Because the store is frozen at construction time, this provider guarantees that flag values cannot change during the application lifetime without a restart — making it suitable for SSR renderers, edge workers, and other environments where mutable shared state is undesirable.
|
||||
|
||||
## Features
|
||||
|
||||
- Read-only: `setFlag` and `deleteFlag` are intentional no-ops
|
||||
- O(1) flag lookup via internal `Map`
|
||||
- Supports any array of `FlagDefinition` objects, including those parsed from JSON
|
||||
- Zero I/O after construction — suitable for synchronous evaluation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { JsonProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const provider = new JsonProvider([
|
||||
{ key: 'dark-mode', type: 'boolean', defaultValue: false },
|
||||
{ key: 'checkout-variant', type: 'string', defaultValue: 'v1' },
|
||||
]);
|
||||
|
||||
provider.getFlag('dark-mode'); // { key: 'dark-mode', ... }
|
||||
provider.getAllFlags(); // array of both flags
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { JsonProvider } from '@suaveplan/feature-flags/providers';
|
||||
import type { FlagDefinition } from '@suaveplan/feature-flags';
|
||||
|
||||
// Type-annotate a JSON import for compile-time safety
|
||||
const rawFlags: FlagDefinition[] = [
|
||||
{ key: 'feature-x', type: 'boolean', defaultValue: false, enabled: true },
|
||||
];
|
||||
|
||||
const provider = new JsonProvider(rawFlags);
|
||||
|
||||
// setFlag and deleteFlag are no-ops on this provider
|
||||
provider.setFlag('feature-x', { key: 'feature-x', type: 'boolean', defaultValue: true });
|
||||
provider.getFlag('feature-x')?.defaultValue; // still false — immutable
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class JsonProvider implements FlagProvider`
|
||||
|
||||
**Constructor:** `new JsonProvider(config: FlagDefinition[])`
|
||||
|
||||
- `config` — array of flag definitions to load. Duplicated keys are resolved last-write-wins in iteration order.
|
||||
|
||||
**`getFlag(key: string): FlagDefinition | undefined`**
|
||||
Returns the flag for `key`, or `undefined`.
|
||||
|
||||
**`getAllFlags(): FlagDefinition[]`**
|
||||
Returns a snapshot array of all loaded flags.
|
||||
|
||||
**`setFlag(key, flag): void`**
|
||||
No-op. Read-only provider.
|
||||
|
||||
**`deleteFlag(key): void`**
|
||||
No-op. Read-only provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `Map` is typed as `ReadonlyMap` to signal immutability to TypeScript, though the concrete `Map` instance is referenced — reassignment of the `store` field is prevented by `readonly`. The `setFlag`/`deleteFlag` no-ops are documented with JSDoc `@param` tags to satisfy documentation-coverage checks. For a mutable alternative, use `MemoryProvider` initialised with the same flag array.
|
||||
@@ -0,0 +1,74 @@
|
||||
# memory-provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the `FlagProvider` interface using an in-memory `Map` as the backing store. It supports full CRUD operations and emits lifecycle events (`flag:created`, `flag:updated`, `flag:deleted`) via an optional `EventBusLike` when flags change. This provider is the primary choice for tests, local development, and runtime in-process overrides.
|
||||
|
||||
Because it holds all flags in memory, it is not suitable as a primary provider in distributed systems where multiple processes must stay in sync — use a database or remote-config provider for that case, seeding it with the same initial definitions.
|
||||
|
||||
## Features
|
||||
|
||||
- Full CRUD: `getFlag`, `getAllFlags`, `setFlag`, `deleteFlag`
|
||||
- Optional `EventBusLike` integration for reactive change propagation
|
||||
- O(1) get/set/delete via `Map`
|
||||
- Supports initial flag list at construction time
|
||||
- Zero I/O — suitable for synchronous evaluation hot paths
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryProvider } from '@suaveplan/feature-flags/providers';
|
||||
|
||||
const provider = new MemoryProvider([
|
||||
{ key: 'dark-mode', type: 'boolean', defaultValue: false },
|
||||
]);
|
||||
|
||||
provider.getFlag('dark-mode'); // FlagDefinition
|
||||
provider.getAllFlags(); // [{ key: 'dark-mode', ... }]
|
||||
provider.setFlag('new-flag', { key: 'new-flag', type: 'string', defaultValue: 'v1' });
|
||||
provider.deleteFlag('new-flag');
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryProvider } from '@suaveplan/feature-flags/providers';
|
||||
import { EventBus } from '@suaveplan/events';
|
||||
|
||||
const bus = new EventBus();
|
||||
bus.on('flag:created', ({ key }) => console.log(`Flag ${key} created`));
|
||||
bus.on('flag:updated', ({ key }) => console.log(`Flag ${key} updated`));
|
||||
bus.on('flag:deleted', ({ key }) => console.log(`Flag ${key} deleted`));
|
||||
|
||||
const provider = new MemoryProvider([], bus);
|
||||
provider.setFlag('feature-x', { key: 'feature-x', type: 'boolean', defaultValue: false });
|
||||
// → logs "Flag feature-x created"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MemoryProvider implements FlagProvider`
|
||||
|
||||
**Constructor:** `new MemoryProvider(initial?: FlagDefinition[], eventBus?: EventBusLike)`
|
||||
|
||||
- `initial` — optional list of flags to pre-populate the store
|
||||
- `eventBus` — optional event bus for change notifications
|
||||
|
||||
**`getFlag(key: string): FlagDefinition | undefined`**
|
||||
Returns the flag for `key`, or `undefined` if not found.
|
||||
|
||||
**`getAllFlags(): FlagDefinition[]`**
|
||||
Returns all flags as an array (insertion order, via `Map` iteration).
|
||||
|
||||
**`setFlag(key: string, flag: FlagDefinition): void`**
|
||||
Inserts or replaces the flag. Emits `flag:created` or `flag:updated` on the event bus.
|
||||
|
||||
**`deleteFlag(key: string): void`**
|
||||
Removes the flag. Emits `flag:deleted` if the flag existed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The event bus `emit` calls are fire-and-forget; the provider does not await them. The `isUpdate` check is performed before `set` by calling `store.has(key)` so that the correct event type is emitted even when the key is being replaced. The `EventBusLike` interface from `@suaveplan/types` is used rather than the concrete `EventBus` class to avoid a hard dependency on `@suaveplan/events` within this file.
|
||||
@@ -0,0 +1,92 @@
|
||||
# attribute-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements a flexible rule-based targeting strategy that evaluates attribute conditions against fields in `EvaluationContext.attributes`. It supports eleven comparison operators and AND/OR rule composition, making it the most expressive targeting primitive in the package. It covers use cases ranging from simple plan-tier targeting to complex multi-condition segments.
|
||||
|
||||
The strategy is stateless — all targeting logic is derived purely from the rule configuration and the evaluation context, with no side effects or external I/O.
|
||||
|
||||
## Features
|
||||
|
||||
- Eleven comparison operators: `eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `contains`, `startsWith`, `endsWith`
|
||||
- AND/OR rule composition for multi-condition targeting
|
||||
- Dot-notation attribute path resolution for nested objects
|
||||
- Exported `compareValues` and `getNestedValue` utilities for custom strategy composition
|
||||
- Graceful handling of missing attributes (returns `matched: false`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AttributeStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [{ attribute: 'plan', operator: 'eq', value: 'pro' }],
|
||||
logic: 'AND',
|
||||
value: true,
|
||||
});
|
||||
|
||||
strategy.evaluate(flag, { attributes: { plan: 'pro' } }); // matched: true
|
||||
strategy.evaluate(flag, { attributes: { plan: 'free' } }); // matched: false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AttributeStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
// Match users who are on a paid plan AND in a specific region
|
||||
const strategy = new AttributeStrategy({
|
||||
rules: [
|
||||
{ attribute: 'plan', operator: 'in', value: ['pro', 'enterprise'] },
|
||||
{ attribute: 'region', operator: 'startsWith', value: 'eu-' },
|
||||
],
|
||||
logic: 'AND',
|
||||
value: 'eu-checkout-v2',
|
||||
});
|
||||
|
||||
strategy.evaluate(flag, {
|
||||
attributes: { plan: 'pro', region: 'eu-west' },
|
||||
});
|
||||
// matched: true, value: 'eu-checkout-v2'
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AttributeOperator`
|
||||
|
||||
`'eq' | 'neq' | 'in' | 'nin' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'startsWith' | 'endsWith'`
|
||||
|
||||
### `AttributeRule`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|---------------------------------|-------------------------------------|
|
||||
| `attribute` | `string` | Dot-notation path into `attributes` |
|
||||
| `operator` | `AttributeOperator` | Comparison operator |
|
||||
| `value` | `string \| number \| boolean \| Array<...>` | Expected value |
|
||||
|
||||
### `AttributeStrategyOptions`
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---------|------------------|----------|---------|--------------------------------|
|
||||
| `rules` | `AttributeRule[]`| Yes | — | Rules to evaluate |
|
||||
| `logic` | `'AND' \| 'OR'` | No | `'AND'` | Rule combination mode |
|
||||
| `value` | `FlagValue` | Yes | — | Value when rules match |
|
||||
|
||||
### `getNestedValue(obj, path): unknown`
|
||||
|
||||
Traverses `obj` using `path` (dot-separated). Returns `undefined` if any segment is absent.
|
||||
|
||||
### `compareValues(actual, operator, expected): boolean`
|
||||
|
||||
Applies `operator` to `actual` vs `expected`. Returns `false` for type mismatches (e.g., `gt` on non-numbers).
|
||||
|
||||
### `class AttributeStrategy implements FlagStrategy`
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`** — runs all rules and combines with AND/OR logic.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `getNestedValue` function splits on `.` and iterates, returning `undefined` at any absent or non-object segment rather than throwing. `compareValues` performs type guards for numeric operators (`gt`, `gte`, `lt`, `lte`) so that string-to-number comparisons always return `false` rather than producing unexpected coercions. The `in`/`nin` operators require `expected` to be an array; a non-array `expected` always returns `false` for `in` and `false` for `nin` rather than throwing.
|
||||
@@ -0,0 +1,81 @@
|
||||
# composite-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a meta-strategy that combines two or more child strategies using AND or OR boolean logic. This enables arbitrarily complex targeting rules to be built by composing the simpler, single-concern strategy primitives. For example, a rule such as "user must be in the 20% rollout AND have the 'enterprise' plan" can be expressed by nesting a `PercentageStrategy` and an `AttributeStrategy` inside a `CompositeStrategy` with AND logic.
|
||||
|
||||
Composite strategies can themselves be nested to arbitrary depth, supporting expression trees of any complexity.
|
||||
|
||||
## Features
|
||||
|
||||
- AND logic — all child strategies must match (short-circuits on first non-match)
|
||||
- OR logic — at least one child strategy must match (short-circuits on first match)
|
||||
- Empty strategy list returns `matched: false` rather than throwing
|
||||
- Child strategies evaluated in declaration order
|
||||
- Nestable — a `CompositeStrategy` can be a child of another `CompositeStrategy`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CompositeStrategy, PercentageStrategy, UserTargetStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const betaUsers = new UserTargetStrategy({ users: ['alice'], value: true });
|
||||
const rollout = new PercentageStrategy({ percentage: 10, value: true });
|
||||
|
||||
const strategy = new CompositeStrategy({
|
||||
strategies: [betaUsers, rollout],
|
||||
logic: 'OR',
|
||||
value: true,
|
||||
});
|
||||
|
||||
// alice always matches; other users match if their bucket < 10%
|
||||
strategy.evaluate(flag, { userId: 'alice' }); // matched: true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CompositeStrategy, PercentageStrategy, AttributeStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const paidPlan = new AttributeStrategy({
|
||||
rules: [{ attribute: 'plan', operator: 'in', value: ['pro', 'enterprise'] }],
|
||||
logic: 'AND',
|
||||
value: true,
|
||||
});
|
||||
|
||||
const smallRollout = new PercentageStrategy({ percentage: 5, value: true });
|
||||
|
||||
// Only paid users who are in the 5% test group
|
||||
const targeted = new CompositeStrategy({
|
||||
strategies: [paidPlan, smallRollout],
|
||||
logic: 'AND',
|
||||
value: 'new-billing-ui',
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CompositeStrategyOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|------------------|----------|-----------------------------------|
|
||||
| `strategies` | `FlagStrategy[]` | Yes | Child strategies to evaluate |
|
||||
| `logic` | `'AND' \| 'OR'` | Yes | Rule combination mode |
|
||||
| `value` | `FlagValue` | Yes | Value returned on match |
|
||||
|
||||
### `class CompositeStrategy implements FlagStrategy`
|
||||
|
||||
**Constructor:** `new CompositeStrategy(options: CompositeStrategyOptions)`
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`**
|
||||
|
||||
- AND: uses `Array.every` — short-circuits on first non-match
|
||||
- OR: uses `Array.some` — short-circuits on first match
|
||||
- Empty `strategies` array returns `matched: false`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Short-circuit evaluation means that later strategies in an AND chain are not evaluated once a non-match is found, and later strategies in an OR chain are not evaluated once a match is found. This is important for performance when strategies involve expensive attribute lookups or when side-effects (e.g., OTel spans) should not be emitted for every strategy in the chain. The composite's own `value` overrides any child `value` fields — the child `value` is ignored; only `matched` is read from children.
|
||||
@@ -0,0 +1,69 @@
|
||||
# percentage-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements a deterministic percentage-rollout strategy for feature flags. Given a configured rollout percentage (0–100), the strategy uses a stable hash of the `(flagKey, userId)` pair to place each user into a consistent bucket. Users in the first N percent of the bucket space receive the flag value; all others do not match.
|
||||
|
||||
The determinism guarantee means that a user who is in the treatment group today will remain in the treatment group tomorrow — without any persistent state — until the flag definition or percentage changes.
|
||||
|
||||
## Features
|
||||
|
||||
- Deterministic bucketing via `hashToPercentage(flagKey, userId)` — no state required
|
||||
- Configurable `userKey` to bucket on any string field of `EvaluationContext` (default `userId`)
|
||||
- Returns the configured `value` when matched, `undefined` otherwise
|
||||
- Gracefully handles missing or non-string context values (returns `matched: false`)
|
||||
- Percentage `0` never matches; percentage `100` always matches
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PercentageStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const strategy = new PercentageStrategy({ percentage: 10, value: true });
|
||||
|
||||
const result = strategy.evaluate(flagDef, { userId: 'user-42' });
|
||||
if (result.matched) {
|
||||
console.log('User is in the 10% treatment group');
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PercentageStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
// Bucket by email instead of userId
|
||||
const emailStrategy = new PercentageStrategy({
|
||||
percentage: 25,
|
||||
value: 'variant-b',
|
||||
userKey: 'email',
|
||||
});
|
||||
|
||||
const result = emailStrategy.evaluate(flag, { email: 'alice@example.com' });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PercentageOptions`
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|------------|----------------------------|----------|------------|-------------------------------------------|
|
||||
| `percentage` | `number` | Yes | — | Rollout percentage, 0–100 |
|
||||
| `value` | `FlagValue` | Yes | — | Value returned when user matches |
|
||||
| `userKey` | `keyof EvaluationContext` | No | `'userId'` | Context field used for bucketing |
|
||||
|
||||
### `class PercentageStrategy implements FlagStrategy`
|
||||
|
||||
**Constructor:** `new PercentageStrategy(options: PercentageOptions)`
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`**
|
||||
|
||||
- Returns `matched: true` with `value` when `hashToPercentage(flag.key, context[userKey]) < percentage`
|
||||
- Returns `matched: false` when the user is outside the bucket or the context field is absent/non-string
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `userKey` field is constrained to `keyof EvaluationContext` to prevent runtime errors from invalid key access. Bucketing uses the flag key as a salt so that the same user does not always fall in or out of every percentage-based flag — independent roll-outs remain statistically independent. When `percentage === 0`, no hash result will be less than zero, so the strategy never matches. When `percentage === 100`, every hash result in `[0, 100)` satisfies the `< 100` condition, so the strategy always matches.
|
||||
@@ -0,0 +1,78 @@
|
||||
# schedule-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements a time-window scheduling strategy for feature flags. A flag managed by this strategy is active only during a configured time interval, enabling precise control over feature launches, promotional windows, and maintenance mode toggles — without requiring a deployment to activate or deactivate the feature.
|
||||
|
||||
The evaluation timestamp is drawn from `context.timestamp` when supplied (enabling deterministic tests) and falls back to `Date.now()` for production use.
|
||||
|
||||
## Features
|
||||
|
||||
- Open-ended windows: omit `startTime`, `endTime`, or both
|
||||
- Inclusive bounds — both `startTime` and `endTime` are part of the active window
|
||||
- Injectable timestamp via `context.timestamp` for testing
|
||||
- No external clock dependency — works with any numeric epoch timestamp
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ScheduleStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
const now = Date.now();
|
||||
const strategy = new ScheduleStrategy({
|
||||
startTime: now,
|
||||
endTime: now + 7 * 24 * 60 * 60 * 1000, // 1 week
|
||||
value: true,
|
||||
});
|
||||
|
||||
strategy.evaluate(flag, {}); // matched: true (current time is within window)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ScheduleStrategy } from '@suaveplan/feature-flags/strategies';
|
||||
|
||||
// Half-open window: active from a fixed start time with no end
|
||||
const maintenanceMode = new ScheduleStrategy({
|
||||
startTime: new Date('2026-06-01T00:00:00Z').getTime(),
|
||||
value: 'maintenance',
|
||||
});
|
||||
|
||||
// Inject a test timestamp
|
||||
maintenanceMode.evaluate(flag, {
|
||||
timestamp: new Date('2026-06-02T12:00:00Z').getTime(),
|
||||
}); // matched: true
|
||||
|
||||
maintenanceMode.evaluate(flag, {
|
||||
timestamp: new Date('2026-05-31T23:59:59Z').getTime(),
|
||||
}); // matched: false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ScheduleOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------------|----------|----------|-----------------------------------------------|
|
||||
| `startTime` | `number` | No | Window start in ms since epoch (inclusive) |
|
||||
| `endTime` | `number` | No | Window end in ms since epoch (inclusive) |
|
||||
| `value` | `FlagValue` | Yes | Value returned when within the window |
|
||||
| `timezone` | `string` | No | IANA timezone (stored, not yet applied) |
|
||||
|
||||
### `class ScheduleStrategy implements FlagStrategy`
|
||||
|
||||
**Constructor:** `new ScheduleStrategy(options: ScheduleOptions)`
|
||||
|
||||
**`evaluate(flag, context): StrategyEvaluation`**
|
||||
|
||||
- Reads `context.timestamp ?? Date.now()` as the current time
|
||||
- Returns `matched: true` when `now >= startTime` (if set) AND `now <= endTime` (if set)
|
||||
- Returns `matched: true` when neither bound is set (always-active window)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both bounds are inclusive (`>=` / `<=`) to match typical scheduling semantics where a launch at `startTime === now` should be immediately active. The `timezone` field is stored but has no effect on the current comparison, which operates entirely in UTC epoch milliseconds; timezone-aware business-hour windows are a planned extension. The `_flag` parameter is accepted but unused — schedule matching does not depend on flag metadata.
|
||||
@@ -0,0 +1,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.
|
||||
114
docs/foundation/feature-flags/feature-flags/src/types/common.md
Normal file
114
docs/foundation/feature-flags/feature-flags/src/types/common.md
Normal 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user