diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index ee2b7b7..214c7d3 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -61,6 +61,7 @@ class TestHttpClient implements HttpClient { return { status: this.status, body: this.body } as SimpleResponse; } } + /** * Basic test that fetching variants for a user succeeds. */ @@ -956,4 +957,54 @@ describe('start', () => { await client.start(); expect(fetchSpy).toBeCalledTimes(0); }); + + test('with local evaluation only, fetchOnStart enabled, calls fetch', async () => { + const client = new ExperimentClient(API_KEY, { + fetchOnStart: true, + }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client.flags.getAll = () => { + return {}; + }; + const fetchSpy = jest.spyOn(client, 'fetch'); + await client.start(); + expect(fetchSpy).toBeCalledTimes(1); + }); + + test('initial flags', async () => { + // Flag, sdk-ci-test-local is modified to always return off + let client = new ExperimentClient(API_KEY, { + fetchOnStart: false, + initialFlags: ` + [ + {"key":"sdk-ci-test-local","metadata":{"deployed":true,"evaluationMode":"local","flagType":"release","flagVersion":1},"segments":[{"metadata":{"segmentName":"All Other Users"},"variant":"off"}],"variants":{"off":{"key":"off","metadata":{"default":true}},"on":{"key":"on","value":"on"}}}, + {"key":"sdk-ci-test-local-2","metadata":{"deployed":true,"evaluationMode":"local","flagType":"release","flagVersion":1},"segments":[{"metadata":{"segmentName":"All Other Users"},"variant":"on"}],"variants":{"off":{"key":"off","metadata":{"default":true}},"on":{"key":"on","value":"on"}}} + ] + `.trim(), + }); + client = await client.cacheReady(); + const user: ExperimentUser = { user_id: 'user_id', device_id: 'device_id' }; + client.setUser(user); + let variant = client.variant('sdk-ci-test-local'); + let variant2 = client.variant('sdk-ci-test-local-2'); + expect(variant.key).toEqual('off'); + expect(variant2.key).toEqual('on'); + // Call start to update the flag, overwrites the initial flag to return on + await client.start(user); + variant = client.variant('sdk-ci-test-local'); + variant2 = client.variant('sdk-ci-test-local-2'); + expect(variant.key).toEqual('on'); + expect(variant2.key).toEqual('on'); + // Initialize a second client with the same storage to simulate an app restart + const client2 = new ExperimentClient(API_KEY, { + fetchOnStart: false, + }); + client2.setUser(user); + // Storage flag should take precedent over initial flag + variant = client.variant('sdk-ci-test-local'); + variant2 = client.variant('sdk-ci-test-local-2'); + expect(variant.key).toEqual('on'); + expect(variant2.key).toEqual('on'); + }); }); diff --git a/src/experimentClient.ts b/src/experimentClient.ts index 1b0681d..b5fc832 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -78,6 +78,8 @@ export class ExperimentClient implements Client { flagPollerIntervalMillis, ); private isRunning = false; + private readonly flagsAndVariantsLoadedPromise: Promise[] | undefined; + private readonly initialFlags: EvaluationFlag[] | undefined; /** * Creates a new ExperimentClient instance. @@ -136,10 +138,21 @@ export class ExperimentClient implements Client { storage, ); this.flags = getFlagStorage(this.apiKey, this.config.instanceName, storage); - // eslint-disable-next-line no-void - void this.flags.load(); - // eslint-disable-next-line no-void - void this.variants.load(); + if (this.config.initialFlags) { + this.initialFlags = JSON.parse(this.config.initialFlags); + } + this.flagsAndVariantsLoadedPromise = [ + this.flags.load(this.convertInitialFlagsForStorage()), + this.variants.load(), + ]; + } + + /** + * Call to ensure the completion of the loading variants and flags from localStorage upon initialization. + */ + public async cacheReady(): Promise { + await Promise.all(this.flagsAndVariantsLoadedPromise); + return this; } /** @@ -373,6 +386,27 @@ export class ExperimentClient implements Client { return this; } + private convertInitialFlagsForStorage(): Record { + if (this.initialFlags) { + const flagsMap: Record = {}; + this.initialFlags.forEach((flag: EvaluationFlag) => { + flagsMap[flag.key] = flag; + }); + return flagsMap; + } + return {}; + } + + private mergeInitialFlagsWithStorage(): void { + if (this.initialFlags) { + this.initialFlags.forEach((flag: EvaluationFlag) => { + if (!this.flags.get(flag.key)) { + this.flags.put(flag.key, flag); + } + }); + } + } + private evaluate(flagKeys?: string[]): Variants { const user = this.addContextSync(this.user); const flags = topologicalSort(this.flags.getAll(), flagKeys); @@ -404,8 +438,6 @@ export class ExperimentClient implements Client { return sourceVariant; } - // TODO variant and source for both local and remote needs to be cleaned up. - /** * This function assumes the flag exists and is local evaluation mode. For * local evaluation, fallback order goes: @@ -653,6 +685,7 @@ export class ExperimentClient implements Client { this.flags.clear(); this.flags.putAll(flags); await this.flags.store(); + this.mergeInitialFlagsWithStorage(); } private async storeVariants( diff --git a/src/storage/cache.ts b/src/storage/cache.ts index da3d27a..1b9960b 100644 --- a/src/storage/cache.ts +++ b/src/storage/cache.ts @@ -71,7 +71,7 @@ export class LoadStoreCache { this.cache = {}; } - public async load(): Promise { + public async load(initialValues?: Record): Promise { const rawValues = await this.storage.get(this.namespace); let jsonValues: Record; try { @@ -97,6 +97,9 @@ export class LoadStoreCache { } } this.clear(); + if (initialValues) { + this.putAll(initialValues); + } this.putAll(values); } diff --git a/src/types/config.ts b/src/types/config.ts index 0a0a074..b66698f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -36,6 +36,12 @@ export interface ExperimentConfig { */ initialVariants?: Variants; + /** + * Initial values for flags. This is useful for bootstrapping the + * client with fallbacks for flag configs. + */ + initialFlags?: string; + /** * Determines the primary source of variants and variants before falling back. * @see Source @@ -136,6 +142,7 @@ export interface ExperimentConfig { | **instanceName** | `$default_instance` | | **fallbackVariant** | `null` | | **initialVariants** | `null` | + | **initialFlags** | `undefined` | | **source** | `Source.LocalStorage` | | **serverUrl** | `"https://api.lab.amplitude.com"` | | **flagsServerUrl** | `"https://flag.lab.amplitude.com"` | @@ -158,6 +165,7 @@ export const Defaults: ExperimentConfig = { instanceName: '$default_instance', fallbackVariant: {}, initialVariants: {}, + initialFlags: undefined, source: Source.LocalStorage, serverUrl: 'https://api.lab.amplitude.com', flagsServerUrl: 'https://flag.lab.amplitude.com',