From 6206c6ef5d5bdba5a8930b2ab5cc05b4146b3a89 Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Thu, 30 Nov 2023 10:50:42 -0800 Subject: [PATCH 1/8] feat: support bootstrapping initial local eval flags --- __tests__/client.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++ src/experimentClient.ts | 13 ++++++++++++ src/types/config.ts | 8 +++++++ 3 files changed, 67 insertions(+) diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index ee2b7b7..d9ce91c 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,49 @@ 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 () => { + const 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(), + }); + 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'); + 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'); + const client2 = new ExperimentClient(API_KEY, { + fetchOnStart: false, + }); + client2.setUser(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'); + }); }); diff --git a/src/experimentClient.ts b/src/experimentClient.ts index 1b0681d..41b2ab8 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -140,6 +140,7 @@ export class ExperimentClient implements Client { void this.flags.load(); // eslint-disable-next-line no-void void this.variants.load(); + this.mergeInitialFlagsWithStorage(); } /** @@ -373,6 +374,17 @@ export class ExperimentClient implements Client { return this; } + private mergeInitialFlagsWithStorage(): void { + if (this.config.initialFlags) { + const initialFlags = JSON.parse(this.config.initialFlags); + for (const key in initialFlags) { + if (!this.flags.get(initialFlags[key].key)) { + this.flags.put(initialFlags[key].key, initialFlags[key]); + } + } + } + } + private evaluate(flagKeys?: string[]): Variants { const user = this.addContextSync(this.user); const flags = topologicalSort(this.flags.getAll(), flagKeys); @@ -653,6 +665,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/types/config.ts b/src/types/config.ts index 0a0a074..e318a19 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -36,6 +36,12 @@ export interface ExperimentConfig { */ initialVariants?: Variants; + /** + * Initial values for flags. + */ + + 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** | `null` | | **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: null, source: Source.LocalStorage, serverUrl: 'https://api.lab.amplitude.com', flagsServerUrl: 'https://flag.lab.amplitude.com', From 48255236ca170cb284689d447a2a89b01efbf531 Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Thu, 30 Nov 2023 10:55:40 -0800 Subject: [PATCH 2/8] docs: add comments --- __tests__/client.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index d9ce91c..02b4a19 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -973,6 +973,7 @@ describe('start', () => { }); test('initial flags', async () => { + // Flag, sdk-ci-test-local is modified to always return off const client = new ExperimentClient(API_KEY, { fetchOnStart: false, initialFlags: ` @@ -988,15 +989,18 @@ describe('start', () => { 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'); From 5a158ada508611a83d084644b5f3541d6f31e5ea Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Thu, 30 Nov 2023 13:47:25 -0800 Subject: [PATCH 3/8] docs: update desc --- src/types/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/config.ts b/src/types/config.ts index e318a19..9435f03 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -37,9 +37,9 @@ export interface ExperimentConfig { initialVariants?: Variants; /** - * Initial values for flags. + * Initial values for flags. This is useful for bootstrapping the + * client with fallbacks for flag configs. */ - initialFlags?: string; /** From 837377f425fc2c48f84aff5915ffa0b82a877feb Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Fri, 1 Dec 2023 15:06:18 -0800 Subject: [PATCH 4/8] refactor: update per comments --- src/experimentClient.ts | 8 ++++---- src/types/config.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/experimentClient.ts b/src/experimentClient.ts index 41b2ab8..1052433 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -377,11 +377,11 @@ export class ExperimentClient implements Client { private mergeInitialFlagsWithStorage(): void { if (this.config.initialFlags) { const initialFlags = JSON.parse(this.config.initialFlags); - for (const key in initialFlags) { - if (!this.flags.get(initialFlags[key].key)) { - this.flags.put(initialFlags[key].key, initialFlags[key]); + initialFlags.forEach((flag: EvaluationFlag) => { + if (!this.flags.get(flag.key)) { + this.flags.put(flag.key, flag); } - } + }); } } diff --git a/src/types/config.ts b/src/types/config.ts index 9435f03..b66698f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -142,7 +142,7 @@ export interface ExperimentConfig { | **instanceName** | `$default_instance` | | **fallbackVariant** | `null` | | **initialVariants** | `null` | - | **initialFlags** | `null` | + | **initialFlags** | `undefined` | | **source** | `Source.LocalStorage` | | **serverUrl** | `"https://api.lab.amplitude.com"` | | **flagsServerUrl** | `"https://flag.lab.amplitude.com"` | @@ -165,7 +165,7 @@ export const Defaults: ExperimentConfig = { instanceName: '$default_instance', fallbackVariant: {}, initialVariants: {}, - initialFlags: null, + initialFlags: undefined, source: Source.LocalStorage, serverUrl: 'https://api.lab.amplitude.com', flagsServerUrl: 'https://flag.lab.amplitude.com', From 75133023cd3d6a372f229d11b6bef44ee882566b Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Mon, 22 Jan 2024 15:24:02 -0800 Subject: [PATCH 5/8] refactor: update load() --- src/experimentClient.ts | 13 +++++++++---- src/storage/cache.ts | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/experimentClient.ts b/src/experimentClient.ts index 1052433..0653141 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -137,7 +137,7 @@ export class ExperimentClient implements Client { ); this.flags = getFlagStorage(this.apiKey, this.config.instanceName, storage); // eslint-disable-next-line no-void - void this.flags.load(); + void this.flags.load(this.getInitialFlags()); // eslint-disable-next-line no-void void this.variants.load(); this.mergeInitialFlagsWithStorage(); @@ -374,9 +374,16 @@ export class ExperimentClient implements Client { return this; } + private getInitialFlags(): Record { + if (this.config.initialFlags) { + return JSON.parse(this.config.initialFlags); + } + return []; + } + private mergeInitialFlagsWithStorage(): void { if (this.config.initialFlags) { - const initialFlags = JSON.parse(this.config.initialFlags); + const initialFlags = this.getInitialFlags(); initialFlags.forEach((flag: EvaluationFlag) => { if (!this.flags.get(flag.key)) { this.flags.put(flag.key, flag); @@ -416,8 +423,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: 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); } From f30782fefd9c0edcb2764d8e80d9483f8c0855ed Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Tue, 6 Feb 2024 13:54:31 -0800 Subject: [PATCH 6/8] fix: remove mergeInitialFlagsWithStorage from init --- src/experimentClient.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/experimentClient.ts b/src/experimentClient.ts index 0653141..5a060a5 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -140,7 +140,6 @@ export class ExperimentClient implements Client { void this.flags.load(this.getInitialFlags()); // eslint-disable-next-line no-void void this.variants.load(); - this.mergeInitialFlagsWithStorage(); } /** @@ -376,14 +375,18 @@ export class ExperimentClient implements Client { private getInitialFlags(): Record { if (this.config.initialFlags) { - return JSON.parse(this.config.initialFlags); + const initialFlags = JSON.parse(this.config.initialFlags); + const flagsMap: Record = {}; + initialFlags.forEach((flag: EvaluationFlag) => { + flagsMap[flag.key] = flag; + }); } - return []; + return {}; } private mergeInitialFlagsWithStorage(): void { if (this.config.initialFlags) { - const initialFlags = this.getInitialFlags(); + const initialFlags = JSON.parse(this.config.initialFlags); initialFlags.forEach((flag: EvaluationFlag) => { if (!this.flags.get(flag.key)) { this.flags.put(flag.key, flag); From 029971278ead599aa1446f8e1e5a36473043b1f8 Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Tue, 6 Feb 2024 16:08:48 -0800 Subject: [PATCH 7/8] fix: add ready() to await flags and variants loading --- __tests__/client.test.ts | 3 ++- src/experimentClient.ts | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index 02b4a19..8f552ee 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -974,7 +974,7 @@ describe('start', () => { test('initial flags', async () => { // Flag, sdk-ci-test-local is modified to always return off - const client = new ExperimentClient(API_KEY, { + let client = new ExperimentClient(API_KEY, { fetchOnStart: false, initialFlags: ` [ @@ -983,6 +983,7 @@ describe('start', () => { ] `.trim(), }); + client = await client.ready(); const user: ExperimentUser = { user_id: 'user_id', device_id: 'device_id' }; client.setUser(user); let variant = client.variant('sdk-ci-test-local'); diff --git a/src/experimentClient.ts b/src/experimentClient.ts index 5a060a5..258bfcf 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -78,6 +78,7 @@ export class ExperimentClient implements Client { flagPollerIntervalMillis, ); private isRunning = false; + private readonly flagsAndVariantsLoadedPromise: Promise[] | undefined; /** * Creates a new ExperimentClient instance. @@ -136,10 +137,18 @@ 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(this.getInitialFlags()); - // eslint-disable-next-line no-void - void this.variants.load(); + this.flagsAndVariantsLoadedPromise = [ + this.flags.load(this.getInitialFlags()), + this.variants.load(), + ]; + } + + /** + * Call to ensure the completion of the loading variants and flags from localStorage upon initialization. + */ + public async ready(): Promise { + await Promise.all(this.flagsAndVariantsLoadedPromise); + return this; } /** @@ -380,6 +389,7 @@ export class ExperimentClient implements Client { initialFlags.forEach((flag: EvaluationFlag) => { flagsMap[flag.key] = flag; }); + return flagsMap; } return {}; } From 5a5da78069b9b67d05d09ac45374bff3b64a03e1 Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Tue, 13 Feb 2024 10:34:21 -0800 Subject: [PATCH 8/8] refactor: rename ready() to cacheReady, create class variable for parsed initialFlags --- __tests__/client.test.ts | 2 +- src/experimentClient.ts | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index 8f552ee..214c7d3 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -983,7 +983,7 @@ describe('start', () => { ] `.trim(), }); - client = await client.ready(); + 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'); diff --git a/src/experimentClient.ts b/src/experimentClient.ts index 258bfcf..b5fc832 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -79,6 +79,7 @@ export class ExperimentClient implements Client { ); private isRunning = false; private readonly flagsAndVariantsLoadedPromise: Promise[] | undefined; + private readonly initialFlags: EvaluationFlag[] | undefined; /** * Creates a new ExperimentClient instance. @@ -137,8 +138,11 @@ export class ExperimentClient implements Client { storage, ); this.flags = getFlagStorage(this.apiKey, this.config.instanceName, storage); + if (this.config.initialFlags) { + this.initialFlags = JSON.parse(this.config.initialFlags); + } this.flagsAndVariantsLoadedPromise = [ - this.flags.load(this.getInitialFlags()), + this.flags.load(this.convertInitialFlagsForStorage()), this.variants.load(), ]; } @@ -146,7 +150,7 @@ export class ExperimentClient implements Client { /** * Call to ensure the completion of the loading variants and flags from localStorage upon initialization. */ - public async ready(): Promise { + public async cacheReady(): Promise { await Promise.all(this.flagsAndVariantsLoadedPromise); return this; } @@ -382,11 +386,10 @@ export class ExperimentClient implements Client { return this; } - private getInitialFlags(): Record { - if (this.config.initialFlags) { - const initialFlags = JSON.parse(this.config.initialFlags); + private convertInitialFlagsForStorage(): Record { + if (this.initialFlags) { const flagsMap: Record = {}; - initialFlags.forEach((flag: EvaluationFlag) => { + this.initialFlags.forEach((flag: EvaluationFlag) => { flagsMap[flag.key] = flag; }); return flagsMap; @@ -395,9 +398,8 @@ export class ExperimentClient implements Client { } private mergeInitialFlagsWithStorage(): void { - if (this.config.initialFlags) { - const initialFlags = JSON.parse(this.config.initialFlags); - initialFlags.forEach((flag: EvaluationFlag) => { + if (this.initialFlags) { + this.initialFlags.forEach((flag: EvaluationFlag) => { if (!this.flags.get(flag.key)) { this.flags.put(flag.key, flag); }