diff --git a/packages/sdk/vercel/__tests__/api/EdgeFeatureStore.test.ts b/packages/sdk/vercel/__tests__/api/EdgeFeatureStore.test.ts new file mode 100644 index 0000000000..cc90f20529 --- /dev/null +++ b/packages/sdk/vercel/__tests__/api/EdgeFeatureStore.test.ts @@ -0,0 +1,182 @@ +import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common-edge'; +import * as edgeExports from '@launchdarkly/js-server-sdk-common-edge'; + +import { EdgeFeatureStore } from '../../src/api/EdgeFeatureStore'; +import mockEdgeProvider from '../utils/mockEdgeProvider'; +import * as testData from './testData.json'; + +describe('EdgeFeatureStore', () => { + const sdkKey = 'sdkKey'; + const kvKey = `LD-Env-${sdkKey}`; + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const mockGet = mockEdgeProvider.get as jest.Mock; + let featureStore: LDFeatureStore; + let asyncFeatureStore: AsyncStoreFacade; + + beforeEach(() => { + featureStore = new EdgeFeatureStore(mockEdgeProvider, sdkKey, 'MockEdgeProvider', mockLogger); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + mockGet.mockImplementation(() => Promise.resolve(testData)); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('get', () => { + test('get flag', async () => { + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(flag).toMatchObject(testData.flags.testFlag1); + }); + + test('invalid flag key', async () => { + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'invalid'); + + expect(flag).toBeUndefined(); + }); + + test('get segment', async () => { + const segment = await asyncFeatureStore.get({ namespace: 'segments' }, 'testSegment1'); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(segment).toMatchObject(testData.segments.testSegment1); + }); + + test('invalid segment key', async () => { + const segment = await asyncFeatureStore.get({ namespace: 'segments' }, 'invalid'); + + expect(segment).toBeUndefined(); + }); + + test('invalid kv key', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + + expect(flag).toBeNull(); + }); + + test('get multiple flags with same payload', async () => { + const reviveSpy = jest.spyOn(edgeExports, 'reviveFullPayload'); + + const flag1 = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + const flag2 = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag2'); + const flag3 = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag3'); + + expect(mockGet).toHaveBeenCalledTimes(3); + expect(reviveSpy).toHaveBeenCalledTimes(1); + expect(flag1).toMatchObject(testData.flags.testFlag1); + expect(flag2).toMatchObject(testData.flags.testFlag2); + expect(flag3).toMatchObject(testData.flags.testFlag3); + + reviveSpy.mockRestore(); + }); + + test('get multiple flags with changing payload', async () => { + const changedFlag2 = { + ...testData.flags.testFlag2, + version: testData.flags.testFlag2.version + 1, + }; + const changedFlag3 = { + ...testData.flags.testFlag3, + version: testData.flags.testFlag3.version + 1, + }; + + mockGet.mockImplementationOnce(() => Promise.resolve(testData)); + mockGet.mockImplementationOnce(() => + // New payload object reference + Promise.resolve({ + ...testData, + flags: { ...testData.flags, testFlag2: { ...changedFlag2 } }, + }), + ); + mockGet.mockImplementationOnce(() => + // New payload object reference + Promise.resolve({ + ...testData, + flags: { ...testData.flags, testFlag3: { ...changedFlag3 } }, + }), + ); + const reviveSpy = jest.spyOn(edgeExports, 'reviveFullPayload'); + + const flag1 = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + const flag2 = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag2'); + const flag3 = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag3'); + + expect(mockGet).toHaveBeenCalledTimes(3); + expect(reviveSpy).toHaveBeenCalledTimes(3); + expect(flag1).toMatchObject(testData.flags.testFlag1); + expect(flag2).toMatchObject(changedFlag2); + expect(flag3).toMatchObject(changedFlag3); + + reviveSpy.mockRestore(); + }); + }); + + describe('all', () => { + test('all flags', async () => { + const flags = await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(flags).toMatchObject(testData.flags); + }); + + test('all segments', async () => { + const segment = await asyncFeatureStore.all({ namespace: 'segments' }); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(segment).toMatchObject(testData.segments); + }); + + test('invalid DataKind', async () => { + const flag = await asyncFeatureStore.all({ namespace: 'InvalidDataKind' }); + + expect(flag).toEqual({}); + }); + + test('invalid kv key', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const segment = await asyncFeatureStore.all({ namespace: 'segments' }); + + expect(segment).toEqual({}); + }); + }); + + describe('initialized', () => { + test('is initialized', async () => { + const isInitialized = await asyncFeatureStore.initialized(); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(isInitialized).toBeTruthy(); + }); + + test('not initialized', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const isInitialized = await asyncFeatureStore.initialized(); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(isInitialized).toBeFalsy(); + }); + }); + + describe('init & getDescription', () => { + test('init', (done) => { + const cb = jest.fn(() => { + done(); + }); + featureStore.init(testData, cb); + }); + + test('getDescription', async () => { + const description = featureStore.getDescription?.(); + + expect(description).toEqual('MockEdgeProvider'); + }); + }); +}); diff --git a/packages/sdk/vercel/__tests__/api/testData.json b/packages/sdk/vercel/__tests__/api/testData.json new file mode 100644 index 0000000000..b9e5296c03 --- /dev/null +++ b/packages/sdk/vercel/__tests__/api/testData.json @@ -0,0 +1,171 @@ +{ + "flags": { + "testFlag1": { + "key": "testFlag1", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "contextKind": "user", + "attribute": "/email", + "op": "contains", + "values": ["gmail"], + "negate": false + } + ], + "trackEvents": false, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }] + } + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag2": { + "key": "testFlag2", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [], + "fallthrough": { + "variation": 0, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }], + "contextKind:": "user", + "attribute": "/email" + } + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag3": { + "key": "testFlag3", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "op": "segmentMatch", + "values": ["testSegment1"], + "negate": false + } + ], + "trackEvents": false + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + } + }, + "segments": { + "testSegment1": { + "name": "testSegment1", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment1", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + }, + "testSegment2": { + "name": "testSegment2", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment2", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + } + } +} diff --git a/packages/sdk/vercel/__tests__/utils/mockEdgeProvider.ts b/packages/sdk/vercel/__tests__/utils/mockEdgeProvider.ts new file mode 100644 index 0000000000..fc237e93fb --- /dev/null +++ b/packages/sdk/vercel/__tests__/utils/mockEdgeProvider.ts @@ -0,0 +1,7 @@ +import { EdgeProvider } from '../../src/api'; + +const mockEdgeProvider: EdgeProvider = { + get: jest.fn(), +}; + +export default mockEdgeProvider; diff --git a/packages/sdk/vercel/src/api/EdgeFeatureStore.ts b/packages/sdk/vercel/src/api/EdgeFeatureStore.ts new file mode 100644 index 0000000000..ce6475a5dd --- /dev/null +++ b/packages/sdk/vercel/src/api/EdgeFeatureStore.ts @@ -0,0 +1,135 @@ +import type { + DataKind, + LDFeatureStore, + LDFeatureStoreDataStorage, + LDFeatureStoreItem, + LDFeatureStoreKindData, + LDLogger, +} from '@launchdarkly/js-server-sdk-common-edge'; +import { noop, reviveFullPayload } from '@launchdarkly/js-server-sdk-common-edge'; + +export interface EdgeProvider { + get: (rootKey: string) => Promise | null | undefined>; +} + +export class EdgeFeatureStore implements LDFeatureStore { + private readonly _rootKey: string; + private _lastRevivedPayload: ReturnType | undefined; + + constructor( + private readonly _edgeProvider: EdgeProvider, + sdkKey: string, + private readonly _description: string, + private _logger: LDLogger, + ) { + this._rootKey = `LD-Env-${sdkKey}`; + } + + async get( + kind: DataKind, + dataKey: string, + callback: (res: LDFeatureStoreItem | null) => void, + ): Promise { + const { namespace } = kind; + const kindKey = namespace === 'features' ? 'flags' : namespace; + this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); + + try { + const storePayload = await this._getStorePayload(); + + switch (namespace) { + case 'features': + callback(storePayload.flags[dataKey]); + break; + case 'segments': + callback(storePayload.segments[dataKey]); + break; + default: + callback(null); + } + } catch (err) { + this._logger.error(err); + callback(null); + } + } + + async all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void = noop): Promise { + const { namespace } = kind; + const kindKey = namespace === 'features' ? 'flags' : namespace; + this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); + try { + const storePayload = await this._getStorePayload(); + + switch (namespace) { + case 'features': + callback(storePayload.flags); + break; + case 'segments': + callback(storePayload.segments); + break; + default: + callback({}); + } + } catch (err) { + this._logger.error(err); + callback({}); + } + } + + /** + * This method is used to retrieve the environment payload from the edge + * provider. + */ + private async _getStorePayload(): Promise< + Exclude, undefined> + > { + // Vercel Edge Config will return the same object reference if + // the payload has not changed. + const providerData = await this._edgeProvider.get(this._rootKey); + + if (!providerData) { + throw new Error(`${this._rootKey} is not found in KV.`); + } + + // Revived payloads are mutated in-place, so if the last revived + // payload object reference is the same, we can just return it + // and avoid processing it again. + if (providerData === this._lastRevivedPayload) { + return this._lastRevivedPayload; + } + + const payload = reviveFullPayload(providerData); + + if (!payload) { + throw new Error(`Error deserializing ${this._rootKey}`); + } + + this._lastRevivedPayload = payload; + + return payload; + } + + async initialized(callback: (isInitialized: boolean) => void = noop): Promise { + const config = await this._edgeProvider.get(this._rootKey); + const result = config !== null; + this._logger.debug(`Is ${this._rootKey} initialized? ${result}`); + callback(result); + } + + init(allData: LDFeatureStoreDataStorage, callback: () => void): void { + callback(); + } + + getDescription(): string { + return this._description; + } + + close(): void { + this._lastRevivedPayload = undefined; + } + + // unused + delete = noop; + + upsert = noop; +} diff --git a/packages/sdk/vercel/src/api/index.ts b/packages/sdk/vercel/src/api/index.ts new file mode 100644 index 0000000000..0e331d06b3 --- /dev/null +++ b/packages/sdk/vercel/src/api/index.ts @@ -0,0 +1 @@ +export * from './EdgeFeatureStore'; diff --git a/packages/sdk/vercel/src/index.ts b/packages/sdk/vercel/src/index.ts index 0d3d35e0b9..80e8198f00 100644 --- a/packages/sdk/vercel/src/index.ts +++ b/packages/sdk/vercel/src/index.ts @@ -12,18 +12,19 @@ import type { EdgeConfigClient } from '@vercel/edge-config'; import { BasicLogger, - EdgeFeatureStore, - EdgeProvider, init as initEdge, LDClient, LDOptions, } from '@launchdarkly/js-server-sdk-common-edge'; +import type { EdgeProvider } from './api'; +import { EdgeFeatureStore } from './api'; import createPlatformInfo from './createPlatformInfo'; export * from '@launchdarkly/js-server-sdk-common-edge'; -export type { LDClient }; +export { EdgeFeatureStore }; +export type { EdgeProvider, LDClient }; /** * Creates an instance of the Vercel LaunchDarkly client.