diff --git a/src/index.spec.ts b/src/index.spec.ts index 908f601..64a1075 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -32,7 +32,14 @@ import { import * as util from './util/index'; -import { getInstance, IAssignmentEvent, IAssignmentLogger, init, NO_OP_EVENT_DISPATCHER } from '.'; +import { + getFlagsConfiguration, + getInstance, + IAssignmentEvent, + IAssignmentLogger, + init, + NO_OP_EVENT_DISPATCHER, +} from '.'; import SpyInstance = jest.SpyInstance; @@ -501,6 +508,10 @@ describe('EppoClient E2E test', () => { }, }; + afterEach(() => { + td.reset(); + }); + it('retries initial configuration request before resolving', async () => { td.replace(HttpClient.prototype, 'getUniversalFlagConfiguration'); let callCount = 0; @@ -635,8 +646,6 @@ describe('EppoClient E2E test', () => { let isReadOnlyFsSpy: SpyInstance; beforeEach(() => { - // Reset the module before each test - jest.resetModules(); // Create a spy on isReadOnlyFs that we can mock isReadOnlyFsSpy = jest.spyOn(util, 'isReadOnlyFs'); }); @@ -735,4 +744,94 @@ describe('EppoClient E2E test', () => { expect(configurationRequestParameters.pollAfterSuccessfulInitialization).toBe(false); }); }); + + describe('getFlagsConfiguration', () => { + let client: EppoClient | null = null; + + afterAll(() => { + if (client) { + client.stopPolling(); + } + }); + + it('returns configuration JSON matching flags-v1.json structure', async () => { + client = await init({ + apiKey: 'dummy', + baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, + assignmentLogger: { logAssignment: jest.fn() }, + }); + + const exportedConfig = getFlagsConfiguration(); + expect(exportedConfig).not.toBeNull(); + + const parsed = JSON.parse(exportedConfig ?? ''); + + // Verify top-level metadata + expect(parsed.format).toBe('SERVER'); + expect(parsed.createdAt).toBe('2024-04-17T19:40:53.716Z'); + expect(parsed.environment).toEqual({ name: 'Test' }); + + // Verify exact number of flags from flags-v1.json + expect(Object.keys(parsed.flags).length).toBe(22); + + // Verify a complex flag with rules and conditions: new-user-onboarding + const flag = parsed.flags['new-user-onboarding']; + expect(flag).toBeDefined(); + expect(flag.key).toBe('new-user-onboarding'); + expect(flag.enabled).toBe(true); + expect(flag.variationType).toBe('STRING'); + expect(flag.totalShards).toBe(10000); + + // Verify variations + expect(Object.keys(flag.variations).length).toBe(6); + expect(flag.variations.control).toEqual({ key: 'control', value: 'control' }); + expect(flag.variations.red).toEqual({ key: 'red', value: 'red' }); + expect(flag.variations.blue).toEqual({ key: 'blue', value: 'blue' }); + expect(flag.variations.green).toEqual({ key: 'green', value: 'green' }); + expect(flag.variations.yellow).toEqual({ key: 'yellow', value: 'yellow' }); + expect(flag.variations.purple).toEqual({ key: 'purple', value: 'purple' }); + + // Verify allocations structure + expect(flag.allocations.length).toBe(4); + + // First allocation: "id rule" with MATCHES condition + const idRuleAlloc = flag.allocations[0]; + expect(idRuleAlloc.key).toBe('id rule'); + expect(idRuleAlloc.doLog).toBe(false); + expect(idRuleAlloc.rules.length).toBe(1); + expect(idRuleAlloc.rules[0].conditions.length).toBe(1); + expect(idRuleAlloc.rules[0].conditions[0]).toEqual({ + attribute: 'id', + operator: 'MATCHES', + value: 'zach', + }); + expect(idRuleAlloc.splits[0].variationKey).toBe('purple'); + + // Second allocation: "internal users" with MATCHES condition + const internalUsersAlloc = flag.allocations[1]; + expect(internalUsersAlloc.key).toBe('internal users'); + expect(internalUsersAlloc.rules[0].conditions[0]).toEqual({ + attribute: 'email', + operator: 'MATCHES', + value: '@mycompany.com', + }); + + // Third allocation: "experiment" with NOT_ONE_OF condition and shards + const experimentAlloc = flag.allocations[2]; + expect(experimentAlloc.key).toBe('experiment'); + expect(experimentAlloc.doLog).toBe(true); + expect(experimentAlloc.rules[0].conditions[0].operator).toBe('NOT_ONE_OF'); + expect(experimentAlloc.rules[0].conditions[0].value).toEqual(['US', 'Canada', 'Mexico']); + expect(experimentAlloc.splits.length).toBe(3); // control, red, yellow + + // Fourth allocation: "rollout" with ONE_OF condition and extraLogging + const rolloutAlloc = flag.allocations[3]; + expect(rolloutAlloc.key).toBe('rollout'); + expect(rolloutAlloc.rules[0].conditions[0].operator).toBe('ONE_OF'); + expect(rolloutAlloc.splits[0].extraLogging).toEqual({ + allocationvalue_type: 'rollout', + owner: 'hippo', + }); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index f0c3dc8..d5bdd59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,14 @@ export { IClientConfig }; let clientInstance: EppoClient; +// We keep references to the configuration stores at module level because EppoClient +// does not expose public getters for store metadata (format, createdAt, environment) +// or bandit configurations. These references are needed by getFlagsConfiguration() +// and getBanditsConfiguration() to reconstruct exportable configuration JSON. +let flagConfigurationStore: MemoryOnlyConfigurationStore; +let banditVariationConfigurationStore: MemoryOnlyConfigurationStore; +let banditModelConfigurationStore: MemoryOnlyConfigurationStore; + export const NO_OP_EVENT_DISPATCHER: EventDispatcher = { // eslint-disable-next-line @typescript-eslint/no-empty-function attachContext: () => {}, @@ -86,9 +94,9 @@ export async function init(config: IClientConfig): Promise { throwOnFailedInitialization, }; - const flagConfigurationStore = new MemoryOnlyConfigurationStore(); - const banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); - const banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); + flagConfigurationStore = new MemoryOnlyConfigurationStore(); + banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); + banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); const eventDispatcher = newEventDispatcher(apiKey, eventTracking); clientInstance = new EppoClient({ @@ -144,6 +152,107 @@ export function getInstance(): EppoClient { return clientInstance; } +/** + * Reconstructs the current flags configuration as a JSON string. + * This can be used to bootstrap another SDK instance using offlineInit(). + * + * @returns JSON string containing the flags configuration, or null if not initialized + * @public + */ +export function getFlagsConfiguration(): string | null { + if (!flagConfigurationStore) { + return null; + } + + const flags = flagConfigurationStore.entries(); + const format = flagConfigurationStore.getFormat(); + const createdAt = flagConfigurationStore.getConfigPublishedAt(); + const environment = flagConfigurationStore.getEnvironment(); + + const configuration: { + createdAt?: string; + format?: string; + environment?: { name: string }; + flags: Record; + banditReferences?: Record< + string, + { + modelVersion: string; + flagVariations: BanditVariation[]; + } + >; + } = { + flags, + }; + + if (createdAt) { + configuration.createdAt = createdAt; + } + if (format) { + configuration.format = format; + } + if (environment) { + configuration.environment = environment; + } + + const banditReferences = reconstructBanditReferences(); + if (banditReferences) { + configuration.banditReferences = banditReferences; + } + + return JSON.stringify(configuration); +} + +/** + * Reconstructs banditReferences from stored variations and parameters. + * The variations are stored indexed by flag key, so we need to re-pivot them + * back to being indexed by bandit key for export. + */ +function reconstructBanditReferences(): Record< + string, + { modelVersion: string; flagVariations: BanditVariation[] } +> | null { + if (!banditVariationConfigurationStore || !banditModelConfigurationStore) { + return null; + } + + const variationsByFlagKey = banditVariationConfigurationStore.entries(); + const banditParameters = banditModelConfigurationStore.entries(); + + // Flatten all variations and group by bandit key + const variationsByBanditKey: Record = {}; + for (const variations of Object.values(variationsByFlagKey)) { + for (const variation of variations) { + const banditKey = variation.key; + if (!variationsByBanditKey[banditKey]) { + variationsByBanditKey[banditKey] = []; + } + variationsByBanditKey[banditKey].push(variation); + } + } + + // Build banditReferences with model versions + const banditReferences: Record< + string, + { modelVersion: string; flagVariations: BanditVariation[] } + > = {}; + for (const [banditKey, variations] of Object.entries(variationsByBanditKey)) { + const params = banditParameters[banditKey]; + if (params) { + banditReferences[banditKey] = { + modelVersion: params.modelVersion, + flagVariations: variations, + }; + } + } + + if (Object.keys(banditReferences).length === 0) { + return null; + } + + return banditReferences; +} + function newEventDispatcher( sdkKey: string, config: IClientConfig['eventTracking'] = {},