Skip to content

Commit

Permalink
Allow making snapshots and evaluating settings on snapshots
Browse files Browse the repository at this point in the history
  • Loading branch information
adams85 committed Jul 25, 2023
1 parent 1e5c677 commit 86442a2
Show file tree
Hide file tree
Showing 3 changed files with 187 additions and 156 deletions.
247 changes: 138 additions & 109 deletions src/ConfigCatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import type { IConfigService } from "./ConfigServiceBase";
import { RefreshResult } from "./ConfigServiceBase";
import type { IEventEmitter } from "./EventEmitter";
import { OverrideBehaviour } from "./FlagOverrides";
import { ClientReadyState, HookEvents, Hooks, IProvidesHooks } from "./Hooks";
import type { HookEvents, Hooks, IProvidesHooks } from "./Hooks";
import { ClientReadyState } from "./Hooks";
import { LazyLoadConfigService } from "./LazyLoadConfigService";
import { ManualPollConfigService } from "./ManualPollConfigService";
import { getWeakRefStub, isWeakRefAvailable } from "./Polyfills";
import type { ProjectConfig, RolloutPercentageItem, RolloutRule, Setting, SettingValue } from "./ProjectConfig";
import type { IConfig, ProjectConfig, RolloutPercentageItem, RolloutRule, Setting, SettingValue } from "./ProjectConfig";
import type { IEvaluationDetails, IRolloutEvaluator, SettingTypeOf, User } from "./RolloutEvaluator";
import { RolloutEvaluator, checkSettingsAvailable, evaluate, evaluateAll, evaluationDetailsFromDefaultValue, getTimestampAsDate, isAllowedValue } from "./RolloutEvaluator";
import { errorToString } from "./Utils";
Expand All @@ -34,20 +35,6 @@ export interface IConfigCatClient extends IProvidesHooks {
*/
getValueAsync<T extends SettingValue>(key: string, defaultValue: T, user?: User): Promise<SettingTypeOf<T>>;

/**
* Returns the value of a feature flag or setting identified by `key` synchronously from the cache.
* @remarks
* It is important to provide an argument for the `defaultValue` parameter that matches the type of the feature flag or setting you are evaluating.
* Please refer to {@link https://configcat.com/docs/sdk-reference/js/#setting-type-mapping | this table} for the corresponding types.
* @param key Key of the feature flag or setting.
* @param defaultValue In case of failure, this value will be returned. Only the following types are allowed: `string`, `boolean`, `number`, `null` and `undefined`.
* @param user The User Object to use for evaluating targeting rules and percentage options.
* @returns The cached value of the feature flag or setting.
* @throws {Error} `key` is empty.
* @throws {TypeError} `defaultValue` is not of an allowed type.
*/
getValue<T extends SettingValue>(key: string, defaultValue: T, user?: User): SettingTypeOf<T>;

/**
* Returns the value along with evaluation details of a feature flag or setting identified by `key`.
* @remarks
Expand All @@ -62,20 +49,6 @@ export interface IConfigCatClient extends IProvidesHooks {
*/
getValueDetailsAsync<T extends SettingValue>(key: string, defaultValue: T, user?: User): Promise<IEvaluationDetails<SettingTypeOf<T>>>;

/**
* Returns the value along with evaluation details of a feature flag or setting identified by `key` synchronously from the cache.
* @remarks
* It is important to provide an argument for the `defaultValue` parameter that matches the type of the feature flag or setting you are evaluating.
* Please refer to {@link https://configcat.com/docs/sdk-reference/js/#setting-type-mapping | this table} for the corresponding types.
* @param key Key of the feature flag or setting.
* @param defaultValue In case of failure, this value will be returned. Only the following types are allowed: `string`, `boolean`, `number`, `null` and `undefined`.
* @param user The User Object to use for evaluating targeting rules and percentage options.
* @returns The cached value along with the details of evaluation of the feature flag or setting.
* @throws {Error} `key` is empty.
* @throws {TypeError} `defaultValue` is not of an allowed type.
*/
getValueDetails<T extends SettingValue>(key: string, defaultValue: T, user?: User): IEvaluationDetails<SettingTypeOf<T>>;

/**
* Returns all setting keys.
* @returns A promise that fulfills with the array of keys.
Expand Down Expand Up @@ -117,6 +90,8 @@ export interface IConfigCatClient extends IProvidesHooks {
*/
waitForReady(): Promise<ClientReadyState>;

snapshot(): IConfigCatClientSnapshot;

/**
* Sets the default user.
* @param defaultUser The default User Object to use for evaluating targeting rules and percentage options.
Expand Down Expand Up @@ -149,6 +124,43 @@ export interface IConfigCatClient extends IProvidesHooks {
dispose(): void;
}

/** Represents the state of `IConfigCatClient` captured at a specific point in time. */
export interface IConfigCatClientSnapshot {
/** The latest config which has been fetched from the remote server. */
readonly remoteConfig: IConfig | null;

/** The available setting keys. (In case the client is configured to use flag override, this will also include the keys provided by the flag override). */
readonly settingKeys: ReadonlyArray<string>;

/**
* Returns the value of a feature flag or setting identified by `key` synchronously, based on the snapshot.
* @remarks
* It is important to provide an argument for the `defaultValue` parameter that matches the type of the feature flag or setting you are evaluating.
* Please refer to {@link https://configcat.com/docs/sdk-reference/js/#setting-type-mapping | this table} for the corresponding types.
* @param key Key of the feature flag or setting.
* @param defaultValue In case of failure, this value will be returned. Only the following types are allowed: `string`, `boolean`, `number`, `null` and `undefined`.
* @param user The User Object to use for evaluating targeting rules and percentage options.
* @returns The cached value of the feature flag or setting.
* @throws {Error} `key` is empty.
* @throws {TypeError} `defaultValue` is not of an allowed type.
*/
getValue<T extends SettingValue>(key: string, defaultValue: T, user?: User): SettingTypeOf<T>;

/**
* Returns the value along with evaluation details of a feature flag or setting identified by `key` synchronously, based on the snapshot.
* @remarks
* It is important to provide an argument for the `defaultValue` parameter that matches the type of the feature flag or setting you are evaluating.
* Please refer to {@link https://configcat.com/docs/sdk-reference/js/#setting-type-mapping | this table} for the corresponding types.
* @param key Key of the feature flag or setting.
* @param defaultValue In case of failure, this value will be returned. Only the following types are allowed: `string`, `boolean`, `number`, `null` and `undefined`.
* @param user The User Object to use for evaluating targeting rules and percentage options.
* @returns The cached value along with the details of evaluation of the feature flag or setting.
* @throws {Error} `key` is empty.
* @throws {TypeError} `defaultValue` is not of an allowed type.
*/
getValueDetails<T extends SettingValue>(key: string, defaultValue: T, user?: User): IEvaluationDetails<SettingTypeOf<T>>;
}

export interface IConfigCatKernel {
configFetcher: IConfigFetcher;
sdkType: string;
Expand Down Expand Up @@ -227,9 +239,9 @@ export class ConfigCatClient implements IConfigCatClient {

const optionsClass =
pollingMode === PollingMode.AutoPoll ? AutoPollOptions :
pollingMode === PollingMode.ManualPoll ? ManualPollOptions :
pollingMode === PollingMode.LazyLoad ? LazyLoadOptions :
(() => { throw new Error("Invalid 'pollingMode' value"); })();
pollingMode === PollingMode.ManualPoll ? ManualPollOptions :
pollingMode === PollingMode.LazyLoad ? LazyLoadOptions :
(() => { throw new Error("Invalid 'pollingMode' value"); })();

const actualOptions = new optionsClass(sdkKey, configCatKernel.sdkType, configCatKernel.sdkVersion, options, configCatKernel.defaultCacheFactory, configCatKernel.eventEmitterFactory);

Expand Down Expand Up @@ -270,13 +282,14 @@ export class ConfigCatClient implements IConfigCatClient {
this.evaluator = new RolloutEvaluator(options.logger);

if (options.flagOverrides?.behaviour !== OverrideBehaviour.LocalOnly) {
this.configService = options instanceof AutoPollOptions ? new AutoPollConfigService(configCatKernel.configFetcher, options) :
options instanceof ManualPollOptions ? new ManualPollConfigService(configCatKernel.configFetcher, options) :
options instanceof LazyLoadOptions ? new LazyLoadConfigService(configCatKernel.configFetcher, options) :
(() => { throw new Error("Invalid 'options' value"); })();
this.configService =
options instanceof AutoPollOptions ? new AutoPollConfigService(configCatKernel.configFetcher, options) :
options instanceof ManualPollOptions ? new ManualPollConfigService(configCatKernel.configFetcher, options) :
options instanceof LazyLoadOptions ? new LazyLoadConfigService(configCatKernel.configFetcher, options) :
(() => { throw new Error("Invalid 'options' value"); })();
}
else {
this.options.signalReadyState(ClientReadyState.HasLocalOverrideFlagDataOnly);
this.options.hooks.emit("clientReady", ClientReadyState.HasLocalOverrideFlagDataOnly);
}

this.suppressFinalize = registerForFinalization(this, { sdkKey: options.apiKey, cacheToken, configService: this.configService, logger: options.logger });
Expand Down Expand Up @@ -358,31 +371,6 @@ export class ConfigCatClient implements IConfigCatClient {
return value;
}

getValue<T extends SettingValue>(key: string, defaultValue: T, user?: User): SettingTypeOf<T> {
this.options.logger.debug("getValue() called.");

validateKey(key);
ensureAllowedDefaultValue(defaultValue);

let value: SettingTypeOf<T>, evaluationDetails: IEvaluationDetails<SettingTypeOf<T>>;
let remoteConfig: ProjectConfig | null = null;
user ??= this.defaultUser;
try {
let settings: { [name: string]: Setting } | null;
[settings, remoteConfig] = this.getSettingsFromCache();
evaluationDetails = evaluate(this.evaluator, settings, key, defaultValue, user, remoteConfig, this.options.logger);
value = evaluationDetails.value;
}
catch (err) {
this.options.logger.settingEvaluationErrorSingle("getValue", key, "defaultValue", defaultValue, err);
evaluationDetails = evaluationDetailsFromDefaultValue(key, defaultValue, getTimestampAsDate(remoteConfig), user, errorToString(err), err);
value = defaultValue as SettingTypeOf<T>;
}

this.options.hooks.emit("flagEvaluated", evaluationDetails);
return value;
}

async getValueDetailsAsync<T extends SettingValue>(key: string, defaultValue: T, user?: User): Promise<IEvaluationDetails<SettingTypeOf<T>>> {
this.options.logger.debug("getValueDetailsAsync() called.");

Expand All @@ -406,29 +394,6 @@ export class ConfigCatClient implements IConfigCatClient {
return evaluationDetails;
}

getValueDetails<T extends SettingValue>(key: string, defaultValue: T, user?: User): IEvaluationDetails<SettingTypeOf<T>> {
this.options.logger.debug("getValueDetails() called.");

validateKey(key);
ensureAllowedDefaultValue(defaultValue);

let evaluationDetails: IEvaluationDetails<SettingTypeOf<T>>;
let remoteConfig: ProjectConfig | null = null;
user ??= this.defaultUser;
try {
let settings: { [name: string]: Setting } | null;
[settings, remoteConfig] = this.getSettingsFromCache();
evaluationDetails = evaluate(this.evaluator, settings, key, defaultValue, user, remoteConfig, this.options.logger);
}
catch (err) {
this.options.logger.settingEvaluationErrorSingle("getValueDetails", key, "defaultValue", defaultValue, err);
evaluationDetails = evaluationDetailsFromDefaultValue(key, defaultValue, getTimestampAsDate(remoteConfig), user, errorToString(err), err);
}

this.options.hooks.emit("flagEvaluated", evaluationDetails);
return evaluationDetails;
}

async getAllKeysAsync(): Promise<string[]> {
this.options.logger.debug("getAllKeysAsync() called.");

Expand Down Expand Up @@ -592,40 +557,39 @@ export class ConfigCatClient implements IConfigCatClient {
return this.options.readyPromise;
}

private async getSettingsAsync(): Promise<SettingsWithRemoteConfig> {
this.options.logger.debug("getSettingsAsync() called.");

const getRemoteConfigAsync: () => Promise<SettingsWithRemoteConfig> = async () => {
const config = await this.configService!.getConfig();
snapshot(): IConfigCatClientSnapshot {
const getRemoteConfig: () => SettingsWithRemoteConfig = () => {
const config = this.options.cache.getInMemory();
const settings = !config.isEmpty ? config.config!.settings : null;
return [settings, config];
};

let remoteSettings: { [name: string]: Setting } | null;
let remoteConfig: ProjectConfig | null;
const flagOverrides = this.options?.flagOverrides;
if (flagOverrides) {
let remoteSettings: { [name: string]: Setting } | null;
let remoteConfig: ProjectConfig | null;
const localSettings = await flagOverrides.dataSource.getOverrides();
const localSettings = flagOverrides.dataSource.getOverridesSync();
switch (flagOverrides.behaviour) {
case OverrideBehaviour.LocalOnly:
return [localSettings, null];
return new Snapshot(localSettings, null, this);
case OverrideBehaviour.LocalOverRemote:
[remoteSettings, remoteConfig] = await getRemoteConfigAsync();
return [{ ...(remoteSettings ?? {}), ...localSettings }, remoteConfig];
[remoteSettings, remoteConfig] = getRemoteConfig();
return new Snapshot({ ...(remoteSettings ?? {}), ...localSettings }, remoteConfig, this);
case OverrideBehaviour.RemoteOverLocal:
[remoteSettings, remoteConfig] = await getRemoteConfigAsync();
return [{ ...localSettings, ...(remoteSettings ?? {}) }, remoteConfig];
[remoteSettings, remoteConfig] = getRemoteConfig();
return new Snapshot({ ...localSettings, ...(remoteSettings ?? {}) }, remoteConfig, this);
}
}

return await getRemoteConfigAsync();
[remoteSettings, remoteConfig] = getRemoteConfig();
return new Snapshot(remoteSettings, remoteConfig, this);
}

private getSettingsFromCache(): SettingsWithRemoteConfig {
this.options.logger.debug("getSettingsFromCache() called.");
private async getSettingsAsync(): Promise<SettingsWithRemoteConfig> {
this.options.logger.debug("getSettingsAsync() called.");

const getRemoteConfig: () => SettingsWithRemoteConfig = () => {
const config = this.options.cache.getInMemory();
const getRemoteConfigAsync: () => Promise<SettingsWithRemoteConfig> = async () => {
const config = await this.configService!.getConfig();
const settings = !config.isEmpty ? config.config!.settings : null;
return [settings, config];
};
Expand All @@ -634,20 +598,20 @@ export class ConfigCatClient implements IConfigCatClient {
if (flagOverrides) {
let remoteSettings: { [name: string]: Setting } | null;
let remoteConfig: ProjectConfig | null;
const localSettings = flagOverrides.dataSource.getOverridesSync();
const localSettings = await flagOverrides.dataSource.getOverrides();
switch (flagOverrides.behaviour) {
case OverrideBehaviour.LocalOnly:
return [localSettings, null];
case OverrideBehaviour.LocalOverRemote:
[remoteSettings, remoteConfig] = getRemoteConfig();
[remoteSettings, remoteConfig] = await getRemoteConfigAsync();
return [{ ...(remoteSettings ?? {}), ...localSettings }, remoteConfig];
case OverrideBehaviour.RemoteOverLocal:
[remoteSettings, remoteConfig] = getRemoteConfig();
[remoteSettings, remoteConfig] = await getRemoteConfigAsync();
return [{ ...localSettings, ...(remoteSettings ?? {}) }, remoteConfig];
}
}

return getRemoteConfig();
return await getRemoteConfigAsync();
}

/** @inheritdoc */
Expand Down Expand Up @@ -696,6 +660,71 @@ export class ConfigCatClient implements IConfigCatClient {
}
}

class Snapshot implements IConfigCatClientSnapshot {
private readonly defaultUser: User | undefined;
private readonly evaluator: IRolloutEvaluator;
private readonly options: ConfigCatClientOptions;

constructor(
private readonly mergedSettings: { [name: string]: Setting } | null,
private readonly cachedConfig: ProjectConfig | null,
client: ConfigCatClient) {

this.defaultUser = client["defaultUser"];
this.evaluator = client["evaluator"];
this.options = client["options"];
}

get remoteConfig() {
const config = this.cachedConfig;
return config && !config.isEmpty ? config.config! : null;
}

get settingKeys() { return this.mergedSettings ? Object.keys(this.mergedSettings) : []; }

getValue<T extends SettingValue>(key: string, defaultValue: T, user?: User): SettingTypeOf<T> {
this.options.logger.debug("Snapshot.getValue() called.");

validateKey(key);
ensureAllowedDefaultValue(defaultValue);

let value: SettingTypeOf<T>, evaluationDetails: IEvaluationDetails<SettingTypeOf<T>>;
user ??= this.defaultUser;
try {
evaluationDetails = evaluate(this.evaluator, this.mergedSettings, key, defaultValue, user, this.cachedConfig, this.options.logger);
value = evaluationDetails.value;
}
catch (err) {
this.options.logger.settingEvaluationErrorSingle("getValue", key, "defaultValue", defaultValue, err);
evaluationDetails = evaluationDetailsFromDefaultValue(key, defaultValue, getTimestampAsDate(this.cachedConfig), user, errorToString(err), err);
value = defaultValue as SettingTypeOf<T>;
}

this.options.hooks.emit("flagEvaluated", evaluationDetails);
return value;
}

getValueDetails<T extends SettingValue>(key: string, defaultValue: T, user?: User): IEvaluationDetails<SettingTypeOf<T>> {
this.options.logger.debug("Snapshot.getValueDetails() called.");

validateKey(key);
ensureAllowedDefaultValue(defaultValue);

let evaluationDetails: IEvaluationDetails<SettingTypeOf<T>>;
user ??= this.defaultUser;
try {
evaluationDetails = evaluate(this.evaluator, this.mergedSettings, key, defaultValue, user, this.cachedConfig, this.options.logger);
}
catch (err) {
this.options.logger.settingEvaluationErrorSingle("getValueDetails", key, "defaultValue", defaultValue, err);
evaluationDetails = evaluationDetailsFromDefaultValue(key, defaultValue, getTimestampAsDate(this.cachedConfig), user, errorToString(err), err);
}

this.options.hooks.emit("flagEvaluated", evaluationDetails);
return evaluationDetails;
}
}

/** Setting key-value pair. */
export class SettingKeyValue<TValue = SettingValue> {
constructor(
Expand Down Expand Up @@ -723,7 +752,7 @@ function ensureAllowedDefaultValue(value: SettingValue): void {
// that would prevent the client object from being GC'd, which would defeat the whole purpose of the finalization logic.
interface IFinalizationData { sdkKey: string; cacheToken?: object; configService?: IConfigService; logger?: LoggerWrapper }

let registerForFinalization = function(client: ConfigCatClient, data: IFinalizationData): () => void {
let registerForFinalization = function (client: ConfigCatClient, data: IFinalizationData): () => void {
// Use FinalizationRegistry (finalization callbacks) if the runtime provides that feature.
if (typeof FinalizationRegistry !== "undefined") {
const finalizationRegistry = new FinalizationRegistry<IFinalizationData>(data => ConfigCatClient["finalize"](data));
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export { SettingType, Comparator } from "./ProjectConfig";

export type { IConfigCatClient };

export type { IConfigCatClientSnapshot } from "./ConfigCatClient";

export { SettingKeyValue } from "./ConfigCatClient";

export type { IEvaluationDetails, SettingTypeOf } from "./RolloutEvaluator";
Expand Down
Loading

0 comments on commit 86442a2

Please sign in to comment.