feat(flags): production-grade feature flag system — multi-variate, segments, audit, SSE, scheduling, prerequisites
- types.ts: multi-variate flags (boolean/string/number/JSON), targeting rules with 18 operators, scheduling (enableAt/disableAt/gradual rollout), prerequisites, segments, audit log, evaluation context - evaluator.ts: pure evaluation engine — schedule checking, prerequisite dependencies (circular detection), individual targeting, targeting rules (AND clauses), segment matching, percentage rollout (FNV-1a), OS version/platform/region filtering - repository.ts: 3 collections — feature_flags, flag_segments, flag_audit_log - routes.ts: 18 endpoints — flag CRUD, toggle, archive, kill switch (with tag filter), segment CRUD, audit log, POST /flags/evaluate (multi-variate), SSE /flags/stream, legacy /flags/poll backward-compat - seed.ts: updated to produce full FeatureFlagDoc with variations, version - flags.test.ts: 63 tests — schema validation, evaluator engine, targeting rules, segments, prerequisites, scheduling, gradual rollouts, multi-variate, version comparison, deterministic hashing - @bytelyst/events: added flag.created, flag.updated, flag.deleted, flag.kill_switch event types - @bytelyst/feature-flag-client: multi-variate support (getValue, getEvaluation, getAllEvaluations), SSE streaming mode, onChange listeners, auth token injection - event-dispatcher.ts + webhooks/types.ts: wired new flag events
This commit is contained in:
parent
d11f84da5f
commit
ca6a4d41d8
@ -148,6 +148,29 @@ export const PlatformEventSchemas = {
|
||||
enabled: z.boolean(),
|
||||
percentage: z.number().optional(),
|
||||
productId: z.string(),
|
||||
flagKey: z.string().optional(),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
'flag.created': z.object({
|
||||
productId: z.string(),
|
||||
flagKey: z.string(),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
'flag.updated': z.object({
|
||||
productId: z.string(),
|
||||
flagKey: z.string(),
|
||||
actor: z.string().optional(),
|
||||
changes: z.array(z.string()).optional(),
|
||||
}),
|
||||
'flag.deleted': z.object({
|
||||
productId: z.string(),
|
||||
flagKey: z.string(),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
'flag.kill_switch': z.object({
|
||||
productId: z.string(),
|
||||
disabled: z.array(z.string()),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
|
||||
// License events
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
/**
|
||||
* Browser/React Native-safe feature flag client for platform-service.
|
||||
*
|
||||
* Polls GET /api/flags/poll on a configurable interval and caches results.
|
||||
* No Node.js dependencies — uses globalThis.fetch.
|
||||
* Supports two modes:
|
||||
* 1. **Polling** (default) — GET /flags/poll on a configurable interval
|
||||
* 2. **Streaming** — SSE via GET /flags/stream for real-time updates
|
||||
*
|
||||
* Both modes support multi-variate evaluation via POST /flags/evaluate.
|
||||
* No Node.js dependencies — uses globalThis.fetch and EventSource.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
@ -15,11 +19,20 @@
|
||||
* });
|
||||
*
|
||||
* await flags.init({ userId: 'user-123' });
|
||||
*
|
||||
* // Boolean check (legacy)
|
||||
* if (flags.isEnabled('premium_body_viz')) { ... }
|
||||
*
|
||||
* // Multi-variate
|
||||
* const color = flags.getValue('cta_color', '#000000');
|
||||
* const config = flags.getValue('rate_limits', { maxReqs: 100 });
|
||||
*
|
||||
* // Listen for changes (SSE or polling refresh)
|
||||
* const unsub = flags.onChange((key) => console.log(`${key} changed`));
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
|
||||
import type { FeatureFlagClient, FeatureFlagClientConfig, EvaluationResult } from './types.js';
|
||||
|
||||
export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient {
|
||||
const {
|
||||
@ -29,87 +42,254 @@ export function createFeatureFlagClient(config: FeatureFlagClientConfig): Featur
|
||||
pollIntervalMs = 5 * 60 * 1000,
|
||||
storage,
|
||||
storagePrefix,
|
||||
useStreaming = false,
|
||||
getAccessToken,
|
||||
} = config;
|
||||
|
||||
const prefix = storagePrefix ?? productId;
|
||||
const STORAGE_KEY = `${prefix}-feature-flags`;
|
||||
const BOOL_KEY = `${prefix}-feature-flags`;
|
||||
const EVAL_KEY = `${prefix}-feature-evals`;
|
||||
|
||||
let flags: Record<string, boolean> = {};
|
||||
let boolFlags: Record<string, boolean> = {};
|
||||
let evaluations: Record<string, EvaluationResult> = {};
|
||||
let initialized = false;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
// eslint-disable-next-line no-undef
|
||||
let eventSource: InstanceType<typeof EventSource> | null = null;
|
||||
let userId: string | undefined;
|
||||
const listeners = new Set<(flagKey: string) => void>();
|
||||
|
||||
// Restore from storage on creation
|
||||
if (storage) {
|
||||
try {
|
||||
const cached = storage.getItem(STORAGE_KEY);
|
||||
if (cached) flags = JSON.parse(cached);
|
||||
const cached = storage.getItem(BOOL_KEY);
|
||||
if (cached) boolFlags = JSON.parse(cached);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
/* Ignore parse errors */
|
||||
}
|
||||
try {
|
||||
const cached = storage.getItem(EVAL_KEY);
|
||||
if (cached) evaluations = JSON.parse(cached);
|
||||
} catch {
|
||||
/* Ignore parse errors */
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFlags(): Promise<void> {
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const requestId =
|
||||
typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'x-product-id': productId,
|
||||
'x-request-id': requestId,
|
||||
};
|
||||
if (getAccessToken) {
|
||||
const token = getAccessToken();
|
||||
if (token) headers['authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function notifyListeners(flagKey: string): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(flagKey);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistToStorage(): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(BOOL_KEY, JSON.stringify(boolFlags));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
try {
|
||||
storage.setItem(EVAL_KEY, JSON.stringify(evaluations));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBoolFlags(): Promise<void> {
|
||||
try {
|
||||
const parts = [`platform=${encodeURIComponent(platform)}`];
|
||||
if (userId) parts.push(`userId=${encodeURIComponent(userId)}`);
|
||||
|
||||
const requestId =
|
||||
typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, {
|
||||
headers: { 'x-product-id': productId, 'x-request-id': requestId },
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = (await res.json()) as { flags?: Record<string, boolean> };
|
||||
flags = data.flags ?? {};
|
||||
const prev = boolFlags;
|
||||
boolFlags = data.flags ?? {};
|
||||
|
||||
// Persist to storage
|
||||
if (storage) {
|
||||
try {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(flags));
|
||||
} catch {
|
||||
// Storage write failure — non-fatal
|
||||
}
|
||||
// Detect changes and notify
|
||||
const allKeys = new Set([...Object.keys(prev), ...Object.keys(boolFlags)]);
|
||||
for (const key of allKeys) {
|
||||
if (prev[key] !== boolFlags[key]) notifyListeners(key);
|
||||
}
|
||||
|
||||
persistToStorage();
|
||||
} catch {
|
||||
// Keep existing flags on network error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvaluations(): Promise<void> {
|
||||
try {
|
||||
const body: Record<string, unknown> = { platform };
|
||||
if (userId) body.userId = userId;
|
||||
|
||||
const res = await globalThis.fetch(`${baseUrl}/flags/evaluate`, {
|
||||
method: 'POST',
|
||||
headers: { ...buildHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = (await res.json()) as { evaluations?: Record<string, EvaluationResult> };
|
||||
const prev = evaluations;
|
||||
evaluations = data.evaluations ?? {};
|
||||
|
||||
// Also update bool flags for backward compatibility
|
||||
for (const [key, result] of Object.entries(evaluations)) {
|
||||
const newBool =
|
||||
result.reason !== 'off' &&
|
||||
result.reason !== 'prerequisite_failed' &&
|
||||
result.reason !== 'schedule_inactive' &&
|
||||
result.reason !== 'error';
|
||||
if (boolFlags[key] !== newBool) {
|
||||
boolFlags[key] = newBool;
|
||||
notifyListeners(key);
|
||||
} else if (JSON.stringify(prev[key]?.value) !== JSON.stringify(result.value)) {
|
||||
notifyListeners(key);
|
||||
}
|
||||
}
|
||||
|
||||
persistToStorage();
|
||||
} catch {
|
||||
// Keep existing evaluations on network error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAll(): Promise<void> {
|
||||
await Promise.all([fetchBoolFlags(), fetchEvaluations()]);
|
||||
}
|
||||
|
||||
function startStreaming(): void {
|
||||
if (typeof globalThis.EventSource === 'undefined') {
|
||||
// SSE not available (e.g. React Native) — fall back to polling
|
||||
intervalId = setInterval(() => {
|
||||
void fetchAll();
|
||||
}, pollIntervalMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/flags/stream`;
|
||||
eventSource = new globalThis.EventSource(url);
|
||||
|
||||
eventSource.onmessage = event => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as { type?: string; flagKey?: string };
|
||||
if (data.type === 'flag_change' && data.flagKey) {
|
||||
// Re-fetch all evaluations on any flag change
|
||||
void fetchAll().then(() => notifyListeners(data.flagKey!));
|
||||
}
|
||||
} catch {
|
||||
/* Ignore parse errors */
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// Reconnect handled automatically by EventSource spec
|
||||
};
|
||||
}
|
||||
|
||||
async function init(params?: { userId?: string }): Promise<void> {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
userId = params?.userId;
|
||||
await fetchFlags();
|
||||
intervalId = setInterval(() => {
|
||||
void fetchFlags();
|
||||
}, pollIntervalMs);
|
||||
|
||||
await fetchAll();
|
||||
|
||||
if (useStreaming) {
|
||||
startStreaming();
|
||||
} else {
|
||||
intervalId = setInterval(() => {
|
||||
void fetchAll();
|
||||
}, pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled(key: string): boolean {
|
||||
return flags[key] === true;
|
||||
return boolFlags[key] === true;
|
||||
}
|
||||
|
||||
function getValue<T = boolean | string | number | Record<string, unknown>>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): T {
|
||||
const result = evaluations[key];
|
||||
if (!result) return defaultValue;
|
||||
if (result.reason === 'off' || result.reason === 'error') return defaultValue;
|
||||
return result.value as T;
|
||||
}
|
||||
|
||||
function getEvaluation(key: string): EvaluationResult | undefined {
|
||||
return evaluations[key];
|
||||
}
|
||||
|
||||
function getAllFlags(): Readonly<Record<string, boolean>> {
|
||||
return flags;
|
||||
return boolFlags;
|
||||
}
|
||||
|
||||
function getAllEvaluations(): Readonly<Record<string, EvaluationResult>> {
|
||||
return evaluations;
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await fetchFlags();
|
||||
await fetchAll();
|
||||
}
|
||||
|
||||
function onChange(listener: (flagKey: string) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
flags = {};
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
boolFlags = {};
|
||||
evaluations = {};
|
||||
initialized = false;
|
||||
userId = undefined;
|
||||
listeners.clear();
|
||||
}
|
||||
|
||||
return { init, isEnabled, getAllFlags, refresh, stop };
|
||||
return {
|
||||
init,
|
||||
isEnabled,
|
||||
getValue,
|
||||
getEvaluation,
|
||||
getAllFlags,
|
||||
getAllEvaluations,
|
||||
refresh,
|
||||
onChange,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,2 +1,7 @@
|
||||
export { createFeatureFlagClient } from './client.js';
|
||||
export type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
|
||||
export type {
|
||||
FeatureFlagClient,
|
||||
FeatureFlagClientConfig,
|
||||
EvaluationContext,
|
||||
EvaluationResult,
|
||||
} from './types.js';
|
||||
|
||||
@ -3,6 +3,27 @@
|
||||
* Browser/React Native-safe — no Node.js dependencies.
|
||||
*/
|
||||
|
||||
// ── Evaluation types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface EvaluationContext {
|
||||
userId?: string;
|
||||
platform?: string;
|
||||
region?: string;
|
||||
osVersion?: string;
|
||||
appVersion?: string;
|
||||
email?: string;
|
||||
custom?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface EvaluationResult {
|
||||
key: string;
|
||||
value: boolean | string | number | Record<string, unknown>;
|
||||
variationKey: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FeatureFlagClientConfig {
|
||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||
baseUrl: string;
|
||||
@ -24,21 +45,44 @@ export interface FeatureFlagClientConfig {
|
||||
|
||||
/** Optional storage key prefix. Default: productId. */
|
||||
storagePrefix?: string;
|
||||
|
||||
/** Use SSE for real-time updates instead of polling. Default: false. */
|
||||
useStreaming?: boolean;
|
||||
|
||||
/** Auth token getter for authenticated requests. */
|
||||
getAccessToken?: () => string | null;
|
||||
}
|
||||
|
||||
// ── Client interface ────────────────────────────────────────────────────────
|
||||
|
||||
export interface FeatureFlagClient {
|
||||
/** Initialize the client: fetch flags immediately and start polling. */
|
||||
/** Initialize the client: fetch flags immediately and start polling/streaming. */
|
||||
init(params?: { userId?: string }): Promise<void>;
|
||||
|
||||
/** Check if a feature flag is enabled. Returns false if not found. */
|
||||
/** Check if a boolean feature flag is enabled. Returns false if not found. */
|
||||
isEnabled(key: string): boolean;
|
||||
|
||||
/** Get all currently cached flags. */
|
||||
/** Get the resolved value of a multi-variate flag. Returns defaultValue if not found. */
|
||||
getValue<T = boolean | string | number | Record<string, unknown>>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): T;
|
||||
|
||||
/** Get the full evaluation result for a flag. Returns undefined if not found. */
|
||||
getEvaluation(key: string): EvaluationResult | undefined;
|
||||
|
||||
/** Get all currently cached boolean flags (legacy format). */
|
||||
getAllFlags(): Readonly<Record<string, boolean>>;
|
||||
|
||||
/** Get all evaluation results (multi-variate format). */
|
||||
getAllEvaluations(): Readonly<Record<string, EvaluationResult>>;
|
||||
|
||||
/** Force a refresh of feature flags. */
|
||||
refresh(): Promise<void>;
|
||||
|
||||
/** Stop polling and reset state. */
|
||||
/** Register a listener for flag changes. Returns unsubscribe function. */
|
||||
onChange(listener: (flagKey: string) => void): () => void;
|
||||
|
||||
/** Stop polling/streaming and reset state. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
@ -115,6 +115,10 @@ export function wireDispatcherToBus(): void {
|
||||
'job.completed',
|
||||
'job.failed',
|
||||
'flag.toggled',
|
||||
'flag.created',
|
||||
'flag.updated',
|
||||
'flag.deleted',
|
||||
'flag.kill_switch',
|
||||
'license.activated',
|
||||
'license.expired',
|
||||
'invitation.redeemed',
|
||||
|
||||
382
services/platform-service/src/modules/flags/evaluator.ts
Normal file
382
services/platform-service/src/modules/flags/evaluator.ts
Normal file
@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Flag evaluation engine — pure functions, no I/O.
|
||||
*
|
||||
* Evaluates a flag against an EvaluationContext and returns the resolved
|
||||
* variation value + the reason it was chosen. Supports:
|
||||
*
|
||||
* - Schedule checking (enableAt / disableAt / gradual rollout)
|
||||
* - Prerequisite dependencies
|
||||
* - Individual user targeting
|
||||
* - Targeting rules with attribute matching (AND within a rule)
|
||||
* - Segment matching
|
||||
* - Percentage rollout (deterministic via FNV-1a)
|
||||
* - OS version range filtering
|
||||
* - Platform / region filtering
|
||||
* - Fallback to default variation
|
||||
*/
|
||||
|
||||
import type {
|
||||
FeatureFlagDoc,
|
||||
FlagVariation,
|
||||
TargetingClause,
|
||||
SegmentDoc,
|
||||
EvaluationContext,
|
||||
EvaluationResult,
|
||||
} from './types.js';
|
||||
|
||||
// ── FNV-1a hash ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* FNV-1a 32-bit hash → 0–99 bucket.
|
||||
* Deterministic: same (userId, flagKey) always produces the same bucket.
|
||||
*/
|
||||
export function hashUserFlag(userId: string, flagKey: string): number {
|
||||
const input = `${userId}:${flagKey}`;
|
||||
let hash = 0x811c9dc5; // FNV offset basis
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193); // FNV prime
|
||||
}
|
||||
return (hash >>> 0) % 100; // 0-99 bucket
|
||||
}
|
||||
|
||||
// ── Semver comparison ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compare two dot-separated version strings.
|
||||
* Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const na = pa[i] ?? 0;
|
||||
const nb = pb[i] ?? 0;
|
||||
if (na < nb) return -1;
|
||||
if (na > nb) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Clause evaluation ───────────────────────────────────────────────────────
|
||||
|
||||
function getContextAttribute(
|
||||
ctx: EvaluationContext,
|
||||
attribute: string
|
||||
): string | number | boolean | undefined {
|
||||
switch (attribute) {
|
||||
case 'userId':
|
||||
return ctx.userId;
|
||||
case 'platform':
|
||||
return ctx.platform;
|
||||
case 'region':
|
||||
return ctx.region;
|
||||
case 'osVersion':
|
||||
return ctx.osVersion;
|
||||
case 'appVersion':
|
||||
return ctx.appVersion;
|
||||
case 'email':
|
||||
return ctx.email;
|
||||
default:
|
||||
return ctx.custom?.[attribute];
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateClause(clause: TargetingClause, ctx: EvaluationContext): boolean {
|
||||
const actual = getContextAttribute(ctx, clause.attribute);
|
||||
if (actual === undefined) return false;
|
||||
|
||||
const strActual = String(actual);
|
||||
|
||||
switch (clause.operator) {
|
||||
case 'eq':
|
||||
return clause.values.some(v => String(v) === strActual);
|
||||
case 'neq':
|
||||
return clause.values.every(v => String(v) !== strActual);
|
||||
case 'contains':
|
||||
return clause.values.some(v => strActual.includes(String(v)));
|
||||
case 'not_contains':
|
||||
return clause.values.every(v => !strActual.includes(String(v)));
|
||||
case 'starts_with':
|
||||
return clause.values.some(v => strActual.startsWith(String(v)));
|
||||
case 'ends_with':
|
||||
return clause.values.some(v => strActual.endsWith(String(v)));
|
||||
case 'gt':
|
||||
return clause.values.some(v => Number(actual) > Number(v));
|
||||
case 'gte':
|
||||
return clause.values.some(v => Number(actual) >= Number(v));
|
||||
case 'lt':
|
||||
return clause.values.some(v => Number(actual) < Number(v));
|
||||
case 'lte':
|
||||
return clause.values.some(v => Number(actual) <= Number(v));
|
||||
case 'in':
|
||||
return clause.values.map(String).includes(strActual);
|
||||
case 'not_in':
|
||||
return !clause.values.map(String).includes(strActual);
|
||||
case 'semver_gt':
|
||||
return clause.values.some(v => compareVersions(strActual, String(v)) > 0);
|
||||
case 'semver_gte':
|
||||
return clause.values.some(v => compareVersions(strActual, String(v)) >= 0);
|
||||
case 'semver_lt':
|
||||
return clause.values.some(v => compareVersions(strActual, String(v)) < 0);
|
||||
case 'semver_lte':
|
||||
return clause.values.some(v => compareVersions(strActual, String(v)) <= 0);
|
||||
case 'semver_eq':
|
||||
return clause.values.some(v => compareVersions(strActual, String(v)) === 0);
|
||||
case 'regex':
|
||||
try {
|
||||
return clause.values.some(v => new RegExp(String(v)).test(strActual));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getVariation(flag: FeatureFlagDoc, key: string): FlagVariation | undefined {
|
||||
return flag.variations.find(v => v.key === key);
|
||||
}
|
||||
|
||||
function offResult(flag: FeatureFlagDoc, reason: EvaluationResult['reason']): EvaluationResult {
|
||||
const variation = getVariation(flag, flag.offVariation);
|
||||
return {
|
||||
key: flag.key,
|
||||
value: variation?.value ?? false,
|
||||
variationKey: flag.offVariation,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
function variationResult(
|
||||
flag: FeatureFlagDoc,
|
||||
variationKey: string,
|
||||
reason: EvaluationResult['reason']
|
||||
): EvaluationResult {
|
||||
const variation = getVariation(flag, variationKey);
|
||||
return {
|
||||
key: flag.key,
|
||||
value: variation?.value ?? true,
|
||||
variationKey,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Schedule checking ───────────────────────────────────────────────────────
|
||||
|
||||
function isScheduleActive(flag: FeatureFlagDoc, now: string): boolean | null {
|
||||
if (!flag.schedule) return null; // no schedule — use flag.enabled
|
||||
|
||||
if (flag.schedule.enableAt && now < flag.schedule.enableAt) return false;
|
||||
if (flag.schedule.disableAt && now >= flag.schedule.disableAt) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the effective rollout percentage considering gradual rollout schedule.
|
||||
*/
|
||||
export function getEffectivePercentage(flag: FeatureFlagDoc, now: string): number {
|
||||
const gr = flag.schedule?.gradualRollout;
|
||||
if (!gr) return flag.percentage;
|
||||
|
||||
if (now < gr.startAt) return gr.startPercentage;
|
||||
if (now >= gr.endAt) return gr.endPercentage;
|
||||
|
||||
// Linear interpolation between start and end
|
||||
const totalMs = new Date(gr.endAt).getTime() - new Date(gr.startAt).getTime();
|
||||
const elapsedMs = new Date(now).getTime() - new Date(gr.startAt).getTime();
|
||||
const progress = Math.min(1, Math.max(0, elapsedMs / totalMs));
|
||||
return Math.round(gr.startPercentage + (gr.endPercentage - gr.startPercentage) * progress);
|
||||
}
|
||||
|
||||
// ── OS version filtering ────────────────────────────────────────────────────
|
||||
|
||||
function matchesOsVersion(flag: FeatureFlagDoc, ctx: EvaluationContext): boolean {
|
||||
if (!flag.osVersions || flag.osVersions.length === 0) return true;
|
||||
if (!ctx.platform || !ctx.osVersion) return true; // no context to filter on
|
||||
|
||||
const rule = flag.osVersions.find(r => r.platform === ctx.platform);
|
||||
if (!rule) return false; // osVersions defined but no rule for this platform
|
||||
|
||||
if (rule.minVersion && compareVersions(ctx.osVersion, rule.minVersion) < 0) return false;
|
||||
if (rule.maxVersion && compareVersions(ctx.osVersion, rule.maxVersion) > 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Platform / region filtering ─────────────────────────────────────────────
|
||||
|
||||
function matchesPlatform(flag: FeatureFlagDoc, ctx: EvaluationContext): boolean {
|
||||
if (flag.platforms.length === 0) return true;
|
||||
if (!ctx.platform) return true;
|
||||
return flag.platforms.includes(ctx.platform);
|
||||
}
|
||||
|
||||
function matchesRegion(flag: FeatureFlagDoc, ctx: EvaluationContext): boolean {
|
||||
if (!flag.regions || flag.regions.length === 0) return true;
|
||||
if (!ctx.region) return true;
|
||||
return flag.regions.includes(ctx.region);
|
||||
}
|
||||
|
||||
// ── Segment matching ────────────────────────────────────────────────────────
|
||||
|
||||
function userMatchesSegment(segment: SegmentDoc, ctx: EvaluationContext): boolean {
|
||||
if (!ctx.userId) return false;
|
||||
|
||||
// Explicit exclusion takes priority
|
||||
if (segment.excludedUsers.includes(ctx.userId)) return false;
|
||||
|
||||
// Explicit inclusion
|
||||
if (segment.includedUsers.includes(ctx.userId)) return true;
|
||||
|
||||
// Rule-based matching: all clauses must match (AND)
|
||||
if (segment.rules.length === 0) return false;
|
||||
return segment.rules.every(clause => evaluateClause(clause, ctx));
|
||||
}
|
||||
|
||||
// ── Main evaluation function ────────────────────────────────────────────────
|
||||
|
||||
export interface EvaluateFlagOptions {
|
||||
flag: FeatureFlagDoc;
|
||||
ctx: EvaluationContext;
|
||||
allFlags: FeatureFlagDoc[];
|
||||
segments: SegmentDoc[];
|
||||
now?: string;
|
||||
/** Guard against circular prerequisites — tracks visited flag keys. */
|
||||
_visited?: Set<string>;
|
||||
}
|
||||
|
||||
export function evaluateFlag(opts: EvaluateFlagOptions): EvaluationResult {
|
||||
const { flag, ctx, allFlags, segments, _visited = new Set() } = opts;
|
||||
const now = opts.now ?? new Date().toISOString();
|
||||
|
||||
// Prevent circular prerequisites
|
||||
if (_visited.has(flag.key)) {
|
||||
return offResult(flag, 'error');
|
||||
}
|
||||
_visited.add(flag.key);
|
||||
|
||||
// 1. Check schedule
|
||||
const scheduleActive = isScheduleActive(flag, now);
|
||||
if (scheduleActive === false) {
|
||||
return offResult(flag, 'schedule_inactive');
|
||||
}
|
||||
|
||||
// 2. Check enabled
|
||||
if (!flag.enabled) {
|
||||
return offResult(flag, 'off');
|
||||
}
|
||||
|
||||
// 3. Check archived
|
||||
if (flag.archived) {
|
||||
return offResult(flag, 'off');
|
||||
}
|
||||
|
||||
// 4. Check platform / region / OS version
|
||||
if (!matchesPlatform(flag, ctx) || !matchesRegion(flag, ctx) || !matchesOsVersion(flag, ctx)) {
|
||||
return offResult(flag, 'off');
|
||||
}
|
||||
|
||||
// 5. Check prerequisites
|
||||
for (const prereq of flag.prerequisites) {
|
||||
const prereqFlag = allFlags.find(f => f.key === prereq.flagKey);
|
||||
if (!prereqFlag) {
|
||||
return offResult(flag, 'prerequisite_failed');
|
||||
}
|
||||
const prereqResult = evaluateFlag({
|
||||
flag: prereqFlag,
|
||||
ctx,
|
||||
allFlags,
|
||||
segments,
|
||||
now,
|
||||
_visited: new Set(_visited),
|
||||
});
|
||||
if (prereqResult.reason === 'error') {
|
||||
return offResult(flag, 'error');
|
||||
}
|
||||
if (prereqResult.variationKey !== prereq.variationKey) {
|
||||
return offResult(flag, 'prerequisite_failed');
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Individual user targeting
|
||||
if (ctx.userId && flag.individualTargets[ctx.userId]) {
|
||||
const targetVariation = flag.individualTargets[ctx.userId];
|
||||
return variationResult(flag, targetVariation, 'individual_target');
|
||||
}
|
||||
|
||||
// 7. Targeting rules (evaluated in order, first match wins)
|
||||
for (const rule of flag.targetingRules) {
|
||||
const allClausesMatch = rule.clauses.every(clause => evaluateClause(clause, ctx));
|
||||
if (allClausesMatch) {
|
||||
// Apply rule's rollout percentage
|
||||
if (rule.rolloutPercentage >= 100) {
|
||||
return variationResult(flag, rule.variationKey, 'rule_match');
|
||||
}
|
||||
if (rule.rolloutPercentage <= 0) continue;
|
||||
if (
|
||||
ctx.userId &&
|
||||
hashUserFlag(ctx.userId, `${flag.key}:${rule.id}`) < rule.rolloutPercentage
|
||||
) {
|
||||
return variationResult(flag, rule.variationKey, 'rule_match');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Segment matching
|
||||
if (flag.segments.length > 0 && ctx.userId) {
|
||||
for (const segKey of flag.segments) {
|
||||
const segment = segments.find(s => s.key === segKey);
|
||||
if (segment && userMatchesSegment(segment, ctx)) {
|
||||
return variationResult(flag, flag.defaultVariation, 'segment_match');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Percentage rollout
|
||||
const effectivePercentage = getEffectivePercentage(flag, now);
|
||||
if (effectivePercentage >= 100) {
|
||||
return variationResult(flag, flag.defaultVariation, 'percentage_rollout');
|
||||
}
|
||||
if (effectivePercentage <= 0) {
|
||||
return offResult(flag, 'off');
|
||||
}
|
||||
if (ctx.userId) {
|
||||
const bucket = hashUserFlag(ctx.userId, flag.key);
|
||||
if (bucket < effectivePercentage) {
|
||||
return variationResult(flag, flag.defaultVariation, 'percentage_rollout');
|
||||
}
|
||||
return offResult(flag, 'off');
|
||||
}
|
||||
|
||||
// 10. Default (no userId, full rollout)
|
||||
return variationResult(flag, flag.defaultVariation, 'default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all flags for a given context. Returns a map of flagKey → EvaluationResult.
|
||||
*/
|
||||
export function evaluateAllFlags(
|
||||
flags: FeatureFlagDoc[],
|
||||
ctx: EvaluationContext,
|
||||
segments: SegmentDoc[],
|
||||
now?: string
|
||||
): Record<string, EvaluationResult> {
|
||||
const results: Record<string, EvaluationResult> = {};
|
||||
const activeFlags = flags.filter(f => !f.archived);
|
||||
|
||||
for (const flag of activeFlags) {
|
||||
results[flag.key] = evaluateFlag({
|
||||
flag,
|
||||
ctx,
|
||||
allFlags: activeFlags,
|
||||
segments,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@ -1,9 +1,78 @@
|
||||
/**
|
||||
* Unit tests for feature flags module — types + validation.
|
||||
* Comprehensive tests for the production-grade feature flag system.
|
||||
*
|
||||
* Covers: schema validation, evaluator engine (targeting rules, segments,
|
||||
* prerequisites, scheduling, gradual rollouts, individual targets),
|
||||
* version comparison, and deterministic hashing.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateFlagSchema, UpdateFlagSchema } from './types.js';
|
||||
import {
|
||||
CreateFlagSchema,
|
||||
UpdateFlagSchema,
|
||||
CreateSegmentSchema,
|
||||
EvaluateSchema,
|
||||
type FeatureFlagDoc,
|
||||
type SegmentDoc,
|
||||
} from './types.js';
|
||||
import {
|
||||
evaluateFlag,
|
||||
evaluateAllFlags,
|
||||
hashUserFlag,
|
||||
compareVersions,
|
||||
getEffectivePercentage,
|
||||
} from './evaluator.js';
|
||||
|
||||
// ── Test helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFlag(overrides: Partial<FeatureFlagDoc> = {}): FeatureFlagDoc {
|
||||
return {
|
||||
id: 'flag_test_feature',
|
||||
productId: 'test',
|
||||
key: 'feature',
|
||||
flagType: 'boolean',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
description: 'Test flag',
|
||||
tags: [],
|
||||
variations: [
|
||||
{ key: 'on', value: true, description: 'On' },
|
||||
{ key: 'off', value: false, description: 'Off' },
|
||||
],
|
||||
defaultVariation: 'on',
|
||||
offVariation: 'off',
|
||||
platforms: [],
|
||||
regions: [],
|
||||
osVersions: [],
|
||||
segments: [],
|
||||
targetingRules: [],
|
||||
individualTargets: {},
|
||||
prerequisites: [],
|
||||
percentage: 100,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSegment(overrides: Partial<SegmentDoc> = {}): SegmentDoc {
|
||||
return {
|
||||
id: 'seg_test_beta',
|
||||
productId: 'test',
|
||||
key: 'beta',
|
||||
name: 'Beta Users',
|
||||
description: 'Beta testers',
|
||||
rules: [],
|
||||
includedUsers: [],
|
||||
excludedUsers: [],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Schema validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('CreateFlagSchema', () => {
|
||||
it('accepts valid flag with defaults', () => {
|
||||
@ -11,124 +80,793 @@ describe('CreateFlagSchema', () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.enabled).toBe(true);
|
||||
expect(result.data.flagType).toBe('boolean');
|
||||
expect(result.data.percentage).toBe(100);
|
||||
expect(result.data.platforms).toEqual([]);
|
||||
expect(result.data.regions).toEqual([]);
|
||||
expect(result.data.osVersions).toEqual([]);
|
||||
expect(result.data.segments).toEqual([]);
|
||||
expect(result.data.targetingRules).toEqual([]);
|
||||
expect(result.data.individualTargets).toEqual({});
|
||||
expect(result.data.prerequisites).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts full input', () => {
|
||||
it('accepts dotted key names', () => {
|
||||
const result = CreateFlagSchema.safeParse({ key: 'smartauth.oauth.google' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts multi-variate string flag', () => {
|
||||
const result = CreateFlagSchema.safeParse({
|
||||
key: 'cta_color',
|
||||
flagType: 'string',
|
||||
variations: [
|
||||
{ key: 'red', value: '#FF0000' },
|
||||
{ key: 'blue', value: '#0000FF' },
|
||||
{ key: 'green', value: '#00FF00' },
|
||||
],
|
||||
defaultVariation: 'red',
|
||||
offVariation: 'blue',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts targeting rules', () => {
|
||||
const result = CreateFlagSchema.safeParse({
|
||||
key: 'new_ui',
|
||||
enabled: false,
|
||||
description: 'New dashboard UI',
|
||||
platforms: ['web', 'ios'],
|
||||
regions: ['us', 'eu'],
|
||||
osVersions: [
|
||||
{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' },
|
||||
{ platform: 'android', minVersion: '14.0' },
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'rule_1',
|
||||
clauses: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
segments: ['beta'],
|
||||
percentage: 50,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.regions).toEqual(['us', 'eu']);
|
||||
expect(result.data.osVersions).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts osVersions with only minVersion', () => {
|
||||
const result = CreateFlagSchema.safeParse({
|
||||
key: 'ios_only',
|
||||
osVersions: [{ platform: 'ios', minVersion: '16.0' }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts osVersions with only maxVersion', () => {
|
||||
it('accepts schedule with gradual rollout', () => {
|
||||
const result = CreateFlagSchema.safeParse({
|
||||
key: 'legacy_only',
|
||||
osVersions: [{ platform: 'android', maxVersion: '13.0' }],
|
||||
key: 'gradual',
|
||||
schedule: {
|
||||
enableAt: '2026-04-01T00:00:00Z',
|
||||
gradualRollout: {
|
||||
startPercentage: 5,
|
||||
endPercentage: 100,
|
||||
startAt: '2026-04-01T00:00:00Z',
|
||||
endAt: '2026-04-08T00:00:00Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects osVersions with empty platform', () => {
|
||||
it('accepts prerequisites', () => {
|
||||
const result = CreateFlagSchema.safeParse({
|
||||
key: 'bad_os',
|
||||
osVersions: [{ platform: '' }],
|
||||
key: 'child_flag',
|
||||
prerequisites: [{ flagKey: 'parent_flag', variationKey: 'on' }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects key with spaces', () => {
|
||||
const result = CreateFlagSchema.safeParse({ key: 'dark mode' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(CreateFlagSchema.safeParse({ key: 'dark mode' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects key with uppercase', () => {
|
||||
const result = CreateFlagSchema.safeParse({ key: 'DarkMode' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(CreateFlagSchema.safeParse({ key: 'DarkMode' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects percentage > 100', () => {
|
||||
const result = CreateFlagSchema.safeParse({ key: 'test', percentage: 150 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(CreateFlagSchema.safeParse({ key: 'test', percentage: 150 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects percentage < 0', () => {
|
||||
const result = CreateFlagSchema.safeParse({ key: 'test', percentage: -10 });
|
||||
expect(CreateFlagSchema.safeParse({ key: 'test', percentage: -10 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects targeting rule with empty clauses', () => {
|
||||
const result = CreateFlagSchema.safeParse({
|
||||
key: 'bad',
|
||||
targetingRules: [{ id: 'r1', clauses: [], variationKey: 'on' }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateFlagSchema', () => {
|
||||
it('accepts partial updates', () => {
|
||||
const result = UpdateFlagSchema.safeParse({
|
||||
enabled: false,
|
||||
percentage: 25,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(UpdateFlagSchema.safeParse({ enabled: false, percentage: 25 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts empty object', () => {
|
||||
const result = UpdateFlagSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
expect(UpdateFlagSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts platform list update', () => {
|
||||
const result = UpdateFlagSchema.safeParse({
|
||||
platforms: ['ios', 'android'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
it('accepts archived field', () => {
|
||||
expect(UpdateFlagSchema.safeParse({ archived: true }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts regions update', () => {
|
||||
const result = UpdateFlagSchema.safeParse({
|
||||
regions: ['us', 'apac'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts osVersions update', () => {
|
||||
const result = UpdateFlagSchema.safeParse({
|
||||
osVersions: [{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
it('accepts targeting rules update', () => {
|
||||
expect(
|
||||
UpdateFlagSchema.safeParse({
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'rule_1',
|
||||
clauses: [{ attribute: 'platform', operator: 'eq', values: ['ios'] }],
|
||||
variationKey: 'on',
|
||||
},
|
||||
],
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareVersions', () => {
|
||||
let compareVersions: (a: string, b: string) => number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('./routes.js');
|
||||
compareVersions = mod.compareVersions;
|
||||
describe('CreateSegmentSchema', () => {
|
||||
it('accepts valid segment', () => {
|
||||
const result = CreateSegmentSchema.safeParse({
|
||||
key: 'beta_users',
|
||||
name: 'Beta Users',
|
||||
rules: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
|
||||
includedUsers: ['user_1'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(CreateSegmentSchema.safeParse({ key: 'bad', name: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EvaluateSchema', () => {
|
||||
it('accepts full context', () => {
|
||||
const result = EvaluateSchema.safeParse({
|
||||
userId: 'user_1',
|
||||
platform: 'ios',
|
||||
region: 'us',
|
||||
osVersion: '17.2',
|
||||
appVersion: '2.1.0',
|
||||
email: 'test@bytelyst.com',
|
||||
custom: { plan: 'pro', age: 25 },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts empty context', () => {
|
||||
expect(EvaluateSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Evaluator engine ────────────────────────────────────────────────────────
|
||||
|
||||
describe('evaluateFlag', () => {
|
||||
it('returns off when flag is disabled', () => {
|
||||
const flag = makeFlag({ enabled: false });
|
||||
const result = evaluateFlag({ flag, ctx: {}, allFlags: [flag], segments: [] });
|
||||
expect(result.reason).toBe('off');
|
||||
expect(result.value).toBe(false);
|
||||
expect(result.variationKey).toBe('off');
|
||||
});
|
||||
|
||||
it('returns off when flag is archived', () => {
|
||||
const flag = makeFlag({ archived: true });
|
||||
const result = evaluateFlag({ flag, ctx: {}, allFlags: [flag], segments: [] });
|
||||
expect(result.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('returns default variation when fully enabled (100%)', () => {
|
||||
const flag = makeFlag();
|
||||
const result = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'user_1' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(result.reason).toBe('percentage_rollout');
|
||||
expect(result.value).toBe(true);
|
||||
expect(result.variationKey).toBe('on');
|
||||
});
|
||||
|
||||
it('returns off when percentage is 0', () => {
|
||||
const flag = makeFlag({ percentage: 0 });
|
||||
const result = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'user_1' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(result.reason).toBe('off');
|
||||
expect(result.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns default with no userId at 100%', () => {
|
||||
const flag = makeFlag();
|
||||
const result = evaluateFlag({ flag, ctx: {}, allFlags: [flag], segments: [] });
|
||||
// At 100%, the evaluator takes the percentage_rollout fast path
|
||||
expect(result.reason).toBe('percentage_rollout');
|
||||
expect(result.value).toBe(true);
|
||||
});
|
||||
|
||||
// ── Platform / region / OS version filtering ──────────────────────────
|
||||
|
||||
it('filters by platform', () => {
|
||||
const flag = makeFlag({ platforms: ['ios'] });
|
||||
const on = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', platform: 'ios' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(on.reason).not.toBe('off');
|
||||
const off = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', platform: 'android' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(off.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('filters by region', () => {
|
||||
const flag = makeFlag({ regions: ['us', 'eu'] });
|
||||
const on = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', region: 'us' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(on.reason).not.toBe('off');
|
||||
const off = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', region: 'apac' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(off.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('filters by OS version range', () => {
|
||||
const flag = makeFlag({
|
||||
osVersions: [{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' }],
|
||||
});
|
||||
const on = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', platform: 'ios', osVersion: '17.2' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(on.reason).not.toBe('off');
|
||||
const off = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', platform: 'ios', osVersion: '16.4' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(off.reason).toBe('off');
|
||||
});
|
||||
|
||||
// ── Individual targeting ──────────────────────────────────────────────
|
||||
|
||||
it('matches individual user target', () => {
|
||||
const flag = makeFlag({
|
||||
individualTargets: { user_vip: 'on', user_blocked: 'off' },
|
||||
});
|
||||
const vip = evaluateFlag({ flag, ctx: { userId: 'user_vip' }, allFlags: [flag], segments: [] });
|
||||
expect(vip.reason).toBe('individual_target');
|
||||
expect(vip.value).toBe(true);
|
||||
|
||||
const blocked = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'user_blocked' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(blocked.reason).toBe('individual_target');
|
||||
expect(blocked.value).toBe(false);
|
||||
});
|
||||
|
||||
// ── Targeting rules ───────────────────────────────────────────────────
|
||||
|
||||
it('evaluates targeting rule with eq operator', () => {
|
||||
const flag = makeFlag({
|
||||
percentage: 0,
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [{ attribute: 'platform', operator: 'eq', values: ['ios'] }],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const ios = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', platform: 'ios' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(ios.reason).toBe('rule_match');
|
||||
expect(ios.value).toBe(true);
|
||||
|
||||
const android = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', platform: 'android' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(android.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('evaluates targeting rule with ends_with operator', () => {
|
||||
const flag = makeFlag({
|
||||
percentage: 0,
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const match = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', email: 'dev@bytelyst.com' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(match.reason).toBe('rule_match');
|
||||
|
||||
const noMatch = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', email: 'user@gmail.com' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(noMatch.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('evaluates targeting rule with in operator', () => {
|
||||
const flag = makeFlag({
|
||||
percentage: 0,
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [{ attribute: 'region', operator: 'in', values: ['us', 'eu'] }],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const us = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', region: 'us' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(us.reason).toBe('rule_match');
|
||||
const apac = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', region: 'apac' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(apac.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('evaluates targeting rule with regex operator', () => {
|
||||
const flag = makeFlag({
|
||||
percentage: 0,
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [{ attribute: 'email', operator: 'regex', values: ['^admin@'] }],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const match = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', email: 'admin@bytelyst.com' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(match.reason).toBe('rule_match');
|
||||
const noMatch = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', email: 'user@bytelyst.com' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(noMatch.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('evaluates targeting rule with semver_gte operator', () => {
|
||||
const flag = makeFlag({
|
||||
percentage: 0,
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [{ attribute: 'appVersion', operator: 'semver_gte', values: ['2.0.0'] }],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const v2 = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', appVersion: '2.1.0' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(v2.reason).toBe('rule_match');
|
||||
const v1 = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', appVersion: '1.9.0' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(v1.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('evaluates custom attribute in targeting rule', () => {
|
||||
const flag = makeFlag({
|
||||
percentage: 0,
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [{ attribute: 'plan', operator: 'eq', values: ['pro'] }],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const pro = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', custom: { plan: 'pro' } },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(pro.reason).toBe('rule_match');
|
||||
const free = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', custom: { plan: 'free' } },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(free.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('requires all clauses to match in a rule (AND logic)', () => {
|
||||
const flag = makeFlag({
|
||||
percentage: 0,
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [
|
||||
{ attribute: 'platform', operator: 'eq', values: ['ios'] },
|
||||
{ attribute: 'region', operator: 'eq', values: ['us'] },
|
||||
],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const both = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', platform: 'ios', region: 'us' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(both.reason).toBe('rule_match');
|
||||
const onlyPlatform = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', platform: 'ios', region: 'eu' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(onlyPlatform.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('first matching rule wins', () => {
|
||||
const flag = makeFlag({
|
||||
key: 'multi_rule',
|
||||
percentage: 0,
|
||||
variations: [
|
||||
{ key: 'on', value: true },
|
||||
{ key: 'off', value: false },
|
||||
{ key: 'beta', value: 'beta-ui' },
|
||||
],
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
|
||||
variationKey: 'beta',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
clauses: [{ attribute: 'platform', operator: 'eq', values: ['ios'] }],
|
||||
variationKey: 'on',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Matches both rules — first (beta) should win
|
||||
const result = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', email: 'dev@bytelyst.com', platform: 'ios' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(result.variationKey).toBe('beta');
|
||||
expect(result.value).toBe('beta-ui');
|
||||
});
|
||||
|
||||
// ── Segments ──────────────────────────────────────────────────────────
|
||||
|
||||
it('matches user in segment includedUsers', () => {
|
||||
const flag = makeFlag({ percentage: 0, segments: ['beta'] });
|
||||
const segment = makeSegment({ includedUsers: ['user_1'] });
|
||||
|
||||
const match = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'user_1' },
|
||||
allFlags: [flag],
|
||||
segments: [segment],
|
||||
});
|
||||
expect(match.reason).toBe('segment_match');
|
||||
expect(match.value).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes user in segment excludedUsers', () => {
|
||||
const flag = makeFlag({ percentage: 0, segments: ['beta'] });
|
||||
const segment = makeSegment({ includedUsers: ['user_1'], excludedUsers: ['user_1'] });
|
||||
|
||||
const result = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'user_1' },
|
||||
allFlags: [flag],
|
||||
segments: [segment],
|
||||
});
|
||||
expect(result.reason).toBe('off');
|
||||
});
|
||||
|
||||
it('matches segment via rules', () => {
|
||||
const flag = makeFlag({ percentage: 0, segments: ['internal'] });
|
||||
const segment = makeSegment({
|
||||
key: 'internal',
|
||||
rules: [{ attribute: 'email', operator: 'ends_with', values: ['@bytelyst.com'] }],
|
||||
});
|
||||
|
||||
const match = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', email: 'dev@bytelyst.com' },
|
||||
allFlags: [flag],
|
||||
segments: [segment],
|
||||
});
|
||||
expect(match.reason).toBe('segment_match');
|
||||
});
|
||||
|
||||
// ── Prerequisites ─────────────────────────────────────────────────────
|
||||
|
||||
it('fails when prerequisite flag is off', () => {
|
||||
const parent = makeFlag({ key: 'parent', enabled: false });
|
||||
const child = makeFlag({
|
||||
key: 'child',
|
||||
prerequisites: [{ flagKey: 'parent', variationKey: 'on' }],
|
||||
});
|
||||
const result = evaluateFlag({
|
||||
flag: child,
|
||||
ctx: { userId: 'u1' },
|
||||
allFlags: [parent, child],
|
||||
segments: [],
|
||||
});
|
||||
expect(result.reason).toBe('prerequisite_failed');
|
||||
});
|
||||
|
||||
it('succeeds when prerequisite flag is on with correct variation', () => {
|
||||
const parent = makeFlag({ key: 'parent', enabled: true });
|
||||
const child = makeFlag({
|
||||
key: 'child',
|
||||
prerequisites: [{ flagKey: 'parent', variationKey: 'on' }],
|
||||
});
|
||||
const result = evaluateFlag({
|
||||
flag: child,
|
||||
ctx: { userId: 'u1' },
|
||||
allFlags: [parent, child],
|
||||
segments: [],
|
||||
});
|
||||
expect(result.reason).not.toBe('prerequisite_failed');
|
||||
});
|
||||
|
||||
it('handles circular prerequisites gracefully', () => {
|
||||
const a = makeFlag({ key: 'a', prerequisites: [{ flagKey: 'b', variationKey: 'on' }] });
|
||||
const b = makeFlag({ key: 'b', prerequisites: [{ flagKey: 'a', variationKey: 'on' }] });
|
||||
const result = evaluateFlag({ flag: a, ctx: { userId: 'u1' }, allFlags: [a, b], segments: [] });
|
||||
expect(result.reason).toBe('error');
|
||||
});
|
||||
|
||||
it('fails when prerequisite flag does not exist', () => {
|
||||
const child = makeFlag({
|
||||
key: 'child',
|
||||
prerequisites: [{ flagKey: 'nonexistent', variationKey: 'on' }],
|
||||
});
|
||||
const result = evaluateFlag({
|
||||
flag: child,
|
||||
ctx: { userId: 'u1' },
|
||||
allFlags: [child],
|
||||
segments: [],
|
||||
});
|
||||
expect(result.reason).toBe('prerequisite_failed');
|
||||
});
|
||||
|
||||
// ── Scheduling ────────────────────────────────────────────────────────
|
||||
|
||||
it('returns schedule_inactive before enableAt', () => {
|
||||
const flag = makeFlag({
|
||||
schedule: { enableAt: '2026-06-01T00:00:00Z' },
|
||||
});
|
||||
const result = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
now: '2026-05-15T00:00:00Z',
|
||||
});
|
||||
expect(result.reason).toBe('schedule_inactive');
|
||||
});
|
||||
|
||||
it('returns active after enableAt', () => {
|
||||
const flag = makeFlag({
|
||||
schedule: { enableAt: '2026-06-01T00:00:00Z' },
|
||||
});
|
||||
const result = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
now: '2026-06-02T00:00:00Z',
|
||||
});
|
||||
expect(result.reason).not.toBe('schedule_inactive');
|
||||
});
|
||||
|
||||
it('returns schedule_inactive after disableAt', () => {
|
||||
const flag = makeFlag({
|
||||
schedule: { disableAt: '2026-06-01T00:00:00Z' },
|
||||
});
|
||||
const result = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1' },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
now: '2026-06-02T00:00:00Z',
|
||||
});
|
||||
expect(result.reason).toBe('schedule_inactive');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Gradual rollout ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getEffectivePercentage', () => {
|
||||
it('returns flag percentage when no gradual rollout', () => {
|
||||
const flag = makeFlag({ percentage: 50 });
|
||||
expect(getEffectivePercentage(flag, '2026-01-01T00:00:00Z')).toBe(50);
|
||||
});
|
||||
|
||||
it('returns startPercentage before rollout starts', () => {
|
||||
const flag = makeFlag({
|
||||
schedule: {
|
||||
gradualRollout: {
|
||||
startPercentage: 5,
|
||||
endPercentage: 100,
|
||||
startAt: '2026-04-01T00:00:00Z',
|
||||
endAt: '2026-04-08T00:00:00Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getEffectivePercentage(flag, '2026-03-31T00:00:00Z')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns endPercentage after rollout ends', () => {
|
||||
const flag = makeFlag({
|
||||
schedule: {
|
||||
gradualRollout: {
|
||||
startPercentage: 5,
|
||||
endPercentage: 100,
|
||||
startAt: '2026-04-01T00:00:00Z',
|
||||
endAt: '2026-04-08T00:00:00Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getEffectivePercentage(flag, '2026-04-09T00:00:00Z')).toBe(100);
|
||||
});
|
||||
|
||||
it('interpolates at midpoint', () => {
|
||||
const flag = makeFlag({
|
||||
schedule: {
|
||||
gradualRollout: {
|
||||
startPercentage: 0,
|
||||
endPercentage: 100,
|
||||
startAt: '2026-04-01T00:00:00Z',
|
||||
endAt: '2026-04-11T00:00:00Z', // 10 days
|
||||
},
|
||||
},
|
||||
});
|
||||
const pct = getEffectivePercentage(flag, '2026-04-06T00:00:00Z'); // 5 days in
|
||||
expect(pct).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multi-variate evaluation ────────────────────────────────────────────────
|
||||
|
||||
describe('multi-variate flags', () => {
|
||||
it('returns string variation value', () => {
|
||||
const flag = makeFlag({
|
||||
key: 'theme',
|
||||
flagType: 'string',
|
||||
variations: [
|
||||
{ key: 'dark', value: 'dark-theme' },
|
||||
{ key: 'light', value: 'light-theme' },
|
||||
],
|
||||
defaultVariation: 'dark',
|
||||
offVariation: 'light',
|
||||
});
|
||||
const result = evaluateFlag({ flag, ctx: { userId: 'u1' }, allFlags: [flag], segments: [] });
|
||||
expect(result.value).toBe('dark-theme');
|
||||
expect(result.variationKey).toBe('dark');
|
||||
});
|
||||
|
||||
it('returns JSON variation via targeting rule', () => {
|
||||
const flag = makeFlag({
|
||||
key: 'config',
|
||||
flagType: 'json',
|
||||
percentage: 0,
|
||||
variations: [
|
||||
{ key: 'v1', value: { maxItems: 10 } },
|
||||
{ key: 'v2', value: { maxItems: 50 } },
|
||||
],
|
||||
defaultVariation: 'v1',
|
||||
offVariation: 'v1',
|
||||
targetingRules: [
|
||||
{
|
||||
id: 'r1',
|
||||
clauses: [{ attribute: 'plan', operator: 'eq', values: ['pro'] }],
|
||||
variationKey: 'v2',
|
||||
rolloutPercentage: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
const pro = evaluateFlag({
|
||||
flag,
|
||||
ctx: { userId: 'u1', custom: { plan: 'pro' } },
|
||||
allFlags: [flag],
|
||||
segments: [],
|
||||
});
|
||||
expect(pro.value).toEqual({ maxItems: 50 });
|
||||
expect(pro.variationKey).toBe('v2');
|
||||
});
|
||||
});
|
||||
|
||||
// ── evaluateAllFlags ────────────────────────────────────────────────────────
|
||||
|
||||
describe('evaluateAllFlags', () => {
|
||||
it('evaluates all non-archived flags', () => {
|
||||
const flags = [
|
||||
makeFlag({ key: 'a', enabled: true }),
|
||||
makeFlag({ key: 'b', enabled: false }),
|
||||
makeFlag({ key: 'c', archived: true }),
|
||||
];
|
||||
const results = evaluateAllFlags(flags, { userId: 'u1' }, []);
|
||||
expect(Object.keys(results)).toEqual(['a', 'b']);
|
||||
expect(results['a'].reason).not.toBe('off');
|
||||
expect(results['b'].reason).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Version comparison ──────────────────────────────────────────────────────
|
||||
|
||||
describe('compareVersions', () => {
|
||||
it('equal versions return 0', () => {
|
||||
expect(compareVersions('17.2.1', '17.2.1')).toBe(0);
|
||||
});
|
||||
@ -155,34 +893,20 @@ describe('compareVersions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashUserFlag (deterministic A/B assignment)', () => {
|
||||
// Import the hash function from routes
|
||||
let hashUserFlag: (userId: string, flagKey: string) => number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('./routes.js');
|
||||
hashUserFlag = mod.hashUserFlag;
|
||||
});
|
||||
// ── Deterministic hashing ───────────────────────────────────────────────────
|
||||
|
||||
describe('hashUserFlag', () => {
|
||||
it('returns the same bucket for the same user+flag', () => {
|
||||
const a = hashUserFlag('user_123', 'dark_mode');
|
||||
const b = hashUserFlag('user_123', 'dark_mode');
|
||||
const c = hashUserFlag('user_123', 'dark_mode');
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
});
|
||||
|
||||
it('returns different buckets for different users', () => {
|
||||
const a = hashUserFlag('user_1', 'feature_x');
|
||||
const b = hashUserFlag('user_2', 'feature_x');
|
||||
// Statistically they should differ (not guaranteed, but extremely likely)
|
||||
// We test multiple pairs to be safe
|
||||
expect(a).not.toBe(b);
|
||||
const buckets = new Set<number>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
buckets.add(hashUserFlag(`user_${i}`, 'feature_x'));
|
||||
}
|
||||
// At least 5 distinct buckets out of 20 users
|
||||
expect(buckets.size).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
@ -201,7 +925,6 @@ describe('hashUserFlag (deterministic A/B assignment)', () => {
|
||||
});
|
||||
|
||||
it('produces roughly uniform distribution', () => {
|
||||
// Hash 1000 users, check that no bucket range gets >70% or <30%
|
||||
let below50 = 0;
|
||||
const total = 1000;
|
||||
for (let i = 0; i < total; i++) {
|
||||
|
||||
@ -1,29 +1,36 @@
|
||||
/**
|
||||
* Feature flags repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*
|
||||
* Three collections:
|
||||
* - feature_flags — flag documents (partition: /productId)
|
||||
* - flag_segments — user segments (partition: /productId)
|
||||
* - flag_audit_log — change audit trail (partition: /productId)
|
||||
*/
|
||||
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { FeatureFlagDoc } from './types.js';
|
||||
import type { FeatureFlagDoc, SegmentDoc, FlagAuditDoc } from './types.js';
|
||||
|
||||
function collection() {
|
||||
// ── Flags ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function flagCollection() {
|
||||
return getCollection<FeatureFlagDoc>('feature_flags', '/productId');
|
||||
}
|
||||
|
||||
export async function list(productId: string): Promise<FeatureFlagDoc[]> {
|
||||
return collection().findMany({
|
||||
return flagCollection().findMany({
|
||||
filter: { productId },
|
||||
sort: { key: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getByKey(key: string, productId: string): Promise<FeatureFlagDoc | null> {
|
||||
return collection().findOne({
|
||||
return flagCollection().findOne({
|
||||
filter: { productId, key },
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(doc: FeatureFlagDoc): Promise<FeatureFlagDoc> {
|
||||
return collection().create(doc);
|
||||
return flagCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
@ -31,7 +38,10 @@ export async function update(
|
||||
updates: Partial<FeatureFlagDoc>
|
||||
): Promise<FeatureFlagDoc | null> {
|
||||
try {
|
||||
return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
|
||||
return await flagCollection().update(id, id, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -39,9 +49,79 @@ export async function update(
|
||||
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await collection().delete(id, id);
|
||||
await flagCollection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Segments ────────────────────────────────────────────────────────────────
|
||||
|
||||
function segmentCollection() {
|
||||
return getCollection<SegmentDoc>('flag_segments', '/productId');
|
||||
}
|
||||
|
||||
export async function listSegments(productId: string): Promise<SegmentDoc[]> {
|
||||
return segmentCollection().findMany({
|
||||
filter: { productId },
|
||||
sort: { key: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSegmentByKey(key: string, productId: string): Promise<SegmentDoc | null> {
|
||||
return segmentCollection().findOne({
|
||||
filter: { productId, key },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSegment(doc: SegmentDoc): Promise<SegmentDoc> {
|
||||
return segmentCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function updateSegment(
|
||||
id: string,
|
||||
updates: Partial<SegmentDoc>
|
||||
): Promise<SegmentDoc | null> {
|
||||
try {
|
||||
return await segmentCollection().update(id, id, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeSegment(id: string): Promise<boolean> {
|
||||
try {
|
||||
await segmentCollection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audit Log ───────────────────────────────────────────────────────────────
|
||||
|
||||
function auditCollection() {
|
||||
return getCollection<FlagAuditDoc>('flag_audit_log', '/productId');
|
||||
}
|
||||
|
||||
export async function createAuditEntry(doc: FlagAuditDoc): Promise<FlagAuditDoc> {
|
||||
return auditCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function listAuditLog(
|
||||
productId: string,
|
||||
flagKey?: string,
|
||||
limit = 50
|
||||
): Promise<FlagAuditDoc[]> {
|
||||
const filter = flagKey ? { productId, flagKey } : { productId };
|
||||
|
||||
const all = await auditCollection().findMany({
|
||||
filter,
|
||||
sort: { timestamp: -1 },
|
||||
});
|
||||
return all.slice(0, limit);
|
||||
}
|
||||
|
||||
@ -1,112 +1,216 @@
|
||||
/**
|
||||
* Feature flags REST endpoints.
|
||||
* Feature flags REST endpoints — production-grade feature management.
|
||||
*
|
||||
* GET /flags — list all flags (admin)
|
||||
* GET /flags/poll — polling endpoint for clients (returns enabled flags)
|
||||
* GET /flags/:key — get single flag
|
||||
* POST /flags — create flag
|
||||
* PUT /flags/:key — update flag
|
||||
* DELETE /flags/:key — delete flag
|
||||
* POST /flags/kill — kill switch (disable all flags matching criteria)
|
||||
* ── Flag CRUD ────────────────────────────────────────────────────────────
|
||||
* GET /flags — list all flags (admin)
|
||||
* GET /flags/poll — legacy boolean polling (backward-compat)
|
||||
* POST /flags/evaluate — full evaluation with context (multi-variate)
|
||||
* GET /flags/:key — get single flag
|
||||
* POST /flags — create flag
|
||||
* PUT /flags/:key — update flag
|
||||
* DELETE /flags/:key — delete flag
|
||||
* POST /flags/:key/toggle — quick toggle enabled state
|
||||
* POST /flags/:key/archive — archive/unarchive flag
|
||||
* POST /flags/kill — kill switch (disable all matching flags)
|
||||
*
|
||||
* ── Segments ─────────────────────────────────────────────────────────────
|
||||
* GET /flags/segments — list segments
|
||||
* GET /flags/segments/:key — get segment
|
||||
* POST /flags/segments — create segment
|
||||
* PUT /flags/segments/:key — update segment
|
||||
* DELETE /flags/segments/:key — delete segment
|
||||
*
|
||||
* ── Audit ────────────────────────────────────────────────────────────────
|
||||
* GET /flags/audit — list audit entries
|
||||
* GET /flags/audit/:flagKey — audit for specific flag
|
||||
*
|
||||
* ── SSE ──────────────────────────────────────────────────────────────────
|
||||
* GET /flags/stream — real-time flag change stream
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { getRequestProductId } from '../../lib/request-context.js';
|
||||
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
|
||||
import { bus } from '../../lib/event-bus.js';
|
||||
import * as repo from './repository.js';
|
||||
import { CreateFlagSchema, UpdateFlagSchema, type FeatureFlagDoc } from './types.js';
|
||||
|
||||
/**
|
||||
* FNV-1a hash — deterministic 32-bit hash for user+flag assignment.
|
||||
* Same (userId, flagKey) pair always produces the same 0-99 bucket.
|
||||
*/
|
||||
function hashUserFlag(userId: string, flagKey: string): number {
|
||||
const input = `${userId}:${flagKey}`;
|
||||
let hash = 0x811c9dc5; // FNV offset basis
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193); // FNV prime
|
||||
}
|
||||
return (hash >>> 0) % 100; // 0-99 bucket
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two dot-separated version strings (e.g. "17.2.1" vs "18.0").
|
||||
* Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
*/
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const na = pa[i] ?? 0;
|
||||
const nb = pb[i] ?? 0;
|
||||
if (na < nb) return -1;
|
||||
if (na > nb) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
import {
|
||||
CreateFlagSchema,
|
||||
UpdateFlagSchema,
|
||||
CreateSegmentSchema,
|
||||
UpdateSegmentSchema,
|
||||
EvaluateSchema,
|
||||
type FeatureFlagDoc,
|
||||
type FlagAuditDoc,
|
||||
} from './types.js';
|
||||
import { evaluateAllFlags, hashUserFlag, compareVersions } from './evaluator.js';
|
||||
|
||||
// Re-export for backward compatibility with existing tests
|
||||
export { hashUserFlag, compareVersions };
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getActor(req: FastifyRequest): string {
|
||||
return req.jwtPayload?.sub ?? 'system';
|
||||
}
|
||||
|
||||
function diffChanges(before: Partial<FeatureFlagDoc>, after: Partial<FeatureFlagDoc>): string[] {
|
||||
const changes: string[] = [];
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
for (const k of keys) {
|
||||
const key = k as keyof FeatureFlagDoc;
|
||||
if (key === 'updatedAt' || key === 'updatedBy' || key === 'version') continue;
|
||||
const bVal = JSON.stringify(before[key]);
|
||||
const aVal = JSON.stringify(after[key]);
|
||||
if (bVal !== aVal) changes.push(key);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function recordAudit(
|
||||
productId: string,
|
||||
flagKey: string,
|
||||
action: FlagAuditDoc['action'],
|
||||
actor: string,
|
||||
before?: Partial<FeatureFlagDoc>,
|
||||
after?: Partial<FeatureFlagDoc>
|
||||
): Promise<void> {
|
||||
const changes = before && after ? diffChanges(before, after) : undefined;
|
||||
const doc: FlagAuditDoc = {
|
||||
id: `faudit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
productId,
|
||||
flagKey,
|
||||
action,
|
||||
actor,
|
||||
before,
|
||||
after,
|
||||
changes,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
await repo.createAuditEntry(doc);
|
||||
}
|
||||
|
||||
function buildDefaultVariations(flagType: string) {
|
||||
switch (flagType) {
|
||||
case 'boolean':
|
||||
return [
|
||||
{ key: 'on', value: true, description: 'Flag is on' },
|
||||
{ key: 'off', value: false, description: 'Flag is off' },
|
||||
];
|
||||
case 'string':
|
||||
return [{ key: 'default', value: '', description: 'Default string value' }];
|
||||
case 'number':
|
||||
return [{ key: 'default', value: 0, description: 'Default number value' }];
|
||||
case 'json':
|
||||
return [{ key: 'default', value: {}, description: 'Default JSON value' }];
|
||||
default:
|
||||
return [
|
||||
{ key: 'on', value: true },
|
||||
{ key: 'off', value: false },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE connected clients ───────────────────────────────────────────────────
|
||||
|
||||
interface SseClient {
|
||||
productId: string;
|
||||
write: (data: string) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const sseClients: Set<SseClient> = new Set();
|
||||
|
||||
function broadcastFlagChange(productId: string, flagKey: string, action: string): void {
|
||||
const event = JSON.stringify({
|
||||
type: 'flag_change',
|
||||
flagKey,
|
||||
action,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
for (const client of sseClients) {
|
||||
if (client.productId === productId) {
|
||||
try {
|
||||
client.write(`data: ${event}\n\n`);
|
||||
} catch {
|
||||
sseClients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function flagRoutes(app: FastifyInstance) {
|
||||
// List all flags
|
||||
// ── List all flags ──────────────────────────────────────────────────────
|
||||
app.get('/flags', async req => {
|
||||
const productId = getRequestProductId(req);
|
||||
return { flags: await repo.list(productId) };
|
||||
const { archived, tag } = req.query as { archived?: string; tag?: string };
|
||||
let flags = await repo.list(productId);
|
||||
|
||||
if (archived !== 'true') {
|
||||
flags = flags.filter(f => !f.archived);
|
||||
}
|
||||
if (tag) {
|
||||
flags = flags.filter(f => f.tags?.includes(tag));
|
||||
}
|
||||
|
||||
return { flags, total: flags.length };
|
||||
});
|
||||
|
||||
// Polling endpoint for clients
|
||||
// ?userId=xxx — deterministic hash ensures same user always gets same flag assignment
|
||||
// ?platform=xxx — filter flags by platform (e.g. ios, android, web, desktop)
|
||||
// ?region=xxx — filter flags by region (e.g. us, eu, apac)
|
||||
// ?osVersion=xxx — filter flags by OS version (e.g. 17.2.1)
|
||||
// ── Legacy boolean polling (backward-compat) ───────────────────────────
|
||||
app.get('/flags/poll', async req => {
|
||||
const productId = getRequestProductId(req);
|
||||
const { platform, userId, region, osVersion } = req.query as {
|
||||
const query = req.query as {
|
||||
platform?: string;
|
||||
userId?: string;
|
||||
region?: string;
|
||||
osVersion?: string;
|
||||
appVersion?: string;
|
||||
};
|
||||
const all = await repo.list(productId);
|
||||
const active = all.filter(f => {
|
||||
if (!f.enabled) return false;
|
||||
// Platform targeting: flag specifies which platforms it applies to
|
||||
if (f.platforms.length > 0 && platform && !f.platforms.includes(platform)) return false;
|
||||
// Region targeting: flag specifies which regions it applies to
|
||||
if (f.regions?.length > 0 && region && !f.regions.includes(region)) return false;
|
||||
// OS version targeting: flag specifies version ranges per platform
|
||||
if (f.osVersions?.length > 0 && platform && osVersion) {
|
||||
const rule = f.osVersions.find(r => r.platform === platform);
|
||||
if (rule) {
|
||||
if (rule.minVersion && compareVersions(osVersion, rule.minVersion) < 0) return false;
|
||||
if (rule.maxVersion && compareVersions(osVersion, rule.maxVersion) > 0) return false;
|
||||
} else {
|
||||
// osVersions defined but no rule for this platform — skip this flag
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const allFlags = await repo.list(productId);
|
||||
const segments = await repo.listSegments(productId);
|
||||
|
||||
const results = evaluateAllFlags(
|
||||
allFlags,
|
||||
{
|
||||
userId: query.userId,
|
||||
platform: query.platform,
|
||||
region: query.region,
|
||||
osVersion: query.osVersion,
|
||||
appVersion: query.appVersion,
|
||||
},
|
||||
segments
|
||||
);
|
||||
|
||||
// Legacy format: { flags: { key: boolean }, productId }
|
||||
const flags: Record<string, boolean> = {};
|
||||
for (const f of active) {
|
||||
if (f.percentage >= 100) {
|
||||
flags[f.key] = true;
|
||||
} else if (f.percentage <= 0) {
|
||||
flags[f.key] = false;
|
||||
} else if (userId) {
|
||||
// Deterministic: same user+flag always gets the same result
|
||||
flags[f.key] = hashUserFlag(userId, f.key) < f.percentage;
|
||||
} else {
|
||||
// Fallback for anonymous requests (no userId)
|
||||
flags[f.key] = Math.random() * 100 < f.percentage;
|
||||
}
|
||||
for (const [key, result] of Object.entries(results)) {
|
||||
flags[key] =
|
||||
result.reason !== 'off' &&
|
||||
result.reason !== 'prerequisite_failed' &&
|
||||
result.reason !== 'schedule_inactive' &&
|
||||
result.reason !== 'error';
|
||||
}
|
||||
return { flags, productId };
|
||||
});
|
||||
|
||||
// Get flag
|
||||
// ── Full evaluation (multi-variate) ────────────────────────────────────
|
||||
app.post('/flags/evaluate', async req => {
|
||||
const productId = getRequestProductId(req);
|
||||
const parsed = EvaluateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const allFlags = await repo.list(productId);
|
||||
const segments = await repo.listSegments(productId);
|
||||
const results = evaluateAllFlags(allFlags, parsed.data, segments);
|
||||
|
||||
return { evaluations: results, productId };
|
||||
});
|
||||
|
||||
// ── Get single flag ────────────────────────────────────────────────────
|
||||
app.get('/flags/:key', async req => {
|
||||
const { key } = req.params as { key: string };
|
||||
const productId = getRequestProductId(req);
|
||||
@ -115,7 +219,7 @@ export async function flagRoutes(app: FastifyInstance) {
|
||||
return flag;
|
||||
});
|
||||
|
||||
// Create flag
|
||||
// ── Create flag ────────────────────────────────────────────────────────
|
||||
app.post('/flags', async (req, reply) => {
|
||||
const productId = getRequestProductId(req);
|
||||
const parsed = CreateFlagSchema.safeParse(req.body);
|
||||
@ -126,20 +230,56 @@ export async function flagRoutes(app: FastifyInstance) {
|
||||
const existing = await repo.getByKey(input.key, productId);
|
||||
if (existing) throw new BadRequestError(`Flag "${input.key}" already exists`);
|
||||
|
||||
const variations = input.variations ?? buildDefaultVariations(input.flagType);
|
||||
const defaultVariation = input.defaultVariation ?? variations[0]?.key ?? 'on';
|
||||
const offVariation =
|
||||
input.offVariation ?? (input.flagType === 'boolean' ? 'off' : (variations[0]?.key ?? 'off'));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const actor = getActor(req);
|
||||
const doc: FeatureFlagDoc = {
|
||||
id: `flag_${productId}_${input.key}`,
|
||||
productId,
|
||||
...input,
|
||||
key: input.key,
|
||||
flagType: input.flagType,
|
||||
enabled: input.enabled,
|
||||
archived: false,
|
||||
description: input.description,
|
||||
tags: input.tags,
|
||||
variations,
|
||||
defaultVariation,
|
||||
offVariation,
|
||||
platforms: input.platforms,
|
||||
regions: input.regions,
|
||||
osVersions: input.osVersions,
|
||||
segments: input.segments,
|
||||
targetingRules: input.targetingRules,
|
||||
individualTargets: input.individualTargets,
|
||||
prerequisites: input.prerequisites,
|
||||
percentage: input.percentage,
|
||||
schedule: input.schedule,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const created = await repo.create(doc);
|
||||
await recordAudit(productId, input.key, 'created', actor, undefined, created);
|
||||
broadcastFlagChange(productId, input.key, 'created');
|
||||
|
||||
try {
|
||||
bus.emit('flag.created', { productId, flagKey: input.key, actor });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// Update flag
|
||||
// ── Update flag ────────────────────────────────────────────────────────
|
||||
app.put('/flags/:key', async req => {
|
||||
const { key } = req.params as { key: string };
|
||||
const productId = getRequestProductId(req);
|
||||
@ -150,37 +290,262 @@ export async function flagRoutes(app: FastifyInstance) {
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const updated = await repo.update(flag.id, parsed.data);
|
||||
|
||||
const actor = getActor(req);
|
||||
const before = { ...flag };
|
||||
const updates = { ...parsed.data, updatedBy: actor, version: flag.version + 1 };
|
||||
const updated = await repo.update(flag.id, updates);
|
||||
if (!updated) throw new NotFoundError('Flag update failed');
|
||||
|
||||
await recordAudit(productId, key, 'updated', actor, before, updated);
|
||||
broadcastFlagChange(productId, key, 'updated');
|
||||
|
||||
try {
|
||||
bus.emit('flag.updated', {
|
||||
productId,
|
||||
flagKey: key,
|
||||
actor,
|
||||
changes: diffChanges(before, updated),
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Delete flag
|
||||
// ── Toggle flag ────────────────────────────────────────────────────────
|
||||
app.post('/flags/:key/toggle', async req => {
|
||||
const { key } = req.params as { key: string };
|
||||
const productId = getRequestProductId(req);
|
||||
const flag = await repo.getByKey(key, productId);
|
||||
if (!flag) throw new NotFoundError('Flag not found');
|
||||
|
||||
const actor = getActor(req);
|
||||
const before = { ...flag };
|
||||
const updated = await repo.update(flag.id, {
|
||||
enabled: !flag.enabled,
|
||||
updatedBy: actor,
|
||||
version: flag.version + 1,
|
||||
});
|
||||
if (!updated) throw new NotFoundError('Flag toggle failed');
|
||||
|
||||
await recordAudit(productId, key, 'toggled', actor, before, updated);
|
||||
broadcastFlagChange(productId, key, 'toggled');
|
||||
|
||||
try {
|
||||
bus.emit('flag.toggled', {
|
||||
productId,
|
||||
flagId: flag.id,
|
||||
flagKey: key,
|
||||
enabled: !flag.enabled,
|
||||
actor,
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// ── Archive/unarchive flag ─────────────────────────────────────────────
|
||||
app.post('/flags/:key/archive', async req => {
|
||||
const { key } = req.params as { key: string };
|
||||
const { archived } = (req.body as { archived?: boolean }) ?? {};
|
||||
const productId = getRequestProductId(req);
|
||||
const flag = await repo.getByKey(key, productId);
|
||||
if (!flag) throw new NotFoundError('Flag not found');
|
||||
|
||||
const newArchived = archived ?? !flag.archived;
|
||||
const actor = getActor(req);
|
||||
const before = { ...flag };
|
||||
const updated = await repo.update(flag.id, {
|
||||
archived: newArchived,
|
||||
enabled: newArchived ? false : flag.enabled,
|
||||
updatedBy: actor,
|
||||
version: flag.version + 1,
|
||||
});
|
||||
if (!updated) throw new NotFoundError('Flag archive failed');
|
||||
|
||||
await recordAudit(productId, key, 'archived', actor, before, updated);
|
||||
broadcastFlagChange(productId, key, newArchived ? 'archived' : 'unarchived');
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// ── Delete flag ────────────────────────────────────────────────────────
|
||||
app.delete('/flags/:key', async req => {
|
||||
const { key } = req.params as { key: string };
|
||||
const productId = getRequestProductId(req);
|
||||
const flag = await repo.getByKey(key, productId);
|
||||
if (!flag) throw new NotFoundError('Flag not found');
|
||||
|
||||
const actor = getActor(req);
|
||||
await repo.remove(flag.id);
|
||||
await recordAudit(productId, key, 'deleted', actor, flag, undefined);
|
||||
broadcastFlagChange(productId, key, 'deleted');
|
||||
|
||||
try {
|
||||
bus.emit('flag.deleted', { productId, flagKey: key, actor });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Kill switch — disable all flags matching optional platform filter
|
||||
// ── Kill switch ────────────────────────────────────────────────────────
|
||||
app.post('/flags/kill', async req => {
|
||||
const { platform, keys } = req.body as { platform?: string; keys?: string[] };
|
||||
const { platform, keys, tags } = req.body as {
|
||||
platform?: string;
|
||||
keys?: string[];
|
||||
tags?: string[];
|
||||
};
|
||||
const productId = getRequestProductId(req);
|
||||
const actor = getActor(req);
|
||||
const all = await repo.list(productId);
|
||||
const toDisable = all.filter(f => {
|
||||
if (!f.enabled) return false;
|
||||
if (keys && keys.length > 0 && !keys.includes(f.key)) return false;
|
||||
if (platform && f.platforms.length > 0 && !f.platforms.includes(platform)) return false;
|
||||
return f.enabled;
|
||||
if (tags && tags.length > 0 && !tags.some(t => f.tags?.includes(t))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const disabled: string[] = [];
|
||||
for (const f of toDisable) {
|
||||
await repo.update(f.id, { enabled: false });
|
||||
const before = { ...f };
|
||||
await repo.update(f.id, { enabled: false, updatedBy: actor, version: f.version + 1 });
|
||||
await recordAudit(productId, f.key, 'kill_switch', actor, before, { ...f, enabled: false });
|
||||
broadcastFlagChange(productId, f.key, 'kill_switch');
|
||||
disabled.push(f.key);
|
||||
}
|
||||
|
||||
try {
|
||||
bus.emit('flag.kill_switch', { productId, disabled, actor });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
return { disabled, count: disabled.length };
|
||||
});
|
||||
|
||||
// ── Segments CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
app.get('/flags/segments', async req => {
|
||||
const productId = getRequestProductId(req);
|
||||
const segments = await repo.listSegments(productId);
|
||||
return { segments, total: segments.length };
|
||||
});
|
||||
|
||||
app.get('/flags/segments/:key', async req => {
|
||||
const { key } = req.params as { key: string };
|
||||
const productId = getRequestProductId(req);
|
||||
const segment = await repo.getSegmentByKey(key, productId);
|
||||
if (!segment) throw new NotFoundError('Segment not found');
|
||||
return segment;
|
||||
});
|
||||
|
||||
app.post('/flags/segments', async (req, reply) => {
|
||||
const productId = getRequestProductId(req);
|
||||
const parsed = CreateSegmentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const input = parsed.data;
|
||||
const existing = await repo.getSegmentByKey(input.key, productId);
|
||||
if (existing) throw new BadRequestError(`Segment "${input.key}" already exists`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const doc = {
|
||||
id: `seg_${productId}_${input.key}`,
|
||||
productId,
|
||||
...input,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const created = await repo.createSegment(doc);
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
app.put('/flags/segments/:key', async req => {
|
||||
const { key } = req.params as { key: string };
|
||||
const productId = getRequestProductId(req);
|
||||
const segment = await repo.getSegmentByKey(key, productId);
|
||||
if (!segment) throw new NotFoundError('Segment not found');
|
||||
|
||||
const parsed = UpdateSegmentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const updated = await repo.updateSegment(segment.id, parsed.data);
|
||||
if (!updated) throw new NotFoundError('Segment update failed');
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/flags/segments/:key', async req => {
|
||||
const { key } = req.params as { key: string };
|
||||
const productId = getRequestProductId(req);
|
||||
const segment = await repo.getSegmentByKey(key, productId);
|
||||
if (!segment) throw new NotFoundError('Segment not found');
|
||||
await repo.removeSegment(segment.id);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/flags/audit', async req => {
|
||||
const productId = getRequestProductId(req);
|
||||
const { limit } = req.query as { limit?: string };
|
||||
const entries = await repo.listAuditLog(productId, undefined, limit ? parseInt(limit, 10) : 50);
|
||||
return { entries, total: entries.length };
|
||||
});
|
||||
|
||||
app.get('/flags/audit/:flagKey', async req => {
|
||||
const { flagKey } = req.params as { flagKey: string };
|
||||
const productId = getRequestProductId(req);
|
||||
const { limit } = req.query as { limit?: string };
|
||||
const entries = await repo.listAuditLog(productId, flagKey, limit ? parseInt(limit, 10) : 50);
|
||||
return { entries, total: entries.length };
|
||||
});
|
||||
|
||||
// ── SSE stream ────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/flags/stream', async (req, reply) => {
|
||||
const productId = getRequestProductId(req);
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
reply.hijack();
|
||||
|
||||
const client: SseClient = {
|
||||
productId,
|
||||
write: (data: string) => reply.raw.write(data),
|
||||
close: () => reply.raw.end(),
|
||||
};
|
||||
sseClients.add(client);
|
||||
|
||||
// Send initial connected event
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', productId })}\n\n`);
|
||||
|
||||
// Heartbeat every 30s
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
reply.raw.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
sseClients.delete(client);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
req.raw.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
sseClients.delete(client);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -257,15 +257,30 @@ export async function seedDefaultFlags(logger?: { info: (msg: string) => void })
|
||||
id: `flag_${productId}_${def.key}`,
|
||||
productId,
|
||||
key: def.key,
|
||||
flagType: 'boolean',
|
||||
enabled: def.enabled,
|
||||
archived: false,
|
||||
description: def.description,
|
||||
tags: [],
|
||||
variations: [
|
||||
{ key: 'on', value: true, description: 'Flag is on' },
|
||||
{ key: 'off', value: false, description: 'Flag is off' },
|
||||
],
|
||||
defaultVariation: 'on',
|
||||
offVariation: 'off',
|
||||
platforms: def.platforms,
|
||||
regions: [],
|
||||
osVersions: [],
|
||||
segments: [],
|
||||
targetingRules: [],
|
||||
individualTargets: {},
|
||||
prerequisites: [],
|
||||
percentage: def.percentage,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
await repo.create(doc);
|
||||
|
||||
@ -1,60 +1,329 @@
|
||||
/**
|
||||
* Feature flags types — extends kill switch to full feature flags.
|
||||
* Ported from admin dashboard + desktop + mobile kill switch implementations.
|
||||
* Feature flags types — production-grade feature management system.
|
||||
*
|
||||
* Supports:
|
||||
* - Multi-variate flags (boolean, string, number, JSON)
|
||||
* - Targeting rules with attribute matching (AND/OR)
|
||||
* - Percentage rollouts (deterministic via FNV-1a)
|
||||
* - Platform / region / OS version targeting
|
||||
* - User segment targeting with CRUD
|
||||
* - Prerequisite flag dependencies
|
||||
* - Scheduled flag activation/deactivation
|
||||
* - Full audit trail (before/after snapshots)
|
||||
* - SSE streaming for real-time updates
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Flag value types ────────────────────────────────────────────────────────
|
||||
|
||||
export type FlagType = 'boolean' | 'string' | 'number' | 'json';
|
||||
|
||||
/** A single variation for multi-variate flags. */
|
||||
export interface FlagVariation {
|
||||
key: string;
|
||||
value: boolean | string | number | Record<string, unknown>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Targeting ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type TargetingOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'contains'
|
||||
| 'not_contains'
|
||||
| 'starts_with'
|
||||
| 'ends_with'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'in'
|
||||
| 'not_in'
|
||||
| 'semver_gt'
|
||||
| 'semver_gte'
|
||||
| 'semver_lt'
|
||||
| 'semver_lte'
|
||||
| 'semver_eq'
|
||||
| 'regex';
|
||||
|
||||
export interface TargetingClause {
|
||||
attribute: string;
|
||||
operator: TargetingOperator;
|
||||
values: (string | number | boolean)[];
|
||||
}
|
||||
|
||||
export interface TargetingRule {
|
||||
id: string;
|
||||
description?: string;
|
||||
clauses: TargetingClause[];
|
||||
variationKey: string;
|
||||
rolloutPercentage: number;
|
||||
}
|
||||
|
||||
export interface OsVersionRange {
|
||||
platform: string;
|
||||
minVersion?: string;
|
||||
maxVersion?: string;
|
||||
}
|
||||
|
||||
// ── Scheduling ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FlagSchedule {
|
||||
enableAt?: string;
|
||||
disableAt?: string;
|
||||
gradualRollout?: {
|
||||
startPercentage: number;
|
||||
endPercentage: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Prerequisites ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface FlagPrerequisite {
|
||||
flagKey: string;
|
||||
variationKey: string;
|
||||
}
|
||||
|
||||
// ── Flag Document ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface FeatureFlagDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
key: string;
|
||||
flagType: FlagType;
|
||||
enabled: boolean;
|
||||
archived: boolean;
|
||||
description: string;
|
||||
tags: string[];
|
||||
|
||||
variations: FlagVariation[];
|
||||
defaultVariation: string;
|
||||
offVariation: string;
|
||||
|
||||
platforms: string[];
|
||||
regions: string[];
|
||||
osVersions: OsVersionRange[];
|
||||
segments: string[];
|
||||
targetingRules: TargetingRule[];
|
||||
individualTargets: Record<string, string>;
|
||||
prerequisites: FlagPrerequisite[];
|
||||
percentage: number;
|
||||
|
||||
schedule?: FlagSchedule;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ── Segment Document ────────────────────────────────────────────────────────
|
||||
|
||||
export interface SegmentDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rules: TargetingClause[];
|
||||
includedUsers: string[];
|
||||
excludedUsers: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Audit Log ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FlagAuditDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
flagKey: string;
|
||||
action: 'created' | 'updated' | 'deleted' | 'toggled' | 'archived' | 'scheduled' | 'kill_switch';
|
||||
actor: string;
|
||||
before?: Partial<FeatureFlagDoc>;
|
||||
after?: Partial<FeatureFlagDoc>;
|
||||
changes?: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ── Evaluation Context ──────────────────────────────────────────────────────
|
||||
|
||||
export interface EvaluationContext {
|
||||
userId?: string;
|
||||
platform?: string;
|
||||
region?: string;
|
||||
osVersion?: string;
|
||||
appVersion?: string;
|
||||
email?: string;
|
||||
custom?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface EvaluationResult {
|
||||
key: string;
|
||||
value: boolean | string | number | Record<string, unknown>;
|
||||
variationKey: string;
|
||||
reason:
|
||||
| 'off'
|
||||
| 'prerequisite_failed'
|
||||
| 'individual_target'
|
||||
| 'rule_match'
|
||||
| 'segment_match'
|
||||
| 'percentage_rollout'
|
||||
| 'default'
|
||||
| 'schedule_inactive'
|
||||
| 'error';
|
||||
}
|
||||
|
||||
// ── Zod Schemas ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const OsVersionRangeSchema = z.object({
|
||||
platform: z.string().min(1),
|
||||
minVersion: z.string().optional(),
|
||||
maxVersion: z.string().optional(),
|
||||
});
|
||||
|
||||
const TargetingClauseSchema = z.object({
|
||||
attribute: z.string().min(1),
|
||||
operator: z.enum([
|
||||
'eq',
|
||||
'neq',
|
||||
'contains',
|
||||
'not_contains',
|
||||
'starts_with',
|
||||
'ends_with',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'in',
|
||||
'not_in',
|
||||
'semver_gt',
|
||||
'semver_gte',
|
||||
'semver_lt',
|
||||
'semver_lte',
|
||||
'semver_eq',
|
||||
'regex',
|
||||
]),
|
||||
values: z.array(z.union([z.string(), z.number(), z.boolean()])).min(1),
|
||||
});
|
||||
|
||||
const TargetingRuleSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
clauses: z.array(TargetingClauseSchema).min(1),
|
||||
variationKey: z.string().min(1),
|
||||
rolloutPercentage: z.number().min(0).max(100).default(100),
|
||||
});
|
||||
|
||||
const FlagVariationSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
value: z.union([z.boolean(), z.string(), z.number(), z.record(z.unknown())]),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const FlagScheduleSchema = z.object({
|
||||
enableAt: z.string().datetime().optional(),
|
||||
disableAt: z.string().datetime().optional(),
|
||||
gradualRollout: z
|
||||
.object({
|
||||
startPercentage: z.number().min(0).max(100),
|
||||
endPercentage: z.number().min(0).max(100),
|
||||
startAt: z.string().datetime(),
|
||||
endAt: z.string().datetime(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const FlagPrerequisiteSchema = z.object({
|
||||
flagKey: z.string().min(1),
|
||||
variationKey: z.string().min(1),
|
||||
});
|
||||
|
||||
export const CreateFlagSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9_]+$/),
|
||||
.regex(/^[a-z0-9_.]+$/),
|
||||
flagType: z.enum(['boolean', 'string', 'number', 'json']).default('boolean'),
|
||||
enabled: z.boolean().default(true),
|
||||
description: z.string().default(''),
|
||||
tags: z.array(z.string()).default([]),
|
||||
|
||||
variations: z.array(FlagVariationSchema).optional(),
|
||||
defaultVariation: z.string().optional(),
|
||||
offVariation: z.string().optional(),
|
||||
|
||||
platforms: z.array(z.string()).default([]),
|
||||
regions: z.array(z.string()).default([]),
|
||||
osVersions: z.array(OsVersionRangeSchema).default([]),
|
||||
segments: z.array(z.string()).default([]),
|
||||
targetingRules: z.array(TargetingRuleSchema).default([]),
|
||||
individualTargets: z.record(z.string()).default({}),
|
||||
prerequisites: z.array(FlagPrerequisiteSchema).default([]),
|
||||
percentage: z.number().min(0).max(100).default(100),
|
||||
|
||||
schedule: FlagScheduleSchema.optional(),
|
||||
});
|
||||
|
||||
export const UpdateFlagSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
|
||||
variations: z.array(FlagVariationSchema).optional(),
|
||||
defaultVariation: z.string().optional(),
|
||||
offVariation: z.string().optional(),
|
||||
|
||||
platforms: z.array(z.string()).optional(),
|
||||
regions: z.array(z.string()).optional(),
|
||||
osVersions: z.array(OsVersionRangeSchema).optional(),
|
||||
segments: z.array(z.string()).optional(),
|
||||
targetingRules: z.array(TargetingRuleSchema).optional(),
|
||||
individualTargets: z.record(z.string()).optional(),
|
||||
prerequisites: z.array(FlagPrerequisiteSchema).optional(),
|
||||
percentage: z.number().min(0).max(100).optional(),
|
||||
archived: z.boolean().optional(),
|
||||
|
||||
schedule: FlagScheduleSchema.optional(),
|
||||
});
|
||||
|
||||
export const CreateSegmentSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9_]+$/),
|
||||
name: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
rules: z.array(TargetingClauseSchema).default([]),
|
||||
includedUsers: z.array(z.string()).default([]),
|
||||
excludedUsers: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const UpdateSegmentSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
rules: z.array(TargetingClauseSchema).optional(),
|
||||
includedUsers: z.array(z.string()).optional(),
|
||||
excludedUsers: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const EvaluateSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
platform: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
osVersion: z.string().optional(),
|
||||
appVersion: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
custom: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
|
||||
});
|
||||
|
||||
export type CreateFlagInput = z.infer<typeof CreateFlagSchema>;
|
||||
export type UpdateFlagInput = z.infer<typeof UpdateFlagSchema>;
|
||||
export type CreateSegmentInput = z.infer<typeof CreateSegmentSchema>;
|
||||
export type UpdateSegmentInput = z.infer<typeof UpdateSegmentSchema>;
|
||||
export type EvaluateInput = z.infer<typeof EvaluateSchema>;
|
||||
|
||||
@ -14,6 +14,10 @@ export const WEBHOOK_EVENTS = [
|
||||
'referral.completed',
|
||||
'waitlist.joined',
|
||||
'flag.toggled',
|
||||
'flag.created',
|
||||
'flag.updated',
|
||||
'flag.deleted',
|
||||
'flag.kill_switch',
|
||||
'license.activated',
|
||||
'license.expired',
|
||||
] as const;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user