Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions __tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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');
});
});
45 changes: 39 additions & 6 deletions src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export class ExperimentClient implements Client {
flagPollerIntervalMillis,
);
private isRunning = false;
private readonly flagsAndVariantsLoadedPromise: Promise<void>[] | undefined;
private readonly initialFlags: EvaluationFlag[] | undefined;

/**
* Creates a new ExperimentClient instance.
Expand Down Expand Up @@ -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<ExperimentClient> {
await Promise.all(this.flagsAndVariantsLoadedPromise);
return this;
}

/**
Expand Down Expand Up @@ -373,6 +386,27 @@ export class ExperimentClient implements Client {
return this;
}

private convertInitialFlagsForStorage(): Record<string, EvaluationFlag> {
if (this.initialFlags) {
const flagsMap: Record<string, EvaluationFlag> = {};
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);
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion src/storage/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class LoadStoreCache<V> {
this.cache = {};
}

public async load(): Promise<void> {
public async load(initialValues?: Record<string, V>): Promise<void> {
const rawValues = await this.storage.get(this.namespace);
let jsonValues: Record<string, unknown>;
try {
Expand All @@ -97,6 +97,9 @@ export class LoadStoreCache<V> {
}
}
this.clear();
if (initialValues) {
this.putAll(initialValues);
}
this.putAll(values);
}

Expand Down
8 changes: 8 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"` |
Expand All @@ -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',
Expand Down