@beignet/core
Version:
Core framework primitives for Beignet
608 lines (551 loc) • 14.9 kB
text/typescript
/**
* @beignet/core/flags
*
* Provider-neutral feature flag primitives for Beignet applications.
*/
type MaybePromise<T> = T | Promise<T>;
/**
* JSON-compatible value used by object flags and flag metadata.
*/
export type FlagJsonValue =
| null
| boolean
| number
| string
| readonly FlagJsonValue[]
| { readonly [key: string]: FlagJsonValue };
/**
* Object-valued flags intentionally exclude scalar JSON values so the flag
* kind remains predictable.
*/
export type FlagObjectValue =
| readonly FlagJsonValue[]
| { readonly [key: string]: FlagJsonValue };
/**
* Value kinds supported by Beignet flags.
*/
export type FlagValue = boolean | string | number | FlagObjectValue;
/**
* Stable feature flag key.
*/
export type FlagKey = string;
/**
* Feature flag value kind.
*/
export type FlagValueKind = "boolean" | "string" | "number" | "object";
/**
* Subject used for provider-neutral flag targeting.
*/
export type FlagSubject = {
type: string;
id: string;
};
/**
* Tenant or account used for provider-neutral flag targeting.
*/
export type FlagTenant = {
id: string;
slug?: string;
};
/**
* Attribute value accepted by the provider-neutral flag context.
*/
export type FlagAttributeValue =
| null
| boolean
| number
| string
| readonly FlagAttributeValue[]
| { readonly [key: string]: FlagAttributeValue };
/**
* Context supplied to flag evaluations, exposure recording, and tracking.
*/
export type FlagEvaluationContext = {
/**
* Stable key used by providers for rollout bucketing and targeting.
*/
targetingKey: string;
/**
* Actor, user, service, tenant, or account being targeted.
*/
subject?: FlagSubject;
/**
* Tenant/account scope, when available.
*/
tenant?: FlagTenant;
/**
* Provider-neutral targeting attributes. Keep private data out unless the
* selected provider and retention policy are approved for it.
*/
attributes?: Record<string, FlagAttributeValue>;
/**
* Attribute keys that should be treated as private by providers and
* instrumentation.
*/
privateAttributes?: readonly string[];
requestId?: string;
traceId?: string;
spanId?: string;
parentSpanId?: string;
traceparent?: string;
};
/**
* Flag definition registered through `defineFlags(...)`.
*/
export type FlagDef<TValue extends FlagValue = FlagValue> = {
kind: "flag";
key: FlagKey;
valueKind: FlagValueKindForValue<TValue>;
defaultValue: TValue;
description?: string;
metadata?: Record<string, unknown>;
};
/**
* Infer the flag value type from a flag definition.
*/
export type InferFlagValue<TFlag> =
TFlag extends FlagDef<infer TValue> ? TValue : never;
/**
* Map a value type to its runtime flag kind.
*/
export type FlagValueKindForValue<TValue extends FlagValue> =
TValue extends boolean
? "boolean"
: TValue extends string
? "string"
: TValue extends number
? "number"
: "object";
/**
* Options accepted when declaring a flag.
*/
export type DefineFlagOptions<TValue extends FlagValue> = {
default: TValue;
description?: string;
metadata?: Record<string, unknown>;
};
/**
* Registry returned by `defineFlags(...)`.
*/
export type FlagRegistry = Record<string, FlagDef>;
/**
* Reason a flag received its value.
*/
export type FlagEvaluationReason =
| "static"
| "targeting_match"
| "default"
| "error"
| "unknown"
| (string & {});
/**
* Normalized error summary for failed flag provider evaluations.
*/
export type FlagEvaluationError = {
message: string;
code?: string;
};
/**
* Options accepted by flag evaluations.
*/
export type FlagEvaluationOptions<TValue extends FlagValue = FlagValue> = {
context?: FlagEvaluationContext;
/**
* Override the flag declaration default for this call.
*/
defaultValue?: TValue;
/**
* Explicitly record an exposure after evaluation succeeds. Plain evaluation
* does not record exposure by default.
*/
expose?: boolean;
};
/**
* Normalized flag evaluation details.
*/
export type FlagEvaluationDetails<TValue extends FlagValue = FlagValue> = {
key: string;
value: TValue;
defaultValue: TValue;
valueKind: FlagValueKindForValue<TValue>;
reason: FlagEvaluationReason;
defaulted: boolean;
variant?: string;
metadata?: Record<string, unknown>;
error?: FlagEvaluationError;
};
/**
* Options accepted by explicit exposure recording.
*/
export type FlagExposureOptions = {
context?: FlagEvaluationContext;
value?: FlagValue;
variant?: string;
metadata?: Record<string, unknown>;
};
/**
* Options accepted by tracking calls.
*/
export type FlagTrackOptions = {
context?: FlagEvaluationContext;
value?: number;
metadata?: Record<string, unknown>;
};
/**
* App-facing feature flag port.
*/
export type FlagsPort = {
evaluate<TValue extends FlagValue>(
flag: FlagDef<TValue>,
options?: FlagEvaluationOptions<TValue>,
): Promise<TValue>;
details<TValue extends FlagValue>(
flag: FlagDef<TValue>,
options?: FlagEvaluationOptions<TValue>,
): Promise<FlagEvaluationDetails<TValue>>;
recordExposure<TValue extends FlagValue>(
flag: FlagDef<TValue>,
options?: FlagExposureOptions,
): Promise<void>;
track(event: string, options?: FlagTrackOptions): Promise<void>;
};
/**
* Flag evaluation observation emitted by memory/static adapters.
*/
export type FlagEvaluationObservation<TValue extends FlagValue = FlagValue> = {
source: "evaluate" | "details";
flag: FlagDef<TValue>;
context?: FlagEvaluationContext;
details: FlagEvaluationDetails<TValue>;
durationMs: number;
requestId?: string;
traceId?: string;
spanId?: string;
parentSpanId?: string;
traceparent?: string;
};
/**
* Explicit flag exposure observation.
*/
export type FlagExposureObservation<TValue extends FlagValue = FlagValue> = {
flag: FlagDef<TValue>;
context?: FlagEvaluationContext;
value?: FlagValue;
variant?: string;
metadata?: Record<string, unknown>;
requestId?: string;
traceId?: string;
spanId?: string;
parentSpanId?: string;
traceparent?: string;
};
/**
* Flag tracking observation.
*/
export type FlagTrackObservation = {
event: string;
context?: FlagEvaluationContext;
value?: number;
metadata?: Record<string, unknown>;
requestId?: string;
traceId?: string;
spanId?: string;
parentSpanId?: string;
traceparent?: string;
};
export type FlagEvaluationObserver = (
observation: FlagEvaluationObservation,
) => MaybePromise<void>;
export type FlagExposureObserver = (
observation: FlagExposureObservation,
) => MaybePromise<void>;
export type FlagTrackObserver = (
observation: FlagTrackObservation,
) => MaybePromise<void>;
/**
* Options for memory/static flag adapters.
*/
export type CreateFlagsOptions = {
onEvaluation?: FlagEvaluationObserver;
onExposure?: FlagExposureObserver;
onTrack?: FlagTrackObserver;
};
/**
* Static flag values keyed by flag key.
*/
export type StaticFlagValues = Record<string, FlagValue>;
/**
* Options for `createStaticFlags(...)`.
*/
export type CreateStaticFlagsOptions = CreateFlagsOptions & {
values?: StaticFlagValues;
};
/**
* In-memory flags port with mutation helpers for tests and local development.
*/
export type MemoryFlagsPort = FlagsPort & {
values: Map<string, FlagValue>;
set<TValue extends FlagValue>(flag: FlagDef<TValue>, value: TValue): void;
reset(flag?: FlagDef): void;
};
function createFlag<TValue extends FlagValue>(
valueKind: FlagValueKind,
key: string,
options: DefineFlagOptions<TValue>,
): FlagDef<TValue> {
const defaultValue = options.default;
if (!matchesValueKind(defaultValue, valueKind)) {
throw new Error(
`Flag "${key}" default value does not match "${valueKind}" flag value kind.`,
);
}
return {
kind: "flag",
key,
valueKind: valueKind as FlagValueKindForValue<TValue>,
defaultValue,
description: options.description,
metadata: options.metadata,
};
}
function defineBooleanFlag(
key: string,
options: DefineFlagOptions<boolean>,
): FlagDef<boolean> {
return createFlag("boolean", key, options);
}
function defineStringFlag(
key: string,
options: DefineFlagOptions<string>,
): FlagDef<string>;
function defineStringFlag<const TValue extends string>(
key: string,
options: DefineFlagOptions<TValue>,
): FlagDef<TValue>;
function defineStringFlag<TValue extends string>(
key: string,
options: DefineFlagOptions<TValue>,
): FlagDef<TValue> {
return createFlag("string", key, options);
}
function defineNumberFlag(
key: string,
options: DefineFlagOptions<number>,
): FlagDef<number>;
function defineNumberFlag<const TValue extends number>(
key: string,
options: DefineFlagOptions<TValue>,
): FlagDef<TValue>;
function defineNumberFlag<TValue extends number>(
key: string,
options: DefineFlagOptions<TValue>,
): FlagDef<TValue> {
return createFlag("number", key, options);
}
function defineObjectFlag<TValue extends FlagObjectValue>(
key: string,
options: DefineFlagOptions<TValue>,
): FlagDef<TValue> {
return createFlag("object", key, options);
}
/**
* Define one feature flag declaration.
*/
export const defineFlag = {
boolean: defineBooleanFlag,
string: defineStringFlag,
number: defineNumberFlag,
object: defineObjectFlag,
};
/**
* Define a typed registry of feature flags.
*/
export function defineFlags<const TRegistry extends FlagRegistry>(
registry: TRegistry,
): TRegistry {
return registry;
}
/**
* Evaluate a flag through any `FlagsPort`.
*/
export function evaluateFlag<TValue extends FlagValue>(
flags: FlagsPort,
flag: FlagDef<TValue>,
options?: FlagEvaluationOptions<TValue>,
): Promise<TValue> {
return flags.evaluate(flag, options);
}
/**
* Return detailed flag evaluation information through any `FlagsPort`.
*/
export function getFlagDetails<TValue extends FlagValue>(
flags: FlagsPort,
flag: FlagDef<TValue>,
options?: FlagEvaluationOptions<TValue>,
): Promise<FlagEvaluationDetails<TValue>> {
return flags.details(flag, options);
}
/**
* Create static flags backed by fixed values.
*/
export function createStaticFlags(
options: CreateStaticFlagsOptions = {},
): FlagsPort {
return createFlagsFromStore(() => options.values ?? {}, options);
}
/**
* Create mutable in-memory flags for tests, examples, and local development.
*/
export function createMemoryFlags(
options: CreateStaticFlagsOptions = {},
): MemoryFlagsPort {
const values = new Map<string, FlagValue>(
Object.entries(options.values ?? {}),
);
const port = createFlagsFromStore(() => Object.fromEntries(values), options);
return {
...port,
values,
set(flag, value) {
if (!matchesValueKind(value, flag.valueKind)) {
throw new Error(
`Flag "${flag.key}" value does not match "${flag.valueKind}" flag value kind.`,
);
}
values.set(flag.key, value);
},
reset(flag) {
if (flag) {
values.delete(flag.key);
return;
}
values.clear();
},
};
}
function createFlagsFromStore(
readValues: () => StaticFlagValues,
observers: CreateFlagsOptions,
): FlagsPort {
async function resolve<TValue extends FlagValue>(
flag: FlagDef<TValue>,
options: FlagEvaluationOptions<TValue> | undefined,
source: FlagEvaluationObservation["source"],
): Promise<FlagEvaluationDetails<TValue>> {
const startedAt = Date.now();
const defaultValue = options?.defaultValue ?? flag.defaultValue;
const values = readValues();
const stored = values[flag.key];
const hasStored = Object.hasOwn(values, flag.key);
const usedStoredValue =
hasStored && matchesValueKind(stored, flag.valueKind);
const value = usedStoredValue
? (stored as typeof defaultValue)
: defaultValue;
const details: FlagEvaluationDetails<typeof defaultValue> = {
key: flag.key,
valueKind: flag.valueKind as FlagValueKindForValue<typeof defaultValue>,
value,
defaultValue,
reason: usedStoredValue ? "static" : "default",
defaulted: !usedStoredValue,
} satisfies FlagEvaluationDetails<typeof defaultValue>;
await observeEvaluation(observers.onEvaluation, {
source,
flag,
context: options?.context,
details,
durationMs: Date.now() - startedAt,
...correlationFromContext(options?.context),
});
if (options?.expose) {
await flags.recordExposure(flag, {
context: options.context,
value: details.value,
variant: details.variant,
metadata: details.metadata,
});
}
return details;
}
const flags: FlagsPort = {
async evaluate(flag, options) {
const details = await resolve(flag, options, "evaluate");
return details.value;
},
async details(flag, options) {
return resolve(flag, options, "details");
},
async recordExposure(flag, options) {
await observeExposure(observers.onExposure, {
flag,
context: options?.context,
value: options?.value,
variant: options?.variant,
metadata: options?.metadata,
...correlationFromContext(options?.context),
});
},
async track(event, options) {
await observeTrack(observers.onTrack, {
event,
context: options?.context,
value: options?.value,
metadata: options?.metadata,
...correlationFromContext(options?.context),
});
},
};
return flags;
}
function matchesValueKind(value: unknown, kind: FlagValueKind): boolean {
switch (kind) {
case "boolean":
return typeof value === "boolean";
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && Number.isFinite(value);
case "object":
return typeof value === "object" && value !== null;
}
}
function correlationFromContext(context: FlagEvaluationContext | undefined) {
return {
requestId: context?.requestId,
traceId: context?.traceId,
spanId: context?.spanId,
parentSpanId: context?.parentSpanId,
traceparent: context?.traceparent,
};
}
async function observeEvaluation(
observer: FlagEvaluationObserver | undefined,
observation: FlagEvaluationObservation,
): Promise<void> {
try {
await observer?.(observation);
} catch {
// Observers are diagnostic only.
}
}
async function observeExposure(
observer: FlagExposureObserver | undefined,
observation: FlagExposureObservation,
): Promise<void> {
try {
await observer?.(observation);
} catch {
// Observers are diagnostic only.
}
}
async function observeTrack(
observer: FlagTrackObserver | undefined,
observation: FlagTrackObservation,
): Promise<void> {
try {
await observer?.(observation);
} catch {
// Observers are diagnostic only.
}
}