Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(core): add feature flag caching #21

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
115 changes: 88 additions & 27 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type ABConfig<T extends string = string> = {

type Settings<
FlagName extends string,
Flags extends Record<FlagName, FlagValueString> = Record<FlagName, FlagValueString>
Flags extends Record<FlagName, FlagValueString> = Record<FlagName, FlagValueString>,
> = {
flags?: {
defaultValues?: {
Expand Down Expand Up @@ -48,10 +48,15 @@ interface PersistentStorage {
set: (key: string, value: string) => void;
}

type FlagCacheConfig = {
refetchFlags: boolean;
timeToLiveInMinutes: number;
};

export type AbbyConfig<
FlagName extends string = string,
Tests extends Record<string, ABConfig> = Record<string, ABConfig>,
Flags extends Record<FlagName, FlagValueString> = Record<FlagName, FlagValueString>
Flags extends Record<FlagName, FlagValueString> = Record<FlagName, FlagValueString>,
> = {
projectId: string;
apiUrl?: string;
Expand All @@ -60,17 +65,25 @@ export type AbbyConfig<
flags?: Flags;
settings?: Settings<F.NoInfer<FlagName>, Flags>;
debug?: boolean;
flagCacheConfig?: FlagCacheConfig;
};

export class Abby<
FlagName extends string,
TestName extends string,
Tests extends Record<string, ABConfig>,
Flags extends Record<FlagName, FlagValueString>
Flags extends Record<FlagName, FlagValueString>,
> {
private log = (...args: any[]) =>
this.config.debug ? console.log(`core.Abby`, ...args) : () => {};

private testDevtoolOverrides: Map<keyof Tests, Tests[keyof Tests]["variants"][number]> =
new Map();

private flagDevtoolOverrides: Map<FlagName, boolean> = new Map();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

guess this shouldn't be "just booleans" anymore, right?
the fix should be another PR :)


#flagTimeoutMap: Map<string, Date> = new Map();

#data: LocalData<FlagName, TestName> = {
tests: {} as any,
flags: {} as any,
Expand All @@ -92,13 +105,20 @@ export class Abby<
private persistantFlagStorage?: PersistentStorage
) {
this._cfg = config as AbbyConfig<FlagName, Tests, Flags>;
this.#data.flags = Object.keys(config.flags ?? {}).reduce((acc, flagName) => {
acc[flagName as FlagName] = this.getDefaultFlagValue(
flagName as FlagName,
config.flags as any
);
return acc;
}, {} as Record<FlagName, FlagValue>);
this.#data.flags = Object.keys(config.flags ?? {}).reduce(
(acc, flagName) => {
acc[flagName as FlagName] = this.getDefaultFlagValue(
flagName as FlagName,
config.flags as any
);
const validUntil = new Date(
new Date().getTime() + 1000 * 60 * (this.config.flagCacheConfig?.timeToLiveInMinutes ?? 1)
); // flagdefault timeout is 1 minute
this.#flagTimeoutMap.set(flagName, validUntil);
return acc;
},
{} as Record<FlagName, FlagValue>
);
this.#data.tests = config.tests ?? ({} as any);
}

Expand Down Expand Up @@ -139,22 +159,28 @@ export class Abby<
data: AbbyDataResponse
): LocalData<FlagName, TestName> {
return {
tests: data.tests.reduce((acc, { name, weights }) => {
if (!acc[name as keyof Tests]) {
tests: data.tests.reduce(
(acc, { name, weights }) => {
if (!acc[name as keyof Tests]) {
return acc;
}

// assigned the fetched weights to the initial config
acc[name as keyof Tests] = {
...acc[name as keyof Tests],
weights,
};
return acc;
}

// assigned the fetched weights to the initial config
acc[name as keyof Tests] = {
...acc[name as keyof Tests],
weights,
};
return acc;
}, (this.config.tests ?? {}) as any),
flags: data.flags.reduce((acc, { name, value }) => {
acc[name] = value;
return acc;
}, {} as Record<string, FlagValue>),
},
(this.config.tests ?? {}) as any
),
flags: data.flags.reduce(
(acc, { name, value }) => {
acc[name] = value;
return acc;
},
{} as Record<string, FlagValue>
),
};
}

Expand Down Expand Up @@ -201,6 +227,39 @@ export class Abby<
return this.getProjectData();
}

/**
* Helper function to retrieve the time a flag is valid
* @param key
* @returns
*/
getFeatureFlagTimeout<F extends FlagName>(key: F) {
return this.#flagTimeoutMap.get(key);
}

/**
* Helper function to check if a featureflag should be refetched
* @param key name of the featureflag
* @returns value of flag
*/
getValidFlag<F extends FlagName>(key: F, refetch: boolean | undefined) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this may break every call in every lib. if this is not intended, maybe do refetch?: boolean instead. :)

if (!refetch) return this.#data.flags[key];
const flagTime = this.#flagTimeoutMap.get(key);
if (!flagTime) return this.#data.flags[key];

const now = new Date();
if (flagTime.getTime() <= now.getTime()) {
this.refetchFlags();
}
return this.#data.flags[key];
}

/**
* helper function to make testing easier
*/
refetchFlags() {
this.loadProjectData();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this.loadProjectData();
return this.loadProjectData();

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadProjectData() has return type void.

}

/**
* Function to get the value of a feature flag. This includes
* the overrides from the dev tools and the local overrides if in development mode
Expand All @@ -213,8 +272,6 @@ export class Abby<
): FlagValueStringToType<CurrentFlag> {
this.log(`getFeatureFlag()`, key);

const storedValue = this.#data.flags[key as unknown as FlagName];

const localOverride = this.flagOverrides?.get(key as unknown as FlagName);

if (localOverride != null) {
Expand All @@ -234,6 +291,10 @@ export class Abby<
return devOverride;
}
}
const storedValue = this.getValidFlag(
key as unknown as FlagName,
this.config.flagCacheConfig?.refetchFlags
);

const flagType = this._cfg.flags?.[key];
const defaultValue = this._cfg.settings?.flags?.defaultValues?.[flagType!];
Expand Down
152 changes: 152 additions & 0 deletions packages/core/tests/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,158 @@ describe("Abby", () => {

expect(abby.getFeatureFlag("flag1")).toBe(false);
});

it("refetches an expired flag", async () => {
const date = new Date(); //current date
vi.setSystemTime(date);
const abby = new Abby({
projectId: "expired",
flags: {
flag1: "Boolean",
flag2: "String",
},
flagCacheConfig: {
refetchFlags: true,
timeToLiveInMinutes: 2,
},
});
await abby.loadProjectData();
const expiredDate = new Date(new Date().getTime() + 1000 * 60 * 10); //date in 100 minutes
vi.setSystemTime(expiredDate);
const spy = vi.spyOn(abby, "refetchFlags");

expect(abby.getFeatureFlag("flag1")).toBeTruthy();
expect(abby.getFeatureFlag("flag2")).toBe("test");
expect(spy).toBeCalled();
});

it("non expired flag does not get refetched", async () => {
const date = new Date(); //current date
vi.setSystemTime(date);
const abby = new Abby({
projectId: "expired",
flags: {
flag1: "Boolean",
flag2: "Boolean",
},
flagCacheConfig: {
refetchFlags: true,
timeToLiveInMinutes: 2,
},
});

await abby.loadProjectData();

const spy = vi.spyOn(abby, "refetchFlags");

expect(abby.getFeatureFlag("flag1")).toBeTruthy();
expect(abby.getFeatureFlag("flag2")).toBe("test");
expect(spy).not.toBeCalled();
});

it("respects the featureFlagCacheConfig refetchFlags value set to false", async () => {
const date = new Date(); //current date
vi.setSystemTime(date);
const abby = new Abby({
projectId: "expired",
flags: {
flag1: "Boolean",
flag2: "Boolean",
},
flagCacheConfig: {
refetchFlags: false,
timeToLiveInMinutes: 2,
},
});

await abby.loadProjectData();

const spy = vi.spyOn(abby, "refetchFlags");

expect(abby.getFeatureFlag("flag1")).toBeTruthy();
expect(abby.getFeatureFlag("flag2")).toBe("test");
expect(spy).not.toBeCalled();
});

it("it refetches expired flags", async () => {
const date = new Date(); //current date
vi.setSystemTime(date);
const abby = new Abby({
projectId: "expired",
flags: {
flag1: "Boolean",
flag2: "Boolean",
},
flagCacheConfig: {
refetchFlags: true,
timeToLiveInMinutes: 2,
},
});

await abby.loadProjectData();

const spy = vi.spyOn(abby, "refetchFlags");

//set date to 5 Minutes in the future
const dateIn5Minutes = new Date(new Date().getTime() + 1000 * 60 * 5);
vi.setSystemTime(dateIn5Minutes);

expect(abby.getFeatureFlag("flag1")).toBeTruthy();
expect(abby.getFeatureFlag("flag2")).toBe("test");
expect(spy).toBeCalled();
});

it("respects the featureFlagCacheCOnfig expiration time", async () => {
const date = new Date(); //current date
vi.setSystemTime(date);
const abby = new Abby({
projectId: "expired",
flags: {
flag1: "Boolean",
flag2: "Boolean",
},
flagCacheConfig: {
refetchFlags: true,
timeToLiveInMinutes: 2,
},
});

await abby.loadProjectData();

const spy = vi.spyOn(abby, "refetchFlags");

expect(abby.getFeatureFlag("flag1")).toBeTruthy();
expect(abby.getFeatureFlag("flag2")).toBe("test");
expect(spy).not.toBeCalled();

//set date to 5 Minutes in the future
const dateIn3Minutes = new Date(new Date().getTime() + 1000 * 60 * 5);
vi.setSystemTime(dateIn3Minutes);
expect(abby.getFeatureFlag("flag1")).toBeTruthy();
expect(abby.getFeatureFlag("flag2")).toBe("test");
expect(spy).toBeCalled();
});
});

it("respects the default behaviour", async () => {
const date = new Date(); //current date
vi.setSystemTime(date);
const abby = new Abby({
projectId: "expired",
flags: { flag1: "Boolean", flag2: "Boolean" },
});

await abby.loadProjectData();

const spy = vi.spyOn(abby, "refetchFlags");

//set date to 5 Minutes in the future
const dateIn3Minutes = new Date(new Date().getTime() + 1000 * 60 * 5);
vi.setSystemTime(dateIn3Minutes);

expect(abby.getFeatureFlag("flag1")).toBeTruthy();
expect(abby.getFeatureFlag("flag2")).toBe("test");
expect(spy).not.toBeCalled();
});

describe("Math helpers", () => {
Expand Down
Loading