Skip to content

Commit

Permalink
feat: to add cache-mode to adapter (#1888)
Browse files Browse the repository at this point in the history
* feat: to add cache-mode to adapter

* feat: add new cache module abstracting over specific caches

* refactor: to make cache require adapter identifier

* refactor: to write into cache module

* refactor: shamefully cast the type

* refactor: shamefully cast the type

* refactor: one cache module to rule them all

* feat: add cache mode to http and graphql adapter

* test: adjust test case for cache mode

* refactor: gimme some constants

* refactor: to not flush flags when reconfiguring

* refactor: to always flush on reconfigure

* fix: to not flush initial remote fetching

* refactor: to use sync import to get flags

* test: add initial set of tests

* test: add test case for cached flag restoring

* Create chilled-phones-greet.md
  • Loading branch information
tdeekens committed Apr 11, 2024
1 parent df89f1e commit 72f308b
Show file tree
Hide file tree
Showing 27 changed files with 609 additions and 245 deletions.
19 changes: 19 additions & 0 deletions .changeset/chilled-phones-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@flopflip/cache": major
"@flopflip/graphql-adapter": minor
"@flopflip/http-adapter": minor
"@flopflip/launchdarkly-adapter": major
"@flopflip/react": minor
"@flopflip/types": minor
---

The release adds a new `cacheMode` property on the `adapterArgs` of an adapter.

Using the `cacheMode` you can opt into an `eager` or `lazy`. The `cacheMode` allows you to define when remote flags should take affect in an application. Before `flopflip` behaved always `eager`. This remains the case when passing `eager` or `null` as the `cacheMode`. In `lazy` mode `flopflip` will not directly flush remote values and only silently put them in the cache. They would then take effect on the next render or `reconfigure`.

In short, the `cacheMode` can be `eager` to indicate that remote values should have effect immediately. The value can also be `lazy` to indicate that values should be updated in the cache but only be applied once the adapter is configured again

With the `cacheMode` we removed some likely unused functionality which explored similar ideas before:

1. `unsubscribeFromCachedFlags`: This is now always the case. You can use the `lazy` `cacheMode` to indicate that you don't want flags to take immediate effect
2. `subscribeToFlagChanges`: This is now always true. You can't opt-out of the flag subscription any longer
Empty file added packages/cache/CHANGELOG.md
Empty file.
36 changes: 36 additions & 0 deletions packages/cache/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@flopflip/cache",
"version": "13.5.2",
"description": "Caching for flipflop adapters",
"main": "dist/flopflip-cache.cjs.js",
"module": "dist/flopflip-cache.esm.js",
"files": [
"readme.md",
"dist/**"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/tdeekens/flopflip.git",
"directory": "packages/cache"
},
"author": "Tobias Deekens <[email protected]>",
"license": "MIT",
"bugs": {
"url": "https://github.com/tdeekens/flopflip/issues"
},
"homepage": "https://github.com/tdeekens/flopflip#readme",
"keywords": [
"feature-flags",
"feature-toggles",
"cache",
"client"
],
"dependencies": {
"@flopflip/localstorage-cache": "13.6.0",
"@flopflip/sessionstorage-cache": "13.6.0",
"@flopflip/types": "13.6.0"
}
}
116 changes: 116 additions & 0 deletions packages/cache/src/cache/cache.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { adapterIdentifiers, cacheIdentifiers } from '@flopflip/types';

import {
getAllCachedFlags,
getCache,
getCachedFlags,
getCachePrefix,
} from './cache';

const cacheKey = 'test';

describe('general caching', () => {
it('should allow writing values to the cache', async () => {
const flags = {
flag1: true,
};
const cache = await getCache(
cacheIdentifiers.session,
adapterIdentifiers.memory,
cacheKey
);

cache.set(flags);

expect(cache.get()).toStrictEqual(flags);
});

it('should allow unsetting values from the cache', async () => {
const flags = {
flag1: true,
};
const cache = await getCache(
cacheIdentifiers.session,
adapterIdentifiers.memory,
cacheKey
);

cache.set(flags);
cache.unset();

expect(cache.get()).toBeNull();
});

it('should update a referencing cache to the values cache', async () => {
const flags = {
flag1: true,
};
const cache = await getCache(
cacheIdentifiers.session,
adapterIdentifiers.memory,
cacheKey
);

cache.set(flags);

expect(sessionStorage.getItem).toHaveBeenLastCalledWith(
`${getCachePrefix(adapterIdentifiers.memory)}/${cacheKey}/flags`
);
});
});

describe('flag caching', () => {
describe('with a single adapter', () => {
it('should allow writing and getting cached flags', async () => {
const flags = {
flag1: true,
};
const cache = await getCache(
cacheIdentifiers.session,
adapterIdentifiers.memory,
cacheKey
);

cache.set(flags);

expect(
getCachedFlags(cacheIdentifiers.session, adapterIdentifiers.memory)
).toStrictEqual(flags);

expect(sessionStorage.getItem).toHaveBeenLastCalledWith(
`${getCachePrefix(adapterIdentifiers.memory)}/${cacheKey}/flags`
);
});
});
describe('with a multiple adapters', () => {
it('should allow writing and getting cached flags for all adapters', async () => {
const memoryAdapterFlags = {
flag1: true,
};
const localstorageAdapterFlags = {
flag2: true,
};

const memoryAdapterCache = await getCache(
cacheIdentifiers.session,
adapterIdentifiers.memory,
cacheKey
);
const localstorageAdapterCache = await getCache(
cacheIdentifiers.session,
adapterIdentifiers.localstorage,
cacheKey
);

localstorageAdapterCache.set(localstorageAdapterFlags);

const fakeAdapter = {
effectIds: [adapterIdentifiers.memory, adapterIdentifiers.localstorage],
};

expect(
getAllCachedFlags(fakeAdapter, cacheIdentifiers.session)
).toStrictEqual({ ...memoryAdapterFlags, ...localstorageAdapterFlags });
});
});
});
123 changes: 123 additions & 0 deletions packages/cache/src/cache/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
cacheIdentifiers,
type TAdapter,
type TAdapterIdentifiers,
type TCacheIdentifiers,
type TFlags,
} from '@flopflip/types';

const FLAGS_CACHE_KEY = 'flags';
const FLAGS_REFERENCE_CACHE_KEY = 'flags-reference';

function getCachePrefix(adapterIdentifiers: TAdapterIdentifiers) {
return `@flopflip/${adapterIdentifiers}-adapter`;
}

async function importCache(cacheIdentifier: TCacheIdentifiers) {
let cacheModule;

switch (cacheIdentifier) {
case cacheIdentifiers.local: {
cacheModule = await import('@flopflip/localstorage-cache');
break;
}

case cacheIdentifiers.session: {
cacheModule = await import('@flopflip/sessionstorage-cache');
break;
}
}

return cacheModule;
}

async function getCache(
cacheIdentifier: TCacheIdentifiers,
adapterIdentifiers: TAdapterIdentifiers,
cacheKey?: string
) {
const cacheModule = await importCache(cacheIdentifier);

const CACHE_PREFIX = getCachePrefix(adapterIdentifiers);
const createCache = cacheModule.default;
const flagsCachePrefix = [CACHE_PREFIX, cacheKey].filter(Boolean).join('/');

const flagsCache = createCache({ prefix: flagsCachePrefix });
const referenceCache = createCache({ prefix: CACHE_PREFIX });

return {
set(flags: TFlags) {
const haveFlagsBeenWritten = flagsCache.set(FLAGS_CACHE_KEY, flags);

if (haveFlagsBeenWritten) {
referenceCache.set(
FLAGS_REFERENCE_CACHE_KEY,
[flagsCachePrefix, FLAGS_CACHE_KEY].join('/')
);
}

return haveFlagsBeenWritten;
},
get() {
return flagsCache.get(FLAGS_CACHE_KEY);
},
unset() {
referenceCache.unset(FLAGS_REFERENCE_CACHE_KEY);

return flagsCache.unset(FLAGS_CACHE_KEY);
},
};
}

function getCachedFlags(
cacheIdentifier: TCacheIdentifiers,
adapterIdentifiers: TAdapterIdentifiers
): TFlags {
const CACHE_PREFIX = getCachePrefix(adapterIdentifiers);

const cacheModule =
cacheIdentifier === cacheIdentifiers.local ? localStorage : sessionStorage;
const flagReferenceKey = [CACHE_PREFIX, FLAGS_REFERENCE_CACHE_KEY].join('/');

const referenceToCachedFlags = cacheModule.getItem(flagReferenceKey);

if (referenceToCachedFlags) {
try {
const cacheKey: string = JSON.parse(referenceToCachedFlags);
const cachedFlags = cacheModule.getItem(cacheKey);

if (cacheKey && cachedFlags) {
return JSON.parse(cachedFlags);
}
} catch (error) {
console.warn(
`@flopflip/cache: Failed to parse cached flags from ${cacheIdentifier}.`
);
}
}

return {};
}

function getAllCachedFlags(
adapter: TAdapter,
cacheIdentifier?: TCacheIdentifiers
) {
if (!cacheIdentifier) {
return {};
}

if (adapter.effectIds) {
return adapter.effectIds.reduce(
(defaultFlags, effectId) => ({
...defaultFlags,
...getCachedFlags(cacheIdentifier, effectId),
}),
{}
);
}

return getCachedFlags(cacheIdentifier, adapter.id);
}

export { getAllCachedFlags, getCache, getCachedFlags, getCachePrefix };
4 changes: 4 additions & 0 deletions packages/cache/src/cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const version = '__@FLOPFLIP/VERSION_OF_RELEASE__';

export { version };
export { getAllCachedFlags, getCache, getCachedFlags } from './cache';
4 changes: 4 additions & 0 deletions packages/cache/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const version = '__@FLOPFLIP/VERSION_OF_RELEASE__';

export { getAllCachedFlags, getCache, getCachedFlags } from './cache';
export { version };
3 changes: 3 additions & 0 deletions packages/cache/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json",
}
3 changes: 3 additions & 0 deletions packages/cache/tsconfig.lint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.lint.json",
}
1 change: 1 addition & 0 deletions packages/graphql-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"dependencies": {
"@babel/runtime": "7.24.4",
"@flopflip/adapter-utilities": "13.6.0",
"@flopflip/cache": "13.5.2",
"@flopflip/localstorage-cache": "13.6.0",
"@flopflip/sessionstorage-cache": "13.6.0",
"@flopflip/types": "13.6.0",
Expand Down
42 changes: 40 additions & 2 deletions packages/graphql-adapter/src/adapter/adapter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,36 @@ describe('when configured', () => {
},
});
});

describe('with lazy cache mode', () => {
beforeEach(async () => {
sessionStorage.getItem.mockReturnValueOnce(
JSON.stringify({ cached: true })
);
adapterEventHandlers = createAdapterEventHandlers();
jest.useFakeTimers();
configurationResult = await adapter.configure(
adapterArgs,
adapterEventHandlers
);
});

it('should only flush cached but not updated flags', () => {
expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalledWith({
id: adapter.id,
flags: expect.objectContaining({
cached: true,
}),
});

expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalledWith({
id: adapter.id,
flags: expect.not.objectContaining({
updated: true,
}),
});
});
});
});

describe('when updating flags', () => {
Expand Down Expand Up @@ -275,6 +305,7 @@ describe('when configured', () => {

beforeEach(async () => {
configurationResult = await adapter.reconfigure({
...adapterArgs,
user,
cacheIdentifier: 'session',
});
Expand All @@ -296,10 +327,17 @@ describe('when configured', () => {
expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalled();
});

it('should invoke `onFlagsStateChange` with empty flags', () => {
it('should invoke `onFlagsStateChange` with all flags', () => {
expect(adapterEventHandlers.onFlagsStateChange).toHaveBeenCalledWith({
id: adapter.id,
flags: {},
flags: {
barFlag: false,
disabled: false,
enabled: true,
flagA1: false,
flagB: false,
fooFlag: true,
},
});
});

Expand Down
Loading

0 comments on commit 72f308b

Please sign in to comment.