diff --git a/packages/server/src/client/internal/open-feature-client.ts b/packages/server/src/client/internal/open-feature-client.ts index 4f440e940..95f54aced 100644 --- a/packages/server/src/client/internal/open-feature-client.ts +++ b/packages/server/src/client/internal/open-feature-client.ts @@ -22,6 +22,7 @@ import { StandardResolutionReasons, instantiateErrorByErrorCode, statusMatchesEvent, + DefaultHookData, } from '@openfeature/core'; import type { FlagEvaluationOptions } from '../../evaluation'; import type { ProviderEvents } from '../../events'; @@ -276,22 +277,27 @@ export class OpenFeatureClient implements Client { const mergedContext = this.mergeContexts(invocationContext); - // this reference cannot change during the course of evaluation - // it may be used as a key in WeakMaps - const hookContext: Readonly = { - flagKey, - defaultValue, - flagValueType: flagType, - clientMetadata: this.metadata, - providerMetadata: this._provider.metadata, - context: mergedContext, - logger: this._logger, - }; + // Create hook context instances for each hook (stable object references for the entire evaluation) + // This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods + // NOTE: Uses the reversed order to reduce the number of times we have to calculate the index. + const hookContexts = allHooksReversed.map(() => + Object.freeze({ + flagKey, + defaultValue, + flagValueType: flagType, + clientMetadata: this.metadata, + providerMetadata: this._provider.metadata, + context: mergedContext, + logger: this._logger, + hookData: new DefaultHookData(), + }), + ); let evaluationDetails: EvaluationDetails; + let frozenContext = mergedContext; try { - const frozenContext = await this.beforeHooks(allHooks, hookContext, options); + frozenContext = await this.beforeHooks(allHooks, hookContexts, mergedContext, options); this.shortCircuitIfNotReady(); @@ -306,53 +312,71 @@ export class OpenFeatureClient implements Client { if (resolutionDetails.errorCode) { const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); - await this.errorHooks(allHooksReversed, hookContext, err, options); + await this.errorHooks(allHooksReversed, hookContexts, err, options); evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata); } else { - await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options); + await this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options); evaluationDetails = resolutionDetails; } } catch (err: unknown) { - await this.errorHooks(allHooksReversed, hookContext, err, options); + await this.errorHooks(allHooksReversed, hookContexts, err, options); evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); } - await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options); + await this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options); return evaluationDetails; } - private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) { - for (const hook of hooks) { - // freeze the hookContext - Object.freeze(hookContext); + private async beforeHooks( + hooks: Hook[], + hookContexts: HookContext[], + mergedContext: EvaluationContext, + options: FlagEvaluationOptions, + ) { + let accumulatedContext = mergedContext; + + for (const [index, hook] of hooks.entries()) { + const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks + const hookContext = hookContexts[hookContextIndex]; - // use Object.assign to avoid modification of frozen hookContext - Object.assign(hookContext.context, { - ...hookContext.context, - ...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))), - }); + // Update the context on the stable hook context object + Object.assign(hookContext.context, accumulatedContext); + + const hookResult = await hook?.before?.(hookContext, Object.freeze(options.hookHints)); + if (hookResult) { + accumulatedContext = { + ...accumulatedContext, + ...hookResult, + }; + + for (let i = 0; i < hooks.length; i++) { + Object.assign(hookContexts[hookContextIndex].context, accumulatedContext); + } + } } // after before hooks, freeze the EvaluationContext. - return Object.freeze(hookContext.context); + return Object.freeze(accumulatedContext); } private async afterHooks( hooks: Hook[], - hookContext: HookContext, + hookContexts: HookContext[], evaluationDetails: EvaluationDetails, options: FlagEvaluationOptions, ) { // run "after" hooks sequentially - for (const hook of hooks) { + for (const [index, hook] of hooks.entries()) { + const hookContext = hookContexts[index]; await hook?.after?.(hookContext, evaluationDetails, options.hookHints); } } - private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) { + private async errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) { // run "error" hooks sequentially - for (const hook of hooks) { + for (const [index, hook] of hooks.entries()) { try { + const hookContext = hookContexts[index]; await hook?.error?.(hookContext, err, options.hookHints); } catch (err) { this._logger.error(`Unhandled error during 'error' hook: ${err}`); @@ -366,13 +390,14 @@ export class OpenFeatureClient implements Client { private async finallyHooks( hooks: Hook[], - hookContext: HookContext, + hookContexts: HookContext[], evaluationDetails: EvaluationDetails, options: FlagEvaluationOptions, ) { // run "finally" hooks sequentially - for (const hook of hooks) { + for (const [index, hook] of hooks.entries()) { try { + const hookContext = hookContexts[index]; await hook?.finally?.(hookContext, evaluationDetails, options.hookHints); } catch (err) { this._logger.error(`Unhandled error during 'finally' hook: ${err}`); diff --git a/packages/server/src/hooks/hook.ts b/packages/server/src/hooks/hook.ts index c7fb07731..b5f6ade90 100644 --- a/packages/server/src/hooks/hook.ts +++ b/packages/server/src/hooks/hook.ts @@ -1,7 +1,8 @@ import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core'; -export type Hook = BaseHook< +export type Hook> = BaseHook< FlagValue, + TData, Promise | EvaluationContext | void, Promise | void >; diff --git a/packages/server/test/hooks-data.spec.ts b/packages/server/test/hooks-data.spec.ts new file mode 100644 index 000000000..92c163653 --- /dev/null +++ b/packages/server/test/hooks-data.spec.ts @@ -0,0 +1,508 @@ +import { OpenFeature } from '../src'; +import type { Client } from '../src/client'; +import type { + JsonValue, + ResolutionDetails, + HookContext, + BeforeHookContext, + HookData} from '@openfeature/core'; +import { + StandardResolutionReasons +} from '@openfeature/core'; +import type { Provider } from '../src/provider'; +import type { Hook } from '../src/hooks'; + +const BOOLEAN_VALUE = true; +const STRING_VALUE = 'val'; +const NUMBER_VALUE = 1; +const OBJECT_VALUE = { key: 'value' }; + +// A test hook that stores data in the before stage and retrieves it in after/error/finally +class TestHookWithData implements Hook { + beforeData: unknown; + afterData: unknown; + errorData: unknown; + finallyData: unknown; + + async before(hookContext: BeforeHookContext) { + // Store some data + hookContext.hookData.set('testKey', 'testValue'); + hookContext.hookData.set('timestamp', Date.now()); + hookContext.hookData.set('object', { nested: 'value' }); + this.beforeData = hookContext.hookData.get('testKey'); + } + + async after(hookContext: HookContext) { + // Retrieve data stored in before + this.afterData = hookContext.hookData.get('testKey'); + } + + async error(hookContext: HookContext) { + // Retrieve data stored in before + this.errorData = hookContext.hookData.get('testKey'); + } + + async finally(hookContext: HookContext) { + // Retrieve data stored in before + this.finallyData = hookContext.hookData.get('testKey'); + } +} + +// Typed hook example demonstrating improved type safety +interface OpenTelemetryData { + spanId: string; + traceId: string; + startTime: number; + attributes: Record; +} + +class TypedOpenTelemetryHook implements Hook { + spanId?: string; + duration?: number; + + async before(hookContext: BeforeHookContext) { + const spanId = `span-${Math.random().toString(36).substring(2, 11)}`; + const traceId = `trace-${Math.random().toString(36).substring(2, 11)}`; + + // Demonstrate that we can cast for type safety while maintaining compatibility + const typedHookData = hookContext.hookData as unknown as HookData; + + // Type-safe setting with proper intellisense + typedHookData.set('spanId', spanId); + typedHookData.set('traceId', traceId); + typedHookData.set('startTime', Date.now()); + typedHookData.set('attributes', { + flagKey: hookContext.flagKey, + clientName: hookContext.clientMetadata.name || 'unknown', + providerName: hookContext.providerMetadata.name, + }); + + this.spanId = spanId; + } + + async after(hookContext: HookContext) { + // Type-safe getting with proper return types + const typedHookData = hookContext.hookData as unknown as HookData; + const startTime: number | undefined = typedHookData.get('startTime'); + const spanId: string | undefined = typedHookData.get('spanId'); + + if (startTime && spanId) { + this.duration = Date.now() - startTime; + // Simulate span completion + } + } + + async error(hookContext: HookContext) { + const typedHookData = hookContext.hookData as unknown as HookData; + const spanId: string | undefined = typedHookData.get('spanId'); + if (spanId) { + // Mark span as error + } + } +} + +// A timing hook that measures evaluation duration +class TimingHook implements Hook { + duration?: number; + + async before(hookContext: BeforeHookContext) { + hookContext.hookData.set('startTime', Date.now()); + } + + async after(hookContext: HookContext) { + const startTime = hookContext.hookData.get('startTime') as number; + if (startTime) { + this.duration = Date.now() - startTime; + } + } + + async error(hookContext: HookContext) { + const startTime = hookContext.hookData.get('startTime') as number; + if (startTime) { + this.duration = Date.now() - startTime; + } + } +} + +// Hook that tests hook data isolation +class IsolationTestHook implements Hook { + hookId: string; + + constructor(id: string) { + this.hookId = id; + } + + before(hookContext: BeforeHookContext) { + const storedId = hookContext.hookData.get('hookId'); + if (storedId) { + throw new Error('Hook data isolation violated! Data is set in before hook.'); + } + + // Each hook instance should have its own data + hookContext.hookData.set('hookId', this.hookId); + hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`); + } + + after(hookContext: HookContext) { + // Verify we can only see our own data + const storedId = hookContext.hookData.get('hookId'); + if (storedId !== this.hookId) { + throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`); + } + } +} + +// Mock provider for testing +const MOCK_PROVIDER: Provider = { + metadata: { name: 'mock-provider' }, + async resolveBooleanEvaluation(): Promise> { + return { + value: BOOLEAN_VALUE, + variant: 'default', + reason: StandardResolutionReasons.DEFAULT, + }; + }, + async resolveStringEvaluation(): Promise> { + return { + value: STRING_VALUE, + variant: 'default', + reason: StandardResolutionReasons.DEFAULT, + }; + }, + async resolveNumberEvaluation(): Promise> { + return { + value: NUMBER_VALUE, + variant: 'default', + reason: StandardResolutionReasons.DEFAULT, + }; + }, + async resolveObjectEvaluation(): Promise> { + return { + value: OBJECT_VALUE as unknown as T, + variant: 'default', + reason: StandardResolutionReasons.DEFAULT, + }; + }, +}; + +// Mock provider that throws an error +const ERROR_PROVIDER: Provider = { + metadata: { name: 'error-provider' }, + async resolveBooleanEvaluation(): Promise> { + throw new Error('Provider error'); + }, + async resolveStringEvaluation(): Promise> { + throw new Error('Provider error'); + }, + async resolveNumberEvaluation(): Promise> { + throw new Error('Provider error'); + }, + async resolveObjectEvaluation(): Promise> { + throw new Error('Provider error'); + }, +}; + +describe('Hook Data', () => { + let client: Client; + + beforeEach(async () => { + OpenFeature.clearHooks(); + await OpenFeature.setProviderAndWait(MOCK_PROVIDER); + client = OpenFeature.getClient(); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + }); + + describe('Basic Hook Data Functionality', () => { + it('should allow hooks to store and retrieve data across stages', async () => { + const hook = new TestHookWithData(); + client.addHooks(hook); + + await client.getBooleanValue('test-flag', false); + + // Verify data was stored in before and retrieved in all other stages + expect(hook.beforeData).toBe('testValue'); + expect(hook.afterData).toBe('testValue'); + expect(hook.finallyData).toBe('testValue'); + }); + + it('should support storing different data types', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const storedValues: any = {}; + + const hook: Hook = { + async before(hookContext: BeforeHookContext) { + // Store various types + hookContext.hookData.set('string', 'test'); + hookContext.hookData.set('number', 42); + hookContext.hookData.set('boolean', true); + hookContext.hookData.set('object', { key: 'value' }); + hookContext.hookData.set('array', [1, 2, 3]); + hookContext.hookData.set('null', null); + hookContext.hookData.set('undefined', undefined); + }, + + async after(hookContext: HookContext) { + storedValues.string = hookContext.hookData.get('string'); + storedValues.number = hookContext.hookData.get('number'); + storedValues.boolean = hookContext.hookData.get('boolean'); + storedValues.object = hookContext.hookData.get('object'); + storedValues.array = hookContext.hookData.get('array'); + storedValues.null = hookContext.hookData.get('null'); + storedValues.undefined = hookContext.hookData.get('undefined'); + }, + }; + + client.addHooks(hook); + await client.getBooleanValue('test-flag', false); + + expect(storedValues.string).toBe('test'); + expect(storedValues.number).toBe(42); + expect(storedValues.boolean).toBe(true); + expect(storedValues.object).toEqual({ key: 'value' }); + expect(storedValues.array).toEqual([1, 2, 3]); + expect(storedValues.null).toBeNull(); + expect(storedValues.undefined).toBeUndefined(); + }); + + it('should handle hook data in error scenarios', async () => { + await OpenFeature.setProviderAndWait(ERROR_PROVIDER); + const hook = new TestHookWithData(); + client.addHooks(hook); + + await client.getBooleanValue('test-flag', false); + + // Verify data was accessible in error and finally stages + expect(hook.beforeData).toBe('testValue'); + expect(hook.errorData).toBe('testValue'); + expect(hook.finallyData).toBe('testValue'); + expect(hook.afterData).toBeUndefined(); // after should not run on error + }); + }); + + describe('Hook Data API', () => { + it('should support has() method', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hasResults: any = {}; + + const hook: Hook = { + async before(hookContext: BeforeHookContext) { + hookContext.hookData.set('exists', 'value'); + hasResults.beforeExists = hookContext.hookData.has('exists'); + hasResults.beforeNotExists = hookContext.hookData.has('notExists'); + }, + + async after(hookContext: HookContext) { + hasResults.afterExists = hookContext.hookData.has('exists'); + hasResults.afterNotExists = hookContext.hookData.has('notExists'); + }, + }; + + client.addHooks(hook); + await client.getBooleanValue('test-flag', false); + + expect(hasResults.beforeExists).toBe(true); + expect(hasResults.beforeNotExists).toBe(false); + expect(hasResults.afterExists).toBe(true); + expect(hasResults.afterNotExists).toBe(false); + }); + + it('should support delete() method', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deleteResults: any = {}; + + const hook: Hook = { + async before(hookContext: BeforeHookContext) { + hookContext.hookData.set('toDelete', 'value'); + deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete'); + deleteResults.deleteResult = hookContext.hookData.delete('toDelete'); + deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete'); + deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete'); + }, + }; + + client.addHooks(hook); + await client.getBooleanValue('test-flag', false); + + expect(deleteResults.hasBeforeDelete).toBe(true); + expect(deleteResults.deleteResult).toBe(true); + expect(deleteResults.hasAfterDelete).toBe(false); + expect(deleteResults.deleteAgainResult).toBe(false); + }); + + it('should support clear() method', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const clearResults: any = {}; + + const hook: Hook = { + async before(hookContext: BeforeHookContext) { + hookContext.hookData.set('key1', 'value1'); + hookContext.hookData.set('key2', 'value2'); + hookContext.hookData.set('key3', 'value3'); + clearResults.hasBeforeClear = hookContext.hookData.has('key1'); + hookContext.hookData.clear(); + clearResults.hasAfterClear = hookContext.hookData.has('key1'); + }, + + async after(hookContext: HookContext) { + // Verify all data was cleared + clearResults.afterHasKey1 = hookContext.hookData.has('key1'); + clearResults.afterHasKey2 = hookContext.hookData.has('key2'); + clearResults.afterHasKey3 = hookContext.hookData.has('key3'); + }, + }; + + client.addHooks(hook); + await client.getBooleanValue('test-flag', false); + + expect(clearResults.hasBeforeClear).toBe(true); + expect(clearResults.hasAfterClear).toBe(false); + expect(clearResults.afterHasKey1).toBe(false); + expect(clearResults.afterHasKey2).toBe(false); + expect(clearResults.afterHasKey3).toBe(false); + }); + }); + + describe('Hook Data Isolation', () => { + it('should isolate data between different hook instances', async () => { + const hook1 = new IsolationTestHook('hook1'); + const hook2 = new IsolationTestHook('hook2'); + const hook3 = new IsolationTestHook('hook3'); + + client.addHooks(hook1, hook2, hook3); + + expect(await client.getBooleanValue('test-flag', false)).toBe(true); + }); + + it('should isolate data between the same hook instance', async () => { + const hook = new IsolationTestHook('hook'); + + client.addHooks(hook, hook); + + expect(await client.getBooleanValue('test-flag', false)).toBe(true); + }); + + it('should not share data between different evaluations', async () => { + let firstEvalData: unknown; + let secondEvalData: unknown; + + const hook: Hook = { + async before(hookContext: BeforeHookContext) { + // Check if data exists from previous evaluation + const existingData = hookContext.hookData.get('evalData'); + if (existingData) { + throw new Error('Hook data leaked between evaluations!'); + } + hookContext.hookData.set('evalData', 'evaluation-specific'); + }, + + async after(hookContext: HookContext) { + if (!firstEvalData) { + firstEvalData = hookContext.hookData.get('evalData'); + } else { + secondEvalData = hookContext.hookData.get('evalData'); + } + }, + }; + + client.addHooks(hook); + + // First evaluation + await client.getBooleanValue('test-flag', false); + // Second evaluation + await client.getBooleanValue('test-flag', false); + + expect(firstEvalData).toBe('evaluation-specific'); + expect(secondEvalData).toBe('evaluation-specific'); + }); + + it('should isolate data between global, client, and invocation hooks', async () => { + const globalHook = new IsolationTestHook('global'); + const clientHook = new IsolationTestHook('client'); + const invocationHook = new IsolationTestHook('invocation'); + + OpenFeature.addHooks(globalHook); + client.addHooks(clientHook); + + expect(await client.getBooleanValue('test-flag', false, {}, { hooks: [invocationHook] })).toBe(true); + }); + }); + + describe('Use Cases', () => { + it('should support timing measurements', async () => { + const timingHook = new TimingHook(); + client.addHooks(timingHook); + + await client.getBooleanValue('test-flag', false); + + expect(timingHook.duration).toBeDefined(); + expect(timingHook.duration).toBeGreaterThanOrEqual(0); + }); + + it('should support multi-stage validation accumulation', async () => { + let finalErrors: string[] = []; + + const validationHook: Hook = { + async before(hookContext: BeforeHookContext) { + hookContext.hookData.set('errors', []); + + // Simulate validation + const errors = hookContext.hookData.get('errors') as string[]; + if (!hookContext.context.userId) { + errors.push('Missing userId'); + } + if (!hookContext.context.region) { + errors.push('Missing region'); + } + }, + + async finally(hookContext: HookContext) { + finalErrors = (hookContext.hookData.get('errors') as string[]) || []; + }, + }; + + client.addHooks(validationHook); + await client.getBooleanValue('test-flag', false, {}); + + expect(finalErrors).toContain('Missing userId'); + expect(finalErrors).toContain('Missing region'); + }); + + it('should support request correlation', async () => { + let correlationId: string | undefined; + + const correlationHook: Hook = { + async before(hookContext: BeforeHookContext) { + const id = `req-${Date.now()}-${Math.random()}`; + hookContext.hookData.set('correlationId', id); + }, + + async after(hookContext: HookContext) { + correlationId = hookContext.hookData.get('correlationId') as string; + }, + }; + + client.addHooks(correlationHook); + await client.getBooleanValue('test-flag', false); + + expect(correlationId).toBeDefined(); + expect(correlationId).toMatch(/^req-\d+-[\d.]+$/); + }); + + it('should support typed hook data for better type safety', async () => { + const typedHook = new TypedOpenTelemetryHook(); + client.addHooks(typedHook); + + await client.getBooleanValue('test-flag', false); + + // Verify the typed hook worked correctly + expect(typedHook.spanId).toBeDefined(); + expect(typedHook.spanId).toMatch(/^span-[a-z0-9]+$/); + expect(typedHook.duration).toBeDefined(); + expect(typeof typedHook.duration).toBe('number'); + expect(typedHook.duration).toBeGreaterThanOrEqual(0); + }); + }); +}); \ No newline at end of file diff --git a/packages/shared/src/hooks/hook-data.ts b/packages/shared/src/hooks/hook-data.ts new file mode 100644 index 000000000..0720957cc --- /dev/null +++ b/packages/shared/src/hooks/hook-data.ts @@ -0,0 +1,72 @@ +/** + * A mutable data structure for hooks to maintain state across their lifecycle. + * Each hook instance gets its own isolated data store that persists for the + * duration of a single flag evaluation. + * @template TData - A record type that defines the shape of the stored data + */ +export interface HookData> { + /** + * Sets a value in the hook data store. + * @param key The key to store the value under + * @param value The value to store + */ + set(key: K, value: TData[K]): void; + set(key: string, value: unknown): void; + + /** + * Gets a value from the hook data store. + * @param key The key to retrieve the value for + * @returns The stored value, or undefined if not found + */ + get(key: K): TData[K] | undefined; + get(key: string): unknown; + + /** + * Checks if a key exists in the hook data store. + * @param key The key to check + * @returns True if the key exists, false otherwise + */ + has(key: K): boolean; + has(key: string): boolean; + + /** + * Deletes a value from the hook data store. + * @param key The key to delete + * @returns True if the key was deleted, false if it didn't exist + */ + delete(key: K): boolean; + delete(key: string): boolean; + + /** + * Clears all values from the hook data store. + */ + clear(): void; +} + +/** + * Default implementation of HookData using a Map. + * @template TData - A record type that defines the shape of the stored data + */ +export class DefaultHookData> implements HookData { + private readonly data = new Map(); + + set(key: K, value: TData[K]): void { + this.data.set(key, value); + } + + get(key: K): TData[K] | undefined { + return this.data.get(key) as TData[K] | undefined; + } + + has(key: K): boolean { + return this.data.has(key); + } + + delete(key: K): boolean { + return this.data.delete(key); + } + + clear(): void { + this.data.clear(); + } +} \ No newline at end of file diff --git a/packages/shared/src/hooks/hook.ts b/packages/shared/src/hooks/hook.ts index 7c3c63376..e5985c55e 100644 --- a/packages/shared/src/hooks/hook.ts +++ b/packages/shared/src/hooks/hook.ts @@ -1,14 +1,19 @@ import type { BeforeHookContext, HookContext, HookHints } from './hooks'; import type { EvaluationDetails, FlagValue } from '../evaluation'; -export interface BaseHook { +export interface BaseHook< + T extends FlagValue = FlagValue, + TData = Record, + BeforeHookReturn = unknown, + HooksReturn = unknown +> { /** * Runs before flag values are resolved from the provider. * If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext. * @param hookContext * @param hookHints */ - before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn; + before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn; /** * Runs after flag values are successfully resolved from the provider. @@ -17,7 +22,7 @@ export interface BaseHook>, + hookContext: Readonly>, evaluationDetails: EvaluationDetails, hookHints?: HookHints, ): HooksReturn; @@ -28,7 +33,7 @@ export interface BaseHook>, error: unknown, hookHints?: HookHints): HooksReturn; + error?(hookContext: Readonly>, error: unknown, hookHints?: HookHints): HooksReturn; /** * Runs after all other hook stages, regardless of success or error. @@ -37,8 +42,9 @@ export interface BaseHook>, + hookContext: Readonly>, evaluationDetails: EvaluationDetails, hookHints?: HookHints, ): HooksReturn; } + diff --git a/packages/shared/src/hooks/hooks.ts b/packages/shared/src/hooks/hooks.ts index 76233ece3..5e8b11590 100644 --- a/packages/shared/src/hooks/hooks.ts +++ b/packages/shared/src/hooks/hooks.ts @@ -2,10 +2,11 @@ import type { ProviderMetadata } from '../provider'; import type { ClientMetadata } from '../client'; import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation'; import type { Logger } from '../logger'; +import type { HookData } from './hook-data'; export type HookHints = Readonly>; -export interface HookContext { +export interface HookContext> { readonly flagKey: string; readonly defaultValue: T; readonly flagValueType: FlagValueType; @@ -13,8 +14,9 @@ export interface HookContext { readonly clientMetadata: ClientMetadata; readonly providerMetadata: ProviderMetadata; readonly logger: Logger; + readonly hookData: HookData; } -export interface BeforeHookContext extends HookContext { +export interface BeforeHookContext> extends HookContext { context: EvaluationContext; } diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index 5dee4fb45..47227681a 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './hook'; export * from './hooks'; export * from './evaluation-lifecycle'; +export * from './hook-data'; diff --git a/packages/shared/test/hook-data-types.spec.ts b/packages/shared/test/hook-data-types.spec.ts new file mode 100644 index 000000000..3fb836578 --- /dev/null +++ b/packages/shared/test/hook-data-types.spec.ts @@ -0,0 +1,214 @@ +import type { HookData, BaseHook, BeforeHookContext, HookContext } from '../src/hooks'; +import { DefaultHookData } from '../src/hooks'; +import type { FlagValue } from '../src/evaluation'; + +describe('Hook Data Type Safety', () => { + it('should provide type safety with typed hook data', () => { + // Define a strict type for hook data + interface MyHookData { + startTime: number; + userId: string; + metadata: { version: string; feature: boolean }; + tags: string[]; + } + + const hookData = new DefaultHookData(); + + // Type-safe setting and getting + hookData.set('startTime', 123456); + hookData.set('userId', 'user-123'); + hookData.set('metadata', { version: '1.0.0', feature: true }); + hookData.set('tags', ['tag1', 'tag2']); + + // TypeScript should infer the correct return types + const startTime: number | undefined = hookData.get('startTime'); + const userId: string | undefined = hookData.get('userId'); + const metadata: { version: string; feature: boolean } | undefined = hookData.get('metadata'); + const tags: string[] | undefined = hookData.get('tags'); + + // Verify the values + expect(startTime).toBe(123456); + expect(userId).toBe('user-123'); + expect(metadata).toEqual({ version: '1.0.0', feature: true }); + expect(tags).toEqual(['tag1', 'tag2']); + + // Type-safe existence checks + expect(hookData.has('startTime')).toBe(true); + expect(hookData.has('userId')).toBe(true); + expect(hookData.has('metadata')).toBe(true); + expect(hookData.has('tags')).toBe(true); + + // Type-safe deletion + expect(hookData.delete('tags')).toBe(true); + expect(hookData.has('tags')).toBe(false); + }); + + it('should support untyped usage for backward compatibility', () => { + const hookData: HookData = new DefaultHookData(); + + // Untyped usage still works + hookData.set('anyKey', 'anyValue'); + hookData.set('numberKey', 42); + hookData.set('objectKey', { nested: true }); + + const value: unknown = hookData.get('anyKey'); + const numberValue: unknown = hookData.get('numberKey'); + const objectValue: unknown = hookData.get('objectKey'); + + expect(value).toBe('anyValue'); + expect(numberValue).toBe(42); + expect(objectValue).toEqual({ nested: true }); + }); + + it('should support mixed usage with typed and untyped keys', () => { + interface PartiallyTypedData { + correlationId: string; + timestamp: number; + } + + const hookData: HookData = new DefaultHookData(); + + // Typed usage + hookData.set('correlationId', 'abc-123'); + hookData.set('timestamp', Date.now()); + + // Untyped usage for additional keys + hookData.set('dynamicKey', 'dynamicValue'); + + // Type-safe retrieval for typed keys + const correlationId: string | undefined = hookData.get('correlationId'); + const timestamp: number | undefined = hookData.get('timestamp'); + + // Untyped retrieval for dynamic keys + const dynamicValue: unknown = hookData.get('dynamicKey'); + + expect(correlationId).toBe('abc-123'); + expect(typeof timestamp).toBe('number'); + expect(dynamicValue).toBe('dynamicValue'); + }); + + it('should work with complex nested types', () => { + interface ComplexHookData { + request: { + id: string; + headers: Record; + body?: { [key: string]: unknown }; + }; + response: { + status: number; + data: unknown; + headers: Record; + }; + metrics: { + startTime: number; + endTime?: number; + duration?: number; + }; + } + + const hookData: HookData = new DefaultHookData(); + + const requestData = { + id: 'req-123', + headers: { 'Content-Type': 'application/json' }, + body: { flag: 'test-flag' } + }; + + hookData.set('request', requestData); + hookData.set('metrics', { startTime: Date.now() }); + + const retrievedRequest = hookData.get('request'); + const retrievedMetrics = hookData.get('metrics'); + + expect(retrievedRequest).toEqual(requestData); + expect(retrievedMetrics?.startTime).toBeDefined(); + expect(typeof retrievedMetrics?.startTime).toBe('number'); + }); + + it('should support generic type inference', () => { + // This function demonstrates how the generic types work in practice + function createTypedHookData(): HookData { + return new DefaultHookData(); + } + + interface TimingData { + start: number; + checkpoint: number; + } + + const timingHookData = createTypedHookData(); + + timingHookData.set('start', performance.now()); + timingHookData.set('checkpoint', performance.now()); + + const start: number | undefined = timingHookData.get('start'); + const checkpoint: number | undefined = timingHookData.get('checkpoint'); + + expect(typeof start).toBe('number'); + expect(typeof checkpoint).toBe('number'); + }); + + it('should work with BaseHook interface without casting', () => { + interface TestHookData { + testId: string; + startTime: number; + metadata: { version: string }; + } + + class TestTypedHook implements BaseHook { + capturedData: { testId?: string; duration?: number } = {}; + + before(hookContext: BeforeHookContext) { + // No casting needed - TypeScript knows the types + hookContext.hookData.set('testId', 'test-123'); + hookContext.hookData.set('startTime', Date.now()); + hookContext.hookData.set('metadata', { version: '1.0.0' }); + } + + after(hookContext: HookContext) { + // Type-safe getting with proper return types + const testId: string | undefined = hookContext.hookData.get('testId'); + const startTime: number | undefined = hookContext.hookData.get('startTime'); + + if (testId && startTime) { + this.capturedData = { + testId, + duration: Date.now() - startTime + }; + } + } + } + + const hook = new TestTypedHook(); + + // Create mock contexts that satisfy the BaseHook interface + const mockBeforeContext: BeforeHookContext = { + flagKey: 'test-flag', + defaultValue: true, + flagValueType: 'boolean', + context: {}, + clientMetadata: { + name: 'test-client', + domain: 'test-domain', + providerMetadata: { name: 'test-provider' } + }, + providerMetadata: { name: 'test-provider' }, + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + hookData: new DefaultHookData() + }; + + const mockAfterContext: HookContext = { + ...mockBeforeContext, + context: Object.freeze({}) + }; + + // Execute the hook methods + hook.before!(mockBeforeContext); + hook.after!(mockAfterContext); + + // Verify the typed hook worked correctly + expect(hook.capturedData.testId).toBe('test-123'); + expect(hook.capturedData.duration).toBeDefined(); + expect(typeof hook.capturedData.duration).toBe('number'); + }); +}); \ No newline at end of file diff --git a/packages/shared/test/telemetry.spec.ts b/packages/shared/test/telemetry.spec.ts index 35cbe7917..5dee988d1 100644 --- a/packages/shared/test/telemetry.spec.ts +++ b/packages/shared/test/telemetry.spec.ts @@ -2,6 +2,7 @@ import { createEvaluationEvent } from '../src/telemetry/evaluation-event'; import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation'; import type { HookContext } from '../src/hooks/hooks'; import { TelemetryAttribute, TelemetryFlagMetadata } from '../src/telemetry'; +import { DefaultHookData } from '../src/hooks/hook-data'; describe('evaluationEvent', () => { const flagKey = 'test-flag'; @@ -25,6 +26,7 @@ describe('evaluationEvent', () => { error: jest.fn(), warn: jest.fn(), }, + hookData: new DefaultHookData(), }; it('should return basic event body with mandatory fields', () => { diff --git a/packages/web/src/client/internal/open-feature-client.ts b/packages/web/src/client/internal/open-feature-client.ts index 7eed9a9a6..3a9501810 100644 --- a/packages/web/src/client/internal/open-feature-client.ts +++ b/packages/web/src/client/internal/open-feature-client.ts @@ -22,6 +22,7 @@ import { StandardResolutionReasons, instantiateErrorByErrorCode, statusMatchesEvent, + DefaultHookData, } from '@openfeature/core'; import type { FlagEvaluationOptions } from '../../evaluation'; import type { ProviderEvents } from '../../events'; @@ -231,22 +232,26 @@ export class OpenFeatureClient implements Client { ...this.apiContextAccessor(this?.options?.domain), }; - // this reference cannot change during the course of evaluation - // it may be used as a key in WeakMaps - const hookContext: Readonly = { - flagKey, - defaultValue, - flagValueType: flagType, - clientMetadata: this.metadata, - providerMetadata: this._provider.metadata, - context, - logger: this._logger, - }; + // Create hook context instances for each hook (stable object references for the entire evaluation) + // This ensures hooks can use WeakMaps with hookContext as keys across lifecycle methods + // NOTE: Uses the reversed order to reduce the number of times we have to calculate the index. + const hookContexts = allHooksReversed.map(() => + Object.freeze({ + flagKey, + defaultValue, + flagValueType: flagType, + clientMetadata: this.metadata, + providerMetadata: this._provider.metadata, + context, + logger: this._logger, + hookData: new DefaultHookData(), + }), + ); let evaluationDetails: EvaluationDetails; try { - this.beforeHooks(allHooks, hookContext, options); + this.beforeHooks(allHooks, hookContexts, options); this.shortCircuitIfNotReady(); @@ -261,45 +266,48 @@ export class OpenFeatureClient implements Client { if (resolutionDetails.errorCode) { const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); - this.errorHooks(allHooksReversed, hookContext, err, options); + this.errorHooks(allHooksReversed, hookContexts, err, options); evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata); } else { - this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options); + this.afterHooks(allHooksReversed, hookContexts, resolutionDetails, options); evaluationDetails = resolutionDetails; } } catch (err: unknown) { - this.errorHooks(allHooksReversed, hookContext, err, options); + this.errorHooks(allHooksReversed, hookContexts, err, options); evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); } - this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options); + this.finallyHooks(allHooksReversed, hookContexts, evaluationDetails, options); return evaluationDetails; } - private beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) { - Object.freeze(hookContext); - Object.freeze(hookContext.context); - - for (const hook of hooks) { + private beforeHooks(hooks: Hook[], hookContexts: HookContext[], options: FlagEvaluationOptions) { + for (const [index, hook] of hooks.entries()) { + const hookContextIndex = hooks.length - 1 - index; // reverse index for before hooks + const hookContext = hookContexts[hookContextIndex]; + Object.freeze(hookContext); + Object.freeze(hookContext.context); hook?.before?.(hookContext, Object.freeze(options.hookHints)); } } private afterHooks( hooks: Hook[], - hookContext: HookContext, + hookContexts: HookContext[], evaluationDetails: EvaluationDetails, options: FlagEvaluationOptions, ) { // run "after" hooks sequentially - for (const hook of hooks) { + for (const [index, hook] of hooks.entries()) { + const hookContext = hookContexts[index]; hook?.after?.(hookContext, evaluationDetails, options.hookHints); } } - private errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) { + private errorHooks(hooks: Hook[], hookContexts: HookContext[], err: unknown, options: FlagEvaluationOptions) { // run "error" hooks sequentially - for (const hook of hooks) { + for (const [index, hook] of hooks.entries()) { try { + const hookContext = hookContexts[index]; hook?.error?.(hookContext, err, options.hookHints); } catch (err) { this._logger.error(`Unhandled error during 'error' hook: ${err}`); @@ -313,13 +321,14 @@ export class OpenFeatureClient implements Client { private finallyHooks( hooks: Hook[], - hookContext: HookContext, + hookContexts: HookContext[], evaluationDetails: EvaluationDetails, options: FlagEvaluationOptions, ) { // run "finally" hooks sequentially - for (const hook of hooks) { + for (const [index, hook] of hooks.entries()) { try { + const hookContext = hookContexts[index]; hook?.finally?.(hookContext, evaluationDetails, options.hookHints); } catch (err) { this._logger.error(`Unhandled error during 'finally' hook: ${err}`); diff --git a/packages/web/src/hooks/hook.ts b/packages/web/src/hooks/hook.ts index 20b2b8874..8dd71d848 100644 --- a/packages/web/src/hooks/hook.ts +++ b/packages/web/src/hooks/hook.ts @@ -1,3 +1,3 @@ import type { BaseHook, FlagValue } from '@openfeature/core'; -export type Hook = BaseHook; +export type Hook> = BaseHook; diff --git a/packages/web/test/hooks-data.spec.ts b/packages/web/test/hooks-data.spec.ts new file mode 100644 index 000000000..ca7992e19 --- /dev/null +++ b/packages/web/test/hooks-data.spec.ts @@ -0,0 +1,436 @@ +import { OpenFeatureAPI } from '../src/open-feature'; +import type { Client } from '../src/client'; +import type { JsonValue, ResolutionDetails, HookContext, BeforeHookContext } from '@openfeature/core'; +import { StandardResolutionReasons } from '@openfeature/core'; +import type { Provider } from '../src/provider'; +import type { Hook } from '../src/hooks'; + +const BOOLEAN_VALUE = true; +const STRING_VALUE = 'val'; +const NUMBER_VALUE = 1; +const OBJECT_VALUE = { key: 'value' }; + +// A test hook that stores data in the before stage and retrieves it in after/error/finally +class TestHookWithData implements Hook { + beforeData: unknown; + afterData: unknown; + errorData: unknown; + finallyData: unknown; + + before(hookContext: BeforeHookContext) { + // Store some data + hookContext.hookData.set('testKey', 'testValue'); + hookContext.hookData.set('timestamp', Date.now()); + hookContext.hookData.set('object', { nested: 'value' }); + this.beforeData = hookContext.hookData.get('testKey'); + } + + after(hookContext: HookContext) { + // Retrieve data stored in before + this.afterData = hookContext.hookData.get('testKey'); + } + + error(hookContext: HookContext) { + // Retrieve data stored in before + this.errorData = hookContext.hookData.get('testKey'); + } + + finally(hookContext: HookContext) { + // Retrieve data stored in before + this.finallyData = hookContext.hookData.get('testKey'); + } +} + +// A timing hook that measures evaluation duration +class TimingHook implements Hook { + duration?: number; + + before(hookContext: BeforeHookContext) { + hookContext.hookData.set('startTime', performance.now()); + } + + after(hookContext: HookContext) { + const startTime = hookContext.hookData.get('startTime') as number; + if (startTime) { + this.duration = performance.now() - startTime; + } + } + + error(hookContext: HookContext) { + const startTime = hookContext.hookData.get('startTime') as number; + if (startTime) { + this.duration = performance.now() - startTime; + } + } +} + +// Hook that tests hook data isolation +class IsolationTestHook implements Hook { + hookId: string; + + constructor(id: string) { + this.hookId = id; + } + + before(hookContext: BeforeHookContext) { + const storedId = hookContext.hookData.get('hookId'); + if (storedId) { + throw new Error('Hook data isolation violated! Data is set in before hook.'); + } + + // Each hook instance should have its own data + hookContext.hookData.set('hookId', this.hookId); + hookContext.hookData.set(`data_${this.hookId}`, `value_${this.hookId}`); + } + + after(hookContext: HookContext) { + // Verify we can only see our own data + const storedId = hookContext.hookData.get('hookId'); + if (storedId !== this.hookId) { + throw new Error(`Hook data isolation violated! Expected ${this.hookId}, got ${storedId}`); + } + } +} + +// Mock provider for testing +const MOCK_PROVIDER: Provider = { + metadata: { name: 'mock-provider' }, + resolveBooleanEvaluation(): ResolutionDetails { + return { + value: BOOLEAN_VALUE, + variant: 'default', + reason: StandardResolutionReasons.DEFAULT, + }; + }, + resolveStringEvaluation(): ResolutionDetails { + return { + value: STRING_VALUE, + variant: 'default', + reason: StandardResolutionReasons.DEFAULT, + }; + }, + resolveNumberEvaluation(): ResolutionDetails { + return { + value: NUMBER_VALUE, + variant: 'default', + reason: StandardResolutionReasons.DEFAULT, + }; + }, + resolveObjectEvaluation(): ResolutionDetails { + return { + value: OBJECT_VALUE as unknown as T, + variant: 'default', + reason: StandardResolutionReasons.DEFAULT, + }; + }, +} as Provider; + +// Mock provider that throws an error +const ERROR_PROVIDER: Provider = { + metadata: { name: 'error-provider' }, + resolveBooleanEvaluation(): ResolutionDetails { + throw new Error('Provider error'); + }, + resolveStringEvaluation(): ResolutionDetails { + throw new Error('Provider error'); + }, + resolveNumberEvaluation(): ResolutionDetails { + throw new Error('Provider error'); + }, + resolveObjectEvaluation(): ResolutionDetails { + throw new Error('Provider error'); + }, +}; + +describe('Hook Data (Web SDK)', () => { + let client: Client; + let api: OpenFeatureAPI; + + beforeEach(() => { + api = OpenFeatureAPI.getInstance(); + api.clearHooks(); + api.setProvider(MOCK_PROVIDER); + client = api.getClient(); + }); + + afterEach(() => { + api.clearProviders(); + }); + + describe('Basic Hook Data Functionality', () => { + it('should allow hooks to store and retrieve data across stages', () => { + const hook = new TestHookWithData(); + client.addHooks(hook); + + client.getBooleanValue('test-flag', false); + + // Verify data was stored in before and retrieved in all other stages + expect(hook.beforeData).toBe('testValue'); + expect(hook.afterData).toBe('testValue'); + expect(hook.finallyData).toBe('testValue'); + }); + + it('should support storing different data types', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const storedValues: any = {}; + + const hook: Hook = { + before(hookContext: BeforeHookContext) { + // Store various types + hookContext.hookData.set('string', 'test'); + hookContext.hookData.set('number', 42); + hookContext.hookData.set('boolean', true); + hookContext.hookData.set('object', { key: 'value' }); + hookContext.hookData.set('array', [1, 2, 3]); + hookContext.hookData.set('null', null); + hookContext.hookData.set('undefined', undefined); + }, + + after(hookContext: HookContext) { + storedValues.string = hookContext.hookData.get('string'); + storedValues.number = hookContext.hookData.get('number'); + storedValues.boolean = hookContext.hookData.get('boolean'); + storedValues.object = hookContext.hookData.get('object'); + storedValues.array = hookContext.hookData.get('array'); + storedValues.null = hookContext.hookData.get('null'); + storedValues.undefined = hookContext.hookData.get('undefined'); + }, + }; + + client.addHooks(hook); + client.getBooleanValue('test-flag', false); + + expect(storedValues.string).toBe('test'); + expect(storedValues.number).toBe(42); + expect(storedValues.boolean).toBe(true); + expect(storedValues.object).toEqual({ key: 'value' }); + expect(storedValues.array).toEqual([1, 2, 3]); + expect(storedValues.null).toBeNull(); + expect(storedValues.undefined).toBeUndefined(); + }); + + it('should handle hook data in error scenarios', () => { + api.setProvider(ERROR_PROVIDER); + const hook = new TestHookWithData(); + client.addHooks(hook); + + client.getBooleanValue('test-flag', false); + + // Verify data was accessible in error and finally stages + expect(hook.beforeData).toBe('testValue'); + expect(hook.errorData).toBe('testValue'); + expect(hook.finallyData).toBe('testValue'); + expect(hook.afterData).toBeUndefined(); // after should not run on error + }); + }); + + describe('Hook Data API', () => { + it('should support has() method', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hasResults: any = {}; + + const hook: Hook = { + before(hookContext: BeforeHookContext) { + hookContext.hookData.set('exists', 'value'); + hasResults.beforeExists = hookContext.hookData.has('exists'); + hasResults.beforeNotExists = hookContext.hookData.has('notExists'); + }, + + after(hookContext: HookContext) { + hasResults.afterExists = hookContext.hookData.has('exists'); + hasResults.afterNotExists = hookContext.hookData.has('notExists'); + }, + }; + + client.addHooks(hook); + client.getBooleanValue('test-flag', false); + + expect(hasResults.beforeExists).toBe(true); + expect(hasResults.beforeNotExists).toBe(false); + expect(hasResults.afterExists).toBe(true); + expect(hasResults.afterNotExists).toBe(false); + }); + + it('should support delete() method', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deleteResults: any = {}; + + const hook: Hook = { + before(hookContext: BeforeHookContext) { + hookContext.hookData.set('toDelete', 'value'); + deleteResults.hasBeforeDelete = hookContext.hookData.has('toDelete'); + deleteResults.deleteResult = hookContext.hookData.delete('toDelete'); + deleteResults.hasAfterDelete = hookContext.hookData.has('toDelete'); + deleteResults.deleteAgainResult = hookContext.hookData.delete('toDelete'); + }, + }; + + client.addHooks(hook); + client.getBooleanValue('test-flag', false); + + expect(deleteResults.hasBeforeDelete).toBe(true); + expect(deleteResults.deleteResult).toBe(true); + expect(deleteResults.hasAfterDelete).toBe(false); + expect(deleteResults.deleteAgainResult).toBe(false); + }); + + it('should support clear() method', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const clearResults: any = {}; + + const hook: Hook = { + before(hookContext: BeforeHookContext) { + hookContext.hookData.set('key1', 'value1'); + hookContext.hookData.set('key2', 'value2'); + hookContext.hookData.set('key3', 'value3'); + clearResults.hasBeforeClear = hookContext.hookData.has('key1'); + hookContext.hookData.clear(); + clearResults.hasAfterClear = hookContext.hookData.has('key1'); + }, + + after(hookContext: HookContext) { + // Verify all data was cleared + clearResults.afterHasKey1 = hookContext.hookData.has('key1'); + clearResults.afterHasKey2 = hookContext.hookData.has('key2'); + clearResults.afterHasKey3 = hookContext.hookData.has('key3'); + }, + }; + + client.addHooks(hook); + client.getBooleanValue('test-flag', false); + + expect(clearResults.hasBeforeClear).toBe(true); + expect(clearResults.hasAfterClear).toBe(false); + expect(clearResults.afterHasKey1).toBe(false); + expect(clearResults.afterHasKey2).toBe(false); + expect(clearResults.afterHasKey3).toBe(false); + }); + }); + + describe('Hook Data Isolation', () => { + it('should isolate data between different hook instances', () => { + const hook1 = new IsolationTestHook('hook1'); + const hook2 = new IsolationTestHook('hook2'); + const hook3 = new IsolationTestHook('hook3'); + + client.addHooks(hook1, hook2, hook3); + + expect(client.getBooleanValue('test-flag', false)).toBe(true); + }); + + it('should isolate data between the same hook instance', () => { + const hook = new IsolationTestHook('hook'); + + client.addHooks(hook, hook); + + expect(client.getBooleanValue('test-flag', false)).toBe(true); + }); + + it('should not share data between different evaluations', () => { + let firstEvalData: unknown; + let secondEvalData: unknown; + + const hook: Hook = { + before(hookContext: BeforeHookContext) { + // Check if data exists from previous evaluation + const existingData = hookContext.hookData.get('evalData'); + if (existingData) { + throw new Error('Hook data leaked between evaluations!'); + } + hookContext.hookData.set('evalData', 'evaluation-specific'); + }, + + after(hookContext: HookContext) { + if (!firstEvalData) { + firstEvalData = hookContext.hookData.get('evalData'); + } else { + secondEvalData = hookContext.hookData.get('evalData'); + } + }, + }; + + client.addHooks(hook); + + // First evaluation + client.getBooleanValue('test-flag', false); + // Second evaluation + client.getBooleanValue('test-flag', false); + + expect(firstEvalData).toBe('evaluation-specific'); + expect(secondEvalData).toBe('evaluation-specific'); + }); + + it('should isolate data between global, client, and invocation hooks', () => { + const globalHook = new IsolationTestHook('global'); + const clientHook = new IsolationTestHook('client'); + const invocationHook = new IsolationTestHook('invocation'); + + api.addHooks(globalHook); + client.addHooks(clientHook); + + expect(client.getBooleanValue('test-flag', false, { hooks: [invocationHook] })).toBe(true); + }); + }); + + describe('Use Cases', () => { + it('should support timing measurements', () => { + const timingHook = new TimingHook(); + client.addHooks(timingHook); + + client.getBooleanValue('test-flag', false); + + expect(timingHook.duration).toBeDefined(); + expect(timingHook.duration).toBeGreaterThanOrEqual(0); + }); + + it('should support multi-stage validation accumulation', () => { + let finalErrors: string[] = []; + + const validationHook: Hook = { + before(hookContext: BeforeHookContext) { + hookContext.hookData.set('errors', []); + + // Simulate validation + const errors = hookContext.hookData.get('errors') as string[]; + if (!hookContext.context.userId) { + errors.push('Missing userId'); + } + if (!hookContext.context.region) { + errors.push('Missing region'); + } + }, + + finally(hookContext: HookContext) { + finalErrors = (hookContext.hookData.get('errors') as string[]) || []; + }, + }; + + client.addHooks(validationHook); + client.getBooleanValue('test-flag', false, {}); + + expect(finalErrors).toContain('Missing userId'); + expect(finalErrors).toContain('Missing region'); + }); + + it('should support request correlation', () => { + let correlationId: string | undefined; + + const correlationHook: Hook = { + before(hookContext: BeforeHookContext) { + const id = `req-${Date.now()}-${Math.random()}`; + hookContext.hookData.set('correlationId', id); + }, + + after(hookContext: HookContext) { + correlationId = hookContext.hookData.get('correlationId') as string; + }, + }; + + client.addHooks(correlationHook); + client.getBooleanValue('test-flag', false); + + expect(correlationId).toBeDefined(); + expect(correlationId).toMatch(/^req-\d+-[\d.]+$/); + }); + }); +}); \ No newline at end of file