UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

608 lines (551 loc) 14.9 kB
/** * @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. } }