diff --git a/packages/shared/common/__tests__/Context.test.ts b/packages/shared/common/__tests__/Context.test.ts index bca00e69bc..2bb450e47b 100644 --- a/packages/shared/common/__tests__/Context.test.ts +++ b/packages/shared/common/__tests__/Context.test.ts @@ -177,3 +177,151 @@ describe('given a multi-kind context', () => { expect(context?.kindsAndKeys).toEqual({ org: 'OrgKey', user: 'User%:/Key' }); }); }); + +describe('given a user context with private attributes', () => { + const input = Context.fromLDContext({ + key: 'testKey', + name: 'testName', + custom: { cat: 'calico', dog: 'lab' }, + anonymous: true, + privateAttributeNames: ['/a/b/c', 'cat', 'custom/dog'], + }); + + const expected = { + key: 'testKey', + kind: 'user', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + _meta: { + privateAttributes: ['/a/b/c', 'cat', 'custom/dog'], + }, + }; + + it('it can convert from LDContext to Context and back to LDContext', () => { + expect(Context.toLDContext(input)).toEqual(expected); + }); +}); + +describe('given a user context without private attributes', () => { + const input = Context.fromLDContext({ + key: 'testKey', + name: 'testName', + custom: { cat: 'calico', dog: 'lab' }, + anonymous: true, + }); + + const expected = { + key: 'testKey', + kind: 'user', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + }; + + it('it can convert from LDContext to Context and back to LDContext', () => { + expect(Context.toLDContext(input)).toEqual(expected); + }); +}); + +describe('given a single context with private attributes', () => { + const input = Context.fromLDContext({ + kind: 'org', + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + _meta: { + privateAttributes: ['/a/b/c', 'cat', 'dog'], + }, + }); + + const expected = { + kind: 'org', + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + _meta: { + privateAttributes: ['/a/b/c', 'cat', 'dog'], + }, + }; + + it('it can convert from LDContext to Context and back to LDContext', () => { + expect(Context.toLDContext(input)).toEqual(expected); + }); +}); + +describe('given a single context without meta', () => { + const input = Context.fromLDContext({ + kind: 'org', + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + }); + + const expected = { + kind: 'org', + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + }; + + it('it can convert from LDContext to Context and back to LDContext', () => { + expect(Context.toLDContext(input)).toEqual(expected); + }); +}); + +describe('given a multi context', () => { + const input = Context.fromLDContext({ + kind: 'multi', + org: { + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + _meta: { + privateAttributes: ['/a/b/c', 'cat', 'custom/dog'], + }, + }, + customer: { + key: 'testKey', + name: 'testName', + bird: 'party parrot', + chicken: 'hen', + }, + }); + + const expected = { + kind: 'multi', + org: { + key: 'testKey', + name: 'testName', + cat: 'calico', + dog: 'lab', + anonymous: true, + _meta: { + privateAttributes: ['/a/b/c', 'cat', 'custom/dog'], + }, + }, + customer: { + key: 'testKey', + name: 'testName', + bird: 'party parrot', + chicken: 'hen', + }, + }; + + it('it can convert from LDContext to Context and back to LDContext', () => { + expect(Context.toLDContext(input)).toEqual(expected); + }); +}); diff --git a/packages/shared/common/src/Context.ts b/packages/shared/common/src/Context.ts index 60560a32e1..6bf677ac4a 100644 --- a/packages/shared/common/src/Context.ts +++ b/packages/shared/common/src/Context.ts @@ -140,6 +140,11 @@ function legacyToSingleKind(user: LDUser): LDSingleKindContext { if (user.country !== null && user.country !== undefined) { singleKindContext.country = user.country; } + if (user.privateAttributeNames !== null && user.privateAttributeNames !== undefined) { + singleKindContext._meta = { + privateAttributes: user.privateAttributeNames, + }; + } // We are not pulling private attributes over because we will serialize // those from attribute references for events. @@ -338,6 +343,31 @@ export default class Context { return Context.contextForError('unknown', 'Context was not of a valid kind'); } + /** + * Creates a {@link LDContext} from a {@link Context}. + * @param context to be converted + * @returns an {@link LDContext} if input was valid, otherwise undefined + */ + public static toLDContext(context: Context): LDContext | undefined { + if (!context.valid) { + return undefined; + } + + const contexts = context.getContexts(); + if (!context.isMulti) { + return contexts[0][1]; + } + const result: LDMultiKindContext = { + kind: 'multi', + }; + contexts.forEach((kindAndContext) => { + const kind = kindAndContext[0]; + const nestedContext = kindAndContext[1]; + result[kind] = nestedContext; + }); + return result; + } + /** * Attempt to get a value for the given context kind using the given reference. * @param reference The reference to the value to get. diff --git a/packages/shared/common/src/api/platform/Storage.ts b/packages/shared/common/src/api/platform/Storage.ts index ec43792304..2275959fa1 100644 --- a/packages/shared/common/src/api/platform/Storage.ts +++ b/packages/shared/common/src/api/platform/Storage.ts @@ -1,3 +1,25 @@ +/** + * Interface for a data store that holds feature flag data and other SDK + * properties in a serialized form. + * + * This interface should be used for platform-specific integrations that store + * data somewhere other than in memory. Each data item is uniquely identified by + * a string typically constructed following a namespacing structure that + * is then encoded. + * + * Implementations may not throw exceptions. + * + * The SDK assumes that the persistence is only being used by a single instance + * of the SDK per SDK key (two different SDK instances, with 2 different SDK + * keys could use the same persistence instance). + * + * The SDK, with correct usage, will not have overlapping writes to the same + * key. + * + * This interface does not depend on the ability to list the contents of the + * store or namespaces. This is to maintain the simplicity of implementing a + * key-value store on many platforms. + */ export interface Storage { get: (key: string) => Promise; set: (key: string, value: string) => Promise; diff --git a/packages/shared/mocks/src/crypto.ts b/packages/shared/mocks/src/crypto.ts index 22e800adcd..411563423f 100644 --- a/packages/shared/mocks/src/crypto.ts +++ b/packages/shared/mocks/src/crypto.ts @@ -6,7 +6,7 @@ export let hasher: Hasher; export const setupCrypto = () => { let counter = 0; hasher = { - update: jest.fn(), + update: jest.fn(() => hasher), digest: jest.fn(() => '1234567890123456'), }; diff --git a/packages/shared/sdk-client/src/LDClientImpl.events.test.ts b/packages/shared/sdk-client/src/LDClientImpl.events.test.ts index 5d2ba57cef..e106f2c734 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.events.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.events.test.ts @@ -9,7 +9,6 @@ import { import { InputCustomEvent, InputIdentifyEvent } from '@launchdarkly/js-sdk-common/dist/internal'; import { basicPlatform, - hasher, logger, MockEventProcessor, setupMockStreamingProcessor, @@ -59,7 +58,6 @@ describe('sdk-client object', () => { ); setupMockStreamingProcessor(false, defaultPutResponse); basicPlatform.crypto.randomUUID.mockReturnValue('random1'); - hasher.digest.mockReturnValue('digested1'); ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, basicPlatform, { logger, diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index 887896029c..990c4c5669 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -2,10 +2,10 @@ import { AutoEnvAttributes, clone, type LDContext, noop } from '@launchdarkly/js import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; import LDEmitter from './api/LDEmitter'; +import { toMulti } from './context/addAutoEnv'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; -import { DeleteFlag, Flag, Flags, PatchFlag } from './types'; -import { toMulti } from './utils/addAutoEnv'; +import { DeleteFlag, Flags, PatchFlag } from './types'; jest.mock('@launchdarkly/js-sdk-common', () => { const actual = jest.requireActual('@launchdarkly/js-sdk-common'); @@ -23,6 +23,8 @@ jest.mock('@launchdarkly/js-sdk-common', () => { const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; +const flagStorageKey = 'LaunchDarkly_1234567890123456_1234567890123456'; +const indexStorageKey = 'LaunchDarkly_1234567890123456_ContextIndex'; let ldc: LDClientImpl; let emitter: LDEmitter; let defaultPutResponse: Flags; @@ -72,7 +74,17 @@ describe('sdk-client storage', () => { defaultPutResponse = clone(mockResponseJson); defaultFlagKeys = Object.keys(defaultPutResponse); - basicPlatform.storage.get.mockImplementation(() => JSON.stringify(defaultPutResponse)); + (basicPlatform.storage.get as jest.Mock).mockImplementation((storageKey: string) => { + switch (storageKey) { + case flagStorageKey: + return JSON.stringify(defaultPutResponse); + case indexStorageKey: + return undefined; + default: + return undefined; + } + }); + jest .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') .mockReturnValue('/stream/path'); @@ -95,7 +107,7 @@ describe('sdk-client storage', () => { // make sure streaming errors const allFlags = await identifyGetAllFlags(true, defaultPutResponse); - expect(basicPlatform.storage.get).toHaveBeenCalledWith('org:Testy Pizza'); + expect(basicPlatform.storage.get).toHaveBeenCalledWith(flagStorageKey); // 'change' should not have been emitted expect(emitter.emit).toHaveBeenCalledTimes(2); @@ -130,7 +142,7 @@ describe('sdk-client storage', () => { const allFlags = await identifyGetAllFlags(true, defaultPutResponse); expect(basicPlatform.storage.get).toHaveBeenLastCalledWith( - expect.stringMatching(/org:Testy Pizza$/), + expect.stringMatching('LaunchDarkly_1234567890123456_1234567890123456'), ); // 'change' should not have been emitted @@ -159,14 +171,7 @@ describe('sdk-client storage', () => { }); }); - test('not emitting change event', async () => { - jest.doMock('./utils', () => { - const actual = jest.requireActual('./utils'); - return { - ...actual, - calculateFlagChanges: () => [], - }; - }); + test('not emitting change event when changed keys is empty', async () => { let LDClientImplTestNoChange; jest.isolateModules(async () => { LDClientImplTestNoChange = jest.requireActual('./LDClientImpl').default; @@ -180,9 +185,13 @@ describe('sdk-client storage', () => { emitter = ldc.emitter; jest.spyOn(emitter as LDEmitter, 'emit'); + // expect emission + await identifyGetAllFlags(true, defaultPutResponse); + + // expit no emission await identifyGetAllFlags(true, defaultPutResponse); - expect(emitter.emit).not.toHaveBeenCalled(); + expect(emitter.emit).toHaveBeenCalledTimes(1); }); test('no storage, cold start from streaming', async () => { @@ -197,11 +206,14 @@ describe('sdk-client storage', () => { expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( 1, - 'org:Testy Pizza', - JSON.stringify(defaultPutResponse), + indexStorageKey, + expect.stringContaining('index'), ); - expect(ldc.logger.debug).toHaveBeenCalledWith( - 'OnIdentifyResolve no changes to emit from: stream PUT.', + + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 2, + flagStorageKey, + JSON.stringify(defaultPutResponse), ); // this is defaultPutResponse @@ -222,11 +234,22 @@ describe('sdk-client storage', () => { delete putResponse['dev-test-flag']; const allFlags = await identifyGetAllFlags(false, putResponse); + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + expect(allFlags).not.toHaveProperty('dev-test-flag'); - expect(basicPlatform.storage.set).toHaveBeenCalledWith( - 'org:Testy Pizza', + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + indexStorageKey, + expect.stringContaining('index'), + ); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 2, + flagStorageKey, JSON.stringify(putResponse), ); + expect(emitter.emit).toHaveBeenNthCalledWith(1, 'change', context, defaultFlagKeys); expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); }); @@ -242,9 +265,19 @@ describe('sdk-client storage', () => { }; const allFlags = await identifyGetAllFlags(false, putResponse); + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + expect(allFlags).toMatchObject({ 'another-dev-test-flag': false }); - expect(basicPlatform.storage.set).toHaveBeenCalledWith( - 'org:Testy Pizza', + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + indexStorageKey, + expect.stringContaining('index'), + ); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 2, + flagStorageKey, JSON.stringify(putResponse), ); expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['another-dev-test-flag']); @@ -262,7 +295,7 @@ describe('sdk-client storage', () => { test('syncing storage on multiple flag operations', async () => { const putResponse = clone(defaultPutResponse); - const newFlag = clone(putResponse['dev-test-flag']); + const newFlag = clone(putResponse['dev-test-flag']); // flag updated, added and deleted putResponse['dev-test-flag'].value = false; @@ -270,6 +303,9 @@ describe('sdk-client storage', () => { delete putResponse['moonshot-demo']; const allFlags = await identifyGetAllFlags(false, putResponse); + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + expect(allFlags).toMatchObject({ 'dev-test-flag': false, 'another-dev-test-flag': true }); expect(allFlags).not.toHaveProperty('moonshot-demo'); expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, [ @@ -288,11 +324,22 @@ describe('sdk-client storage', () => { false, ); + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(2); expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( 1, - 'org:Testy Pizza', + indexStorageKey, + expect.stringContaining('index'), + ); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 2, + flagStorageKey, JSON.stringify(defaultPutResponse), ); + + // we expect one change from the local storage init, but no further change from the PUT expect(emitter.emit).toHaveBeenCalledTimes(1); expect(emitter.emit).toHaveBeenNthCalledWith(1, 'change', context, defaultFlagKeys); @@ -314,7 +361,10 @@ describe('sdk-client storage', () => { putResponse['dev-test-flag'].reason = { kind: 'RULE_MATCH', inExperiment: true }; const allFlags = await identifyGetAllFlags(false, putResponse); - const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; + + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; expect(allFlags).toMatchObject({ 'dev-test-flag': true }); expect(flagsInStorage['dev-test-flag'].reason).toEqual({ @@ -334,13 +384,13 @@ describe('sdk-client storage', () => { patchResponse.version += 1; const allFlags = await identifyGetAllFlags(false, defaultPutResponse, patchResponse); - const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; expect(allFlags).toMatchObject({ 'dev-test-flag': false }); - expect(basicPlatform.storage.set).toHaveBeenCalledWith( - 'org:Testy Pizza', - expect.stringContaining(JSON.stringify(patchResponse)), - ); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(4); expect(flagsInStorage['dev-test-flag'].version).toEqual(patchResponse.version); expect(emitter.emit).toHaveBeenCalledTimes(2); expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); @@ -351,11 +401,15 @@ describe('sdk-client storage', () => { patchResponse.key = 'another-dev-test-flag'; const allFlags = await identifyGetAllFlags(false, defaultPutResponse, patchResponse); - const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; expect(allFlags).toHaveProperty('another-dev-test-flag'); - expect(basicPlatform.storage.set).toHaveBeenCalledWith( - 'org:Testy Pizza', + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 4, + flagStorageKey, expect.stringContaining(JSON.stringify(patchResponse)), ); expect(flagsInStorage).toHaveProperty('another-dev-test-flag'); @@ -377,7 +431,7 @@ describe('sdk-client storage', () => { false, ); - expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(0); expect(emitter.emit).not.toHaveBeenCalledWith('change'); // this is defaultPutResponse @@ -405,11 +459,15 @@ describe('sdk-client storage', () => { undefined, deleteResponse, ); - const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; expect(allFlags).not.toHaveProperty('dev-test-flag'); - expect(basicPlatform.storage.set).toHaveBeenCalledWith( - 'org:Testy Pizza', + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 4, + flagStorageKey, expect.stringContaining('dev-test-flag'), ); expect(flagsInStorage['dev-test-flag']).toMatchObject({ ...deleteResponse, deleted: true }); @@ -432,7 +490,7 @@ describe('sdk-client storage', () => { ); expect(allFlags).toHaveProperty('dev-test-flag'); - expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(0); expect(emitter.emit).not.toHaveBeenCalledWith('change'); }); @@ -451,7 +509,7 @@ describe('sdk-client storage', () => { ); expect(allFlags).toHaveProperty('dev-test-flag'); - expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(0); expect(emitter.emit).not.toHaveBeenCalledWith('change'); }); @@ -462,9 +520,13 @@ describe('sdk-client storage', () => { }; await identifyGetAllFlags(false, defaultPutResponse, undefined, deleteResponse, false); + + // wait for async code to resolve promises + await jest.runAllTimersAsync(); + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.lastCall[1]) as Flags; - expect(basicPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(4); // two index saves and two flag saves expect(flagsInStorage['does-not-exist']).toMatchObject({ ...deleteResponse, deleted: true }); expect(emitter.emit).toHaveBeenCalledWith('change', context, ['does-not-exist']); }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index 3a2a0df944..7e21e0cd22 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -178,7 +178,6 @@ describe('sdk-client object', () => { await expect(ldc.identify(carContext)).rejects.toThrow('test-error'); expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/^error:.*test-error/)); - expect(ldc.getContext()).toBeUndefined(); }); test('identify change and error listeners', async () => { diff --git a/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts b/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts index 2cf4f79b63..92c2501b3d 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts @@ -1,10 +1,10 @@ import { AutoEnvAttributes, clone, LDContext } from '@launchdarkly/js-sdk-common'; import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; +import { toMulti } from './context/addAutoEnv'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; import { Flags } from './types'; -import { toMulti } from './utils/addAutoEnv'; jest.mock('@launchdarkly/js-sdk-common', () => { const actual = jest.requireActual('@launchdarkly/js-sdk-common'); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 5fefa7e6db..7c087379b5 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -23,19 +23,23 @@ import { ConnectionMode, LDClient, type LDOptions } from './api'; import LDEmitter, { EventName } from './api/LDEmitter'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; import Configuration from './configuration'; +import { addAutoEnv } from './context/addAutoEnv'; +import { ensureKey } from './context/ensureKey'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; +import FlagManager from './flag-manager/FlagManager'; +import { ItemDescriptor } from './flag-manager/ItemDescriptor'; import PollingProcessor from './polling/PollingProcessor'; import { DeleteFlag, Flags, PatchFlag } from './types'; -import { addAutoEnv, calculateFlagChanges, ensureKey } from './utils'; const { createErrorEvaluationDetail, createSuccessEvaluationDetail, ClientMessages, ErrorKinds } = internal; export default class LDClientImpl implements LDClient { private readonly config: Configuration; - private context?: LDContext; + private uncheckedContext?: LDContext; + private checkedContext?: Context; private readonly diagnosticsManager?: internal.DiagnosticsManager; private eventProcessor?: internal.EventProcessor; private identifyTimeout: number = 5; @@ -47,9 +51,10 @@ export default class LDClientImpl implements LDClient { private eventFactoryDefault = new EventFactory(false); private eventFactoryWithReasons = new EventFactory(true); private emitter: LDEmitter; - private flags: Flags = {}; + private flagManager: FlagManager; private readonly clientContext: ClientContext; + private eventSendingEnabled: boolean = true; private networkAvailable: boolean = true; private connectionMode: ConnectionMode; @@ -76,6 +81,12 @@ export default class LDClientImpl implements LDClient { this.connectionMode = this.config.initialConnectionMode; this.clientContext = new ClientContext(sdkKey, this.config, platform); this.logger = this.config.logger; + this.flagManager = new FlagManager( + this.platform, + sdkKey, + this.config.maxCachedContexts, + this.config.logger, + ); this.diagnosticsManager = createDiagnosticsManager(sdkKey, this.config, platform); this.eventProcessor = createEventProcessor( sdkKey, @@ -91,6 +102,11 @@ export default class LDClientImpl implements LDClient { this.emitter.on('error', (c: LDContext, err: any) => { this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`); }); + + this.flagManager.on((context, flagKeys) => { + const ldContext = Context.toLDContext(context); + this.emitter.emit('change', ldContext, flagKeys); + }); } /** @@ -113,9 +129,9 @@ export default class LDClientImpl implements LDClient { break; case 'polling': case 'streaming': - if (this.context) { + if (this.uncheckedContext) { // identify will start the update processor - return this.identify(this.context, { timeout: this.identifyTimeout }); + return this.identify(this.uncheckedContext, { timeout: this.identifyTimeout }); } break; @@ -141,12 +157,16 @@ export default class LDClientImpl implements LDClient { } allFlags(): LDFlagSet { - const result: LDFlagSet = {}; - Object.entries(this.flags).forEach(([k, r]) => { - if (!r.deleted) { - result[k] = r.value; - } - }); + // extracting all flag values + const result = Object.entries(this.flagManager.getAll()).reduce( + (acc: LDFlagSet, [key, descriptor]) => { + if (descriptor.flag !== null && descriptor.flag !== undefined && !descriptor.flag.deleted) { + acc[key] = descriptor.flag.value; + } + return acc; + }, + {}, + ); return result; } @@ -170,52 +190,58 @@ export default class LDClientImpl implements LDClient { } getContext(): LDContext | undefined { - return this.context ? clone(this.context) : undefined; + // The LDContext returned here may have been modified by the SDK (for example: adding auto env attributes). + // We are returning an LDContext here to maintain a consistent represetnation of context to the consuming + // code. We are returned the unchecked context so that if a consumer identifies with an invalid context + // and then calls getContext, they get back the same context they provided, without any assertion about + // validity. + return this.uncheckedContext ? clone(this.uncheckedContext) : undefined; } private createStreamListeners( - context: LDContext, - canonicalKey: string, + context: Context, identifyResolve: any, ): Map { const listeners = new Map(); listeners.set('put', { deserializeData: JSON.parse, - processJson: async (dataJson: Flags) => { - this.logger.debug(`Stream PUT: ${Object.keys(dataJson)}`); - this.onIdentifyResolve(identifyResolve, dataJson, context, 'stream PUT'); - await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); + processJson: async (evalResults: Flags) => { + this.logger.debug(`Stream PUT: ${Object.keys(evalResults)}`); + + // mapping flags to item descriptors + const descriptors = Object.entries(evalResults).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + + await this.flagManager.init(context, descriptors).then(identifyResolve()); }, }); listeners.set('patch', { deserializeData: JSON.parse, - processJson: async (dataJson: PatchFlag) => { - this.logger.debug(`Stream PATCH ${JSON.stringify(dataJson, null, 2)}`); - const existing = this.flags[dataJson.key]; - - // add flag if it doesn't exist or update it if version is newer - if (!existing || (existing && dataJson.version > existing.version)) { - this.flags[dataJson.key] = dataJson; - await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); - const changedKeys = [dataJson.key]; - this.logger.debug(`Emitting changes from PATCH: ${changedKeys}`); - this.emitter.emit('change', context, changedKeys); - } + processJson: async (patchFlag: PatchFlag) => { + this.logger.debug(`Stream PATCH ${JSON.stringify(patchFlag, null, 2)}`); + this.flagManager.upsert(context, patchFlag.key, { + version: patchFlag.version, + flag: patchFlag, + }); }, }); listeners.set('delete', { deserializeData: JSON.parse, - processJson: async (dataJson: DeleteFlag) => { - this.logger.debug(`Stream DELETE ${JSON.stringify(dataJson, null, 2)}`); - const existing = this.flags[dataJson.key]; - - // the deleted flag is saved as tombstoned - if (!existing || existing.version < dataJson.version) { - this.flags[dataJson.key] = { - ...dataJson, + processJson: async (deleteFlag: DeleteFlag) => { + this.logger.debug(`Stream DELETE ${JSON.stringify(deleteFlag, null, 2)}`); + + this.flagManager.upsert(context, deleteFlag.key, { + version: deleteFlag.version, + flag: { + ...deleteFlag, deleted: true, // props below are set to sensible defaults. they are irrelevant // because this flag has been deleted. @@ -223,12 +249,8 @@ export default class LDClientImpl implements LDClient { value: undefined, variation: 0, trackEvents: false, - }; - await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); - const changedKeys = [dataJson.key]; - this.logger.debug(`Emitting changes from DELETE: ${changedKeys}`); - this.emitter.emit('change', context, changedKeys); - } + }, + }); }, }); @@ -282,11 +304,6 @@ export default class LDClientImpl implements LDClient { return { identifyPromise: raced, identifyResolve: res, identifyReject: rej }; } - private async getFlagsFromStorage(canonicalKey: string): Promise { - const f = await this.platform.storage?.get(canonicalKey); - return f ? JSON.parse(f) : undefined; - } - /** * Identifies a context to LaunchDarkly. See {@link LDClient.identify}. * @@ -327,26 +344,25 @@ export default class LDClientImpl implements LDClient { this.emitter.emit('error', context, error); return Promise.reject(error); } + this.uncheckedContext = context; + this.checkedContext = checkedContext; - this.eventProcessor?.sendEvent(this.eventFactoryDefault.identifyEvent(checkedContext)); + this.eventProcessor?.sendEvent(this.eventFactoryDefault.identifyEvent(this.checkedContext)); const { identifyPromise, identifyResolve, identifyReject } = this.createIdentifyPromise( this.identifyTimeout, ); - this.logger.debug(`Identifying ${JSON.stringify(context)}`); + this.logger.debug(`Identifying ${JSON.stringify(this.checkedContext)}`); - const flagsStorage = await this.getFlagsFromStorage(checkedContext.canonicalKey); - if (flagsStorage) { - this.logger.debug('Using storage'); - this.onIdentifyResolve(identifyResolve, flagsStorage, context, 'identify storage'); + const loadedFromCache = await this.flagManager.loadCached(this.checkedContext); + if (loadedFromCache) { + identifyResolve(); } if (this.isOffline()) { - if (flagsStorage) { + if (loadedFromCache) { this.logger.debug('Offline identify using storage flags.'); } else { this.logger.debug('Offline identify no storage. Defaults will be used.'); - this.context = context; - this.flags = {}; identifyResolve(); } } else { @@ -356,7 +372,7 @@ export default class LDClientImpl implements LDClient { this.createStreamingProcessor(context, checkedContext, identifyResolve, identifyReject); break; case 'polling': - this.createPollingProcessor(identifyResolve, context, checkedContext, identifyReject); + this.createPollingProcessor(context, checkedContext, identifyResolve, identifyReject); break; default: break; @@ -368,9 +384,9 @@ export default class LDClientImpl implements LDClient { } private createPollingProcessor( - identifyResolve: any, - context: any, + context: LDContext, checkedContext: Context, + identifyResolve: any, identifyReject: any, ) { let pollingPath = this.createPollUriPath(context); @@ -385,8 +401,17 @@ export default class LDClientImpl implements LDClient { this.config, async (flags) => { this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); - this.onIdentifyResolve(identifyResolve, flags, context, 'polling'); - await this.platform.storage?.set(checkedContext.canonicalKey, JSON.stringify(this.flags)); + + // mapping flags to item descriptors + const descriptors = Object.entries(flags).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + + await this.flagManager.init(checkedContext, descriptors).then(identifyResolve()); }, (err) => { identifyReject(err); @@ -396,7 +421,7 @@ export default class LDClientImpl implements LDClient { } private createStreamingProcessor( - context: any, + context: LDContext, checkedContext: Context, identifyResolve: any, identifyReject: any, @@ -410,7 +435,7 @@ export default class LDClientImpl implements LDClient { this.sdkKey, this.clientContext, streamingPath, - this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve), + this.createStreamListeners(checkedContext, identifyResolve), this.diagnosticsManager, (e) => { identifyReject(e); @@ -419,33 +444,6 @@ export default class LDClientImpl implements LDClient { ); } - /** - * Performs common tasks when resolving the identify promise: - * - resolve the promise - * - update in memory context - * - update in memory flags - * - emit change event if needed - * - * @param resolve - * @param flags - * @param context - * @param source For logging purposes - * @private - */ - private onIdentifyResolve(resolve: any, flags: Flags, context: LDContext, source: string) { - resolve(); - const changedKeys = calculateFlagChanges(this.flags, flags); - this.context = context; - this.flags = flags; - - if (changedKeys.length > 0) { - this.emitter.emit('change', context, changedKeys); - this.logger.debug(`OnIdentifyResolve emitting changes from: ${source}.`); - } else { - this.logger.debug(`OnIdentifyResolve no changes to emit from: ${source}.`); - } - } - off(eventName: EventName, listener: Function): void { this.emitter.off(eventName, listener); } @@ -455,12 +453,7 @@ export default class LDClientImpl implements LDClient { } track(key: string, data?: any, metricValue?: number): void { - if (!this.context) { - this.logger.warn(ClientMessages.missingContextKeyNoEvent); - return; - } - const checkedContext = Context.fromLDContext(this.context); - if (!checkedContext.valid) { + if (!this.checkedContext || !this.checkedContext.valid) { this.logger.warn(ClientMessages.missingContextKeyNoEvent); return; } @@ -471,7 +464,7 @@ export default class LDClientImpl implements LDClient { } this.eventProcessor?.sendEvent( - this.eventFactoryDefault.customEvent(key, checkedContext!, data, metricValue), + this.eventFactoryDefault.customEvent(key, this.checkedContext!, data, metricValue), ); } @@ -481,27 +474,27 @@ export default class LDClientImpl implements LDClient { eventFactory: EventFactory, typeChecker?: (value: any) => [boolean, string], ): LDFlagValue { - if (!this.context) { + if (!this.uncheckedContext) { this.logger.debug(ClientMessages.missingContextKeyNoEvent); return createErrorEvaluationDetail(ErrorKinds.UserNotSpecified, defaultValue); } - const evalContext = Context.fromLDContext(this.context); - const found = this.flags[flagKey]; + const evalContext = Context.fromLDContext(this.uncheckedContext); + const foundItem = this.flagManager.get(flagKey); - if (!found || found.deleted) { + if (foundItem === undefined || foundItem.flag.deleted) { const defVal = defaultValue ?? null; const error = new LDClientError( `Unknown feature flag "${flagKey}"; returning default value ${defVal}.`, ); - this.emitter.emit('error', this.context, error); + this.emitter.emit('error', this.uncheckedContext, error); this.eventProcessor?.sendEvent( this.eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext), ); return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue); } - const { reason, value, variation } = found; + const { reason, value, variation } = foundItem.flag; if (typeChecker) { const [matched, type] = typeChecker(value); @@ -511,7 +504,7 @@ export default class LDClientImpl implements LDClient { flagKey, defaultValue, // track default value on type errors defaultValue, - found, + foundItem.flag, evalContext, reason, ), @@ -519,7 +512,7 @@ export default class LDClientImpl implements LDClient { const error = new LDClientError( `Wrong type "${type}" for feature flag "${flagKey}"; returning default value`, ); - this.emitter.emit('error', this.context, error); + this.emitter.emit('error', this.uncheckedContext, error); return createErrorEvaluationDetail(ErrorKinds.WrongType, defaultValue); } } @@ -530,7 +523,14 @@ export default class LDClientImpl implements LDClient { successDetail.value = defaultValue; } this.eventProcessor?.sendEvent( - eventFactory.evalEventClient(flagKey, value, defaultValue, found, evalContext, reason), + eventFactory.evalEventClient( + flagKey, + value, + defaultValue, + foundItem.flag, + evalContext, + reason, + ), ); return successDetail; } diff --git a/packages/shared/sdk-client/src/LDClientImpl.variation.test.ts b/packages/shared/sdk-client/src/LDClientImpl.variation.test.ts index 9066c02396..f565315c3f 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.variation.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.variation.test.ts @@ -1,4 +1,4 @@ -import { AutoEnvAttributes, clone, LDContext } from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, Context, LDContext } from '@launchdarkly/js-sdk-common'; import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; import * as mockResponseJson from './evaluation/mockResponse.json'; @@ -29,7 +29,7 @@ describe('sdk-client object', () => { beforeEach(() => { defaultPutResponse = clone(mockResponseJson); setupMockStreamingProcessor(false, defaultPutResponse); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, basicPlatform, { + ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, basicPlatform, { logger, sendEvents: false, }); @@ -79,8 +79,21 @@ describe('sdk-client object', () => { test('variationDetail deleted flag not found', async () => { await ldc.identify(context); + + const checkedContext = Context.fromLDContext(context); + // @ts-ignore - ldc.flags['dev-test-flag'].deleted = true; + await ldc.flagManager.upsert(checkedContext, 'dev-test-flag', { + version: 999, + flag: { + deleted: true, + version: 0, + flagVersion: 0, + value: undefined, + variation: 0, + trackEvents: false, + }, + }); const flag = ldc.variationDetail('dev-test-flag', 'deleted'); expect(flag).toEqual({ diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index d1d972341e..97de4f8b13 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -139,6 +139,14 @@ export interface LDOptions { */ logger?: LDLogger; + /** + * The maximum number of locally cached contexts. The cache is used to decrease initialization + * latency and to provide fallback when the SDK cannot reach LaunchDarkly services. + * + * @defaultValue 5 + */ + maxCachedContexts?: number; + /** * Specifies a list of attribute names (either built-in or custom) which should be marked as * private, and not sent to LaunchDarkly in analytics events. You can also specify this on a diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 3b3d8173db..c8d7f9aa72 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -25,6 +25,8 @@ export default class Configuration { public readonly eventsUri = ServiceEndpoints.DEFAULT_EVENTS; public readonly streamUri = Configuration.DEFAULT_STREAM; + public readonly maxCachedContexts = 5; + public readonly capacity = 100; public readonly diagnosticRecordingInterval = 900; public readonly flushInterval = 30; diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts b/packages/shared/sdk-client/src/context/addAutoEnv.test.ts similarity index 100% rename from packages/shared/sdk-client/src/utils/addAutoEnv.test.ts rename to packages/shared/sdk-client/src/context/addAutoEnv.test.ts diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.ts b/packages/shared/sdk-client/src/context/addAutoEnv.ts similarity index 93% rename from packages/shared/sdk-client/src/utils/addAutoEnv.ts rename to packages/shared/sdk-client/src/context/addAutoEnv.ts index ec4bdfcecf..c769b1b6be 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/context/addAutoEnv.ts @@ -12,7 +12,8 @@ import { } from '@launchdarkly/js-sdk-common'; import Configuration from '../configuration'; -import { getOrGenerateKey } from './getOrGenerateKey'; +import { getOrGenerateKey } from '../storage/getOrGenerateKey'; +import { namespaceForGeneratedContextKey } from '../storage/namespaceUtils'; const { isLegacyUser, isSingleKind, isMultiKind } = internal; const defaultAutoEnvSchemaVersion = '1.0'; @@ -94,7 +95,8 @@ export const addDeviceInfo = async (platform: Platform) => { // Check if device has any meaningful data before we return it. if (Object.keys(device).filter((k) => k !== 'key' && k !== 'envAttributesVersion').length) { - device.key = await getOrGenerateKey('context', 'ld_device', platform); + const ldDeviceNamespace = namespaceForGeneratedContextKey('ld_device'); + device.key = await getOrGenerateKey(ldDeviceNamespace, platform); device.envAttributesVersion = device.envAttributesVersion || defaultAutoEnvSchemaVersion; return device; } diff --git a/packages/shared/sdk-client/src/utils/ensureKey.test.ts b/packages/shared/sdk-client/src/context/ensureKey.test.ts similarity index 69% rename from packages/shared/sdk-client/src/utils/ensureKey.test.ts rename to packages/shared/sdk-client/src/context/ensureKey.test.ts index 69ef4a9a58..bbb6f289ab 100644 --- a/packages/shared/sdk-client/src/utils/ensureKey.test.ts +++ b/packages/shared/sdk-client/src/context/ensureKey.test.ts @@ -4,20 +4,16 @@ import type { LDContextCommon, LDMultiKindContext, LDUser, - Storage, } from '@launchdarkly/js-sdk-common'; import { basicPlatform } from '@launchdarkly/private-js-mocks'; -import ensureKey from './ensureKey'; -import { getOrGenerateKey, prefixNamespace } from './getOrGenerateKey'; +import { ensureKey } from './ensureKey'; describe('ensureKey', () => { let crypto: Crypto; - let storage: Storage; beforeEach(() => { crypto = basicPlatform.crypto; - storage = basicPlatform.storage; (crypto.randomUUID as jest.Mock).mockReturnValueOnce('random1').mockReturnValueOnce('random2'); }); @@ -26,33 +22,6 @@ describe('ensureKey', () => { jest.resetAllMocks(); }); - test('prefixNamespace', async () => { - const nsKey = prefixNamespace('anonymous', 'org'); - expect(nsKey).toEqual('LaunchDarkly_AnonymousKeys_org'); - }); - - test('getOrGenerateKey create new key', async () => { - const key = await getOrGenerateKey('anonymous', 'org', basicPlatform); - - expect(key).toEqual('random1'); - expect(crypto.randomUUID).toHaveBeenCalled(); - expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); - expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org', 'random1'); - }); - - test('getOrGenerateKey existing key', async () => { - (storage.get as jest.Mock).mockImplementation((namespacedKind: string) => - namespacedKind === 'LaunchDarkly_AnonymousKeys_org' ? 'random1' : undefined, - ); - - const key = await getOrGenerateKey('anonymous', 'org', basicPlatform); - - expect(key).toEqual('random1'); - expect(crypto.randomUUID).not.toHaveBeenCalled(); - expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); - expect(storage.set).not.toHaveBeenCalled(); - }); - test('ensureKey should not override anonymous key if specified', async () => { const context: LDContext = { kind: 'org', anonymous: true, key: 'Testy Pizza' }; const c = await ensureKey(context, basicPlatform); diff --git a/packages/shared/sdk-client/src/utils/ensureKey.ts b/packages/shared/sdk-client/src/context/ensureKey.ts similarity index 85% rename from packages/shared/sdk-client/src/utils/ensureKey.ts rename to packages/shared/sdk-client/src/context/ensureKey.ts index dfbce2b6fe..7b7f18cf3d 100644 --- a/packages/shared/sdk-client/src/utils/ensureKey.ts +++ b/packages/shared/sdk-client/src/context/ensureKey.ts @@ -9,7 +9,8 @@ import { Platform, } from '@launchdarkly/js-sdk-common'; -import { getOrGenerateKey } from './getOrGenerateKey'; +import { getOrGenerateKey } from '../storage/getOrGenerateKey'; +import { namespaceForAnonymousGeneratedContextKey } from '../storage/namespaceUtils'; const { isLegacyUser, isMultiKind, isSingleKind } = internal; @@ -30,9 +31,10 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf const { anonymous, key } = c; if (anonymous && !key) { + const storageKey = namespaceForAnonymousGeneratedContextKey(kind); // This mutates a cloned copy of the original context from ensureyKey so this is safe. // eslint-disable-next-line no-param-reassign - c.key = await getOrGenerateKey('anonymous', kind, platform); + c.key = await getOrGenerateKey(storageKey, platform); } }; @@ -61,7 +63,7 @@ const ensureKeyLegacy = async (c: LDUser, platform: Platform) => { * @param context * @param platform */ -const ensureKey = async (context: LDContext, platform: Platform) => { +export const ensureKey = async (context: LDContext, platform: Platform) => { const cloned = clone(context); if (isSingleKind(cloned)) { @@ -78,5 +80,3 @@ const ensureKey = async (context: LDContext, platform: Platform) => { return cloned; }; - -export default ensureKey; diff --git a/packages/shared/sdk-client/src/flag-manager/ContextIndex.test.ts b/packages/shared/sdk-client/src/flag-manager/ContextIndex.test.ts new file mode 100644 index 0000000000..aa9bd45ded --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/ContextIndex.test.ts @@ -0,0 +1,107 @@ +import ContextIndex from './ContextIndex'; + +describe('ContextIndex tests', () => { + test('notice adds to index', async () => { + const indexUnderTest = new ContextIndex(); + indexUnderTest.notice('first', 1); + indexUnderTest.notice('second', 2); + indexUnderTest.notice('third', 3); + + expect(indexUnderTest.container.index.length).toEqual(3); + expect(indexUnderTest.container.index.at(0)).toEqual({ id: 'first', timestamp: 1 }); + expect(indexUnderTest.container.index.at(1)).toEqual({ id: 'second', timestamp: 2 }); + expect(indexUnderTest.container.index.at(2)).toEqual({ id: 'third', timestamp: 3 }); + }); + + test('notice updates timestamp', async () => { + const indexUnderTest = new ContextIndex(); + indexUnderTest.notice('first', 1); + indexUnderTest.notice('second', 2); + expect(indexUnderTest.container.index.length).toEqual(2); + expect(indexUnderTest.container.index.at(0)).toEqual({ id: 'first', timestamp: 1 }); + expect(indexUnderTest.container.index.at(1)).toEqual({ id: 'second', timestamp: 2 }); + + indexUnderTest.notice('first', 3); + indexUnderTest.notice('second', 4); + expect(indexUnderTest.container.index.length).toEqual(2); + expect(indexUnderTest.container.index.at(0)).toEqual({ id: 'first', timestamp: 3 }); + expect(indexUnderTest.container.index.at(1)).toEqual({ id: 'second', timestamp: 4 }); + }); + + test('prune oldest down to maximum', async () => { + const indexUnderTest = new ContextIndex(); + indexUnderTest.notice('first', 50); + indexUnderTest.notice('second', 1); + indexUnderTest.notice('third', 2); + indexUnderTest.notice('fourth', 51); + expect(indexUnderTest.container.index.length).toEqual(4); + + indexUnderTest.prune(2); + expect(indexUnderTest.container.index.length).toEqual(2); + expect(indexUnderTest.container.index.at(0)).toEqual({ id: 'first', timestamp: 50 }); + expect(indexUnderTest.container.index.at(1)).toEqual({ id: 'fourth', timestamp: 51 }); + }); + + test('prune oldest down to 0', async () => { + const indexUnderTest = new ContextIndex(); + indexUnderTest.notice('first', 50); + indexUnderTest.notice('second', 1); + indexUnderTest.notice('third', 2); + indexUnderTest.notice('fourth', 51); + expect(indexUnderTest.container.index.length).toEqual(4); + + indexUnderTest.prune(0); + expect(indexUnderTest.container.index.length).toEqual(0); + }); + + test('prune negative number', async () => { + const indexUnderTest = new ContextIndex(); + indexUnderTest.notice('first', 50); + indexUnderTest.notice('second', 1); + indexUnderTest.notice('third', 2); + indexUnderTest.notice('fourth', 51); + expect(indexUnderTest.container.index.length).toEqual(4); + + indexUnderTest.prune(-1); + expect(indexUnderTest.container.index.length).toEqual(0); + }); + + test('prune two entries have same timestamp', async () => { + const indexUnderTest = new ContextIndex(); + indexUnderTest.notice('first', 1); + indexUnderTest.notice('second', 1); + expect(indexUnderTest.container.index.length).toEqual(2); + + indexUnderTest.prune(1); + expect(indexUnderTest.container.index.length).toEqual(1); + expect(indexUnderTest.container.index[0].id).toEqual('second'); + }); + + test('toJson', async () => { + const indexUnderTest = new ContextIndex(); + indexUnderTest.notice('first', 1); + indexUnderTest.notice('second', 2); + indexUnderTest.notice('third', 3); + + const output = indexUnderTest.toJson(); + expect(output).toEqual( + '{"index":[{"id":"first","timestamp":1},{"id":"second","timestamp":2},{"id":"third","timestamp":3}]}', + ); + }); + + test('fromJson valid', async () => { + const input = + '{"index":[{"id":"first","timestamp":1},{"id":"second","timestamp":2},{"id":"third","timestamp":3}]}'; + const indexUnderTest = ContextIndex.fromJson(input); + expect(indexUnderTest.container.index.length).toEqual(3); + expect(indexUnderTest.container.index.at(0)).toEqual({ id: 'first', timestamp: 1 }); + expect(indexUnderTest.container.index.at(1)).toEqual({ id: 'second', timestamp: 2 }); + expect(indexUnderTest.container.index.at(2)).toEqual({ id: 'third', timestamp: 3 }); + }); + + test('fromJson invalid', async () => { + const input = 'My name is Json. I am invalid.'; + const indexUnderTest = ContextIndex.fromJson(input); + expect(indexUnderTest.container.index.length).toEqual(0); + }); +}); diff --git a/packages/shared/sdk-client/src/flag-manager/ContextIndex.ts b/packages/shared/sdk-client/src/flag-manager/ContextIndex.ts new file mode 100644 index 0000000000..63b361abf5 --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/ContextIndex.ts @@ -0,0 +1,70 @@ +/** + * An index for tracking the most recently used contexts by timestamp with the ability to + * update entry timestamps and prune out least used contexts above a max capacity provided. + */ +export default class ContextIndex { + container: IndexContainer = { index: new Array() }; + + /** + * Creates a {@link ContextIndex} from its JSON representation (likely retrieved from persistence). + * @param json representation of the {@link ContextIndex} + * @returns the {@link ContextIndex} + */ + static fromJson(json: string): ContextIndex { + const contextIndex = new ContextIndex(); + try { + contextIndex.container = JSON.parse(json); + } catch (e) { + /* ignoring error and returning empty index */ + } + + return contextIndex; + } + + /** + * @returns the JSON representation of the {@link ContextIndex} (like for saving to persistence) + */ + toJson(): string { + return JSON.stringify(this.container); + } + + /** + * Notice that a context has been used and when it was used. This will update an existing record + * with the given timestamp, or create a new record if one doesn't exist. + * @param id of the corresponding context + * @param timestamp in millis since epoch + */ + notice(id: string, timestamp: number) { + const entry = this.container.index.find((it) => it.id === id); + if (entry === undefined) { + this.container.index.push({ id, timestamp }); + } else { + entry.timestamp = timestamp; + } + } + + /** + * Prune the index to the specified max size and then return the IDs + * @param maxContexts the maximum number of contexts to retain after this prune + * @returns an array of removed entries + */ + prune(maxContexts: number): Array { + const clampedMax = Math.max(maxContexts, 0); // clamp to [0, infinity) + if (this.container.index.length > clampedMax) { + // sort by timestamp so that older timestamps appear first in the array + this.container.index.sort((a, b) => a.timestamp - b.timestamp); + // delete the first N many elements above capacity. splice returns removed elements + return this.container.index.splice(0, this.container.index.length - clampedMax); + } + return []; + } +} + +export interface IndexContainer { + index: Array; +} + +interface IndexEntry { + id: string; + timestamp: number; +} diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts new file mode 100644 index 0000000000..c90b32c51e --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -0,0 +1,98 @@ +import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; + +import { namespaceForEnvironment } from '../storage/namespaceUtils'; +import FlagPersistence from './FlagPersistence'; +import { DefaultFlagStore } from './FlagStore'; +import FlagUpdater, { FlagsChangeCallback } from './FlagUpdater'; +import { ItemDescriptor } from './ItemDescriptor'; + +/** + * Top level manager of flags for the client. LDClient should be using this + * class and not any of the specific instances managed by it. Updates from + * data sources should be directed to the [init] and [upsert] methods of this + * class. + */ +export default class FlagManager { + private flagStore = new DefaultFlagStore(); + private flagUpdater: FlagUpdater; + private flagPersistence: FlagPersistence; + + /** + * @param platform implementation of various platform provided functionality + * @param sdkKey that will be used to distinguish different environments + * @param maxCachedContexts that specifies the max number of contexts that will be cached in persistence + * @param logger used for logging various messages + * @param timeStamper exists for testing purposes + */ + constructor( + platform: Platform, + sdkKey: string, + maxCachedContexts: number, + logger: LDLogger, + private readonly timeStamper: () => number = () => Date.now(), + ) { + const environmentNamespace = namespaceForEnvironment(platform.crypto, sdkKey); + + this.flagUpdater = new FlagUpdater(this.flagStore, logger); + this.flagPersistence = new FlagPersistence( + platform, + environmentNamespace, + maxCachedContexts, + this.flagStore, + this.flagUpdater, + logger, + timeStamper, + ); + } + + /** + * Attempts to get a flag by key from the current flags. + */ + get(key: string): ItemDescriptor | undefined { + return this.flagStore.get(key); + } + + /** + * Gets all the current flags. + */ + getAll(): { [key: string]: ItemDescriptor } { + return this.flagStore.getAll(); + } + + /** + * Initializes the flag manager with data from a data source. + * Persistence initialization is handled by {@link FlagPersistence} + */ + async init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise { + return this.flagPersistence.init(context, newFlags); + } + + /** + * Attempt to update a flag. If the flag is for the wrong context, or + * it is of an older version, then an update will not be performed. + */ + async upsert(context: Context, key: string, item: ItemDescriptor): Promise { + return this.flagPersistence.upsert(context, key, item); + } + + /** + * Asynchronously load cached values from persistence. + */ + async loadCached(context: Context): Promise { + return this.flagPersistence.loadCached(context); + } + + /** + * Register a flag change callback. + */ + on(callback: FlagsChangeCallback): void { + this.flagUpdater.on(callback); + } + + /** + * Unregister a flag change callback. + */ + off(callback: FlagsChangeCallback): void { + this.flagUpdater.off(callback); + } +} diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.test.ts b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.test.ts new file mode 100644 index 0000000000..2cd26cd1b0 --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.test.ts @@ -0,0 +1,399 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common'; + +import { namespaceForContextData, namespaceForContextIndex } from '../storage/namespaceUtils'; +import { Flag, Flags } from '../types'; +import FlagPersistence from './FlagPersistence'; +import { DefaultFlagStore } from './FlagStore'; +import FlagUpdater from './FlagUpdater'; + +const TEST_NAMESPACE = 'TestNamespace'; + +describe('FlagPersistence tests', () => { + test('loadCached returns false when no cache', async () => { + const flagStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const fpUnderTest = new FlagPersistence( + makeMockPlatform(makeMemoryStorage(), makeMockCrypto()), + TEST_NAMESPACE, + 5, + flagStore, + new FlagUpdater(flagStore, mockLogger), + mockLogger, + ); + + const context = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + const didLoadCache = await fpUnderTest.loadCached(context); + expect(didLoadCache).toEqual(false); + }); + + test('loadCached returns false when corrupt cache', async () => { + const flagStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const fpUnderTest = new FlagPersistence( + makeMockPlatform( + makeCorruptStorage(), // storage that corrupts data + makeMockCrypto(), + ), + TEST_NAMESPACE, + 5, + flagStore, + new FlagUpdater(flagStore, mockLogger), + mockLogger, + ); + + const context = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + + await fpUnderTest.init(context, flags); + const didLoadCache = await fpUnderTest.loadCached(context); + expect(didLoadCache).toEqual(false); + }); + + test('loadCached updates FlagUpdater with cached flags', async () => { + const flagStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const flagUpdater = new FlagUpdater(flagStore, mockLogger); + const flagUpdaterSpy = jest.spyOn(flagUpdater, 'initCached'); + const fpUnderTest = new FlagPersistence( + makeMockPlatform(makeMemoryStorage(), makeMockCrypto()), + TEST_NAMESPACE, + 5, + flagStore, + flagUpdater, + mockLogger, + ); + + const context = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + + await fpUnderTest.init(context, flags); + const didLoadCache = await fpUnderTest.loadCached(context); + expect(didLoadCache).toEqual(true); + expect(flagUpdaterSpy).toHaveBeenCalledWith(context, flags); + }); + + test('loadCached migrates pre 10.3.1 cached flags', async () => { + const flagStore = new DefaultFlagStore(); + const memoryStorage = makeMemoryStorage(); + const mockLogger = makeMockLogger(); + const flagUpdater = new FlagUpdater(flagStore, mockLogger); + const fpUnderTest = new FlagPersistence( + makeMockPlatform(memoryStorage, makeMockCrypto()), + TEST_NAMESPACE, + 5, + flagStore, + flagUpdater, + mockLogger, + ); + + const context = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + + // put mock old flags into the storage + const mockOldFlags: Flags = { + flagA: makeMockFlag(), + }; + memoryStorage.set(context.canonicalKey, JSON.stringify(mockOldFlags)); + + const didLoadCache = await fpUnderTest.loadCached(context); + expect(didLoadCache).toEqual(true); + + // expect migration to have deleted data at old location + expect(await memoryStorage.get(context.canonicalKey)).toBeNull(); + }); + + test('init successfully persists flags', async () => { + const memoryStorage = makeMemoryStorage(); + const mockPlatform = makeMockPlatform(memoryStorage, makeMockCrypto()); + const flagStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const flagUpdater = new FlagUpdater(flagStore, mockLogger); + + const fpUnderTest = new FlagPersistence( + mockPlatform, + TEST_NAMESPACE, + 5, + flagStore, + flagUpdater, + mockLogger, + ); + + const context = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + + await fpUnderTest.init(context, flags); + + const contextDataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context); + const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE); + expect(await memoryStorage.get(contextIndexKey)).toContain(contextDataKey); + expect(await memoryStorage.get(contextDataKey)).toContain('flagA'); + }); + + test('init prunes cached contexts above max', async () => { + const memoryStorage = makeMemoryStorage(); + const mockPlatform = makeMockPlatform(memoryStorage, makeMockCrypto()); + const flagStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const flagUpdater = new FlagUpdater(flagStore, mockLogger); + + const fpUnderTest = new FlagPersistence( + mockPlatform, + TEST_NAMESPACE, + 1, // max of 1 for this test + flagStore, + flagUpdater, + mockLogger, + ); + + const context1 = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + const context2 = Context.fromLDContext({ kind: 'user', key: 'TestyUser' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + + await fpUnderTest.init(context1, flags); + await fpUnderTest.init(context2, flags); + + const context1DataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context1); + const context2DataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context2); + const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE); + + const indexData = await memoryStorage.get(contextIndexKey); + expect(indexData).not.toContain(context1DataKey); + expect(indexData).toContain(context2DataKey); + expect(await memoryStorage.get(context1DataKey)).toBeNull(); + expect(await memoryStorage.get(context2DataKey)).toContain('flagA'); + }); + + test('init kicks timestamp', async () => { + const memoryStorage = makeMemoryStorage(); + const mockPlatform = makeMockPlatform(memoryStorage, makeMockCrypto()); + const flagStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const flagUpdater = new FlagUpdater(flagStore, mockLogger); + + const fpUnderTest = new FlagPersistence( + mockPlatform, + TEST_NAMESPACE, + 5, + flagStore, + flagUpdater, + mockLogger, + makeIncrementingStamper(), + ); + + const context = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + + await fpUnderTest.init(context, flags); + await fpUnderTest.init(context, flags); + const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE); + + const indexData = await memoryStorage.get(contextIndexKey); + expect(indexData).toContain(`"timestamp":2`); + }); + + test('upsert updates persistence', async () => { + const memoryStorage = makeMemoryStorage(); + const mockPlatform = makeMockPlatform(memoryStorage, makeMockCrypto()); + const flagStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const flagUpdater = new FlagUpdater(flagStore, mockLogger); + + const fpUnderTest = new FlagPersistence( + mockPlatform, + TEST_NAMESPACE, + 5, + flagStore, + flagUpdater, + mockLogger, + ); + + const context = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + const flagAv1 = makeMockFlag(1); + const flagAv2 = makeMockFlag(2); + const flags = { + flagA: { + version: 1, + flag: flagAv1, + }, + }; + + await fpUnderTest.init(context, flags); + await fpUnderTest.upsert(context, 'flagA', { version: 2, flag: flagAv2 }); + + const contextDataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context); + + // check memory flag store and persistence + expect(flagStore.get('flagA')?.version).toEqual(2); + expect(await memoryStorage.get(contextDataKey)).toContain('"version":2'); + }); + + test('upsert ignores inactive context', async () => { + const memoryStorage = makeMemoryStorage(); + const mockPlatform = makeMockPlatform(memoryStorage, makeMockCrypto()); + const flagStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const flagUpdater = new FlagUpdater(flagStore, mockLogger); + + const fpUnderTest = new FlagPersistence( + mockPlatform, + TEST_NAMESPACE, + 5, + flagStore, + flagUpdater, + mockLogger, + ); + + const activeContext = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); + const inactiveContext = Context.fromLDContext({ kind: 'user', key: 'TestyUser' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + + await fpUnderTest.init(activeContext, flags); + await fpUnderTest.upsert(inactiveContext, 'inactiveContextFlag', { + version: 1, + flag: makeMockFlag(), + }); + + const activeContextDataKey = namespaceForContextData( + mockPlatform.crypto, + TEST_NAMESPACE, + activeContext, + ); + const inactiveContextDataKey = namespaceForContextData( + mockPlatform.crypto, + TEST_NAMESPACE, + inactiveContext, + ); + + expect(await memoryStorage.get(activeContextDataKey)).not.toBeNull(); + expect(await memoryStorage.get(inactiveContextDataKey)).toBeNull(); + }); +}); + +function makeMockPlatform(storage: Storage, crypto: Crypto): Platform { + return { + storage, + crypto, + info: { + platformData: jest.fn(), + sdkData: jest.fn(), + }, + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + }, + }; +} + +function makeMemoryStorage(): Storage { + const data = new Map(); + return { + get: async (key: string) => { + const value = data.get(key); + return value !== undefined ? value : null; // mapping undefined to null to satisfy interface + }, + set: async (key: string, value: string) => { + data.set(key, value); + }, + clear: async (key: string) => { + data.delete(key); + }, + }; +} + +function makeCorruptStorage(): Storage { + const data = new Map(); + return { + get: async (key: string) => { + const value = data.get(key); + return value !== undefined ? 'corruption!!!!!' : null; // mapping undefined to null to satisfy interface + }, + set: async (key: string, value: string) => { + data.set(key, value); + }, + clear: async (key: string) => { + data.delete(key); + }, + }; +} + +function makeMockCrypto() { + let counter = 0; + let lastInput = ''; + const hasher: Hasher = { + update: jest.fn((input) => { + lastInput = input; + return hasher; + }), + digest: jest.fn(() => `${lastInput}Hashed`), + }; + + return { + createHash: jest.fn(() => hasher), + createHmac: jest.fn(), + randomUUID: jest.fn(() => { + counter += 1; + // Will provide a unique value for tests. + // Very much not a UUID of course. + return `${counter}`; + }), + }; +} + +function makeMockLogger(): LDLogger { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +} + +function makeMockFlag(version: number = 1): Flag { + // the values of the flag object itself are not relevant for these tests, the + // version on the item descriptor is what matters + return { + version, + flagVersion: version, + value: undefined, + variation: 0, + trackEvents: false, + }; +} + +function makeIncrementingStamper(): () => number { + let count = 0; + return () => { + count += 1; + return count; + }; +} diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts new file mode 100644 index 0000000000..e7d903e240 --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts @@ -0,0 +1,150 @@ +import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; + +import { namespaceForContextData, namespaceForContextIndex } from '../storage/namespaceUtils'; +import { Flags } from '../types'; +import ContextIndex from './ContextIndex'; +import FlagStore from './FlagStore'; +import FlagUpdater from './FlagUpdater'; +import { ItemDescriptor } from './ItemDescriptor'; + +/** + * This class handles persisting and loading flag values from a persistent + * store. It intercepts updates and forwards them to the flag updater and + * then persists changes after the updater has completed. + */ +export default class FlagPersistence { + private contextIndex: ContextIndex | undefined; + private indexKey: string; + + constructor( + private readonly platform: Platform, + private readonly environmentNamespace: string, + private readonly maxCachedContexts: number, + private readonly flagStore: FlagStore, + private readonly flagUpdater: FlagUpdater, + private readonly logger: LDLogger, + private readonly timeStamper: () => number = () => Date.now(), + ) { + this.indexKey = namespaceForContextIndex(this.environmentNamespace); + } + + /** + * Inits flag persistence for the provided context with the provided flags. This will result + * in the underlying {@link FlagUpdater} switching its active context. + */ + async init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise { + this.flagUpdater.init(context, newFlags); + await this.storeCache(context); + } + + /** + * Upserts a flag into the {@link FlagUpdater} and stores that to persistence if the upsert + * was successful / accepted. An upsert may be rejected if the provided context is not + * the active context. + */ + async upsert(context: Context, key: string, item: ItemDescriptor): Promise { + if (this.flagUpdater.upsert(context, key, item)) { + await this.storeCache(context); + return true; + } + return false; + } + + /** + * Loads the flags from persistence for the provided context and gives those to the + * {@link FlagUpdater} this {@link FlagPersistence} was constructed with. + */ + async loadCached(context: Context): Promise { + const storageKey = namespaceForContextData( + this.platform.crypto, + this.environmentNamespace, + context, + ); + let flagsJson = await this.platform.storage?.get(storageKey); + if (flagsJson === null || flagsJson === undefined) { + // Fallback: in version <10.3.1 flag data was stored under the canonical key, check + // to see if data is present and migrate the data if present. + flagsJson = await this.platform.storage?.get(context.canonicalKey); + if (flagsJson === null || flagsJson === undefined) { + // return false indicating cache did not load if flag json is still absent + return false; + } + + // migrate data from version <10.3.1 and cleanup data that was under canonical key + await this.platform.storage?.set(storageKey, flagsJson); + await this.platform.storage?.clear(context.canonicalKey); + } + + try { + const flags: Flags = JSON.parse(flagsJson); + + // mapping flags to item descriptors + const descriptors = Object.entries(flags).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + + this.flagUpdater.initCached(context, descriptors); + this.logger.debug('Loaded cached flag evaluations from persistent storage'); + return true; + } catch (e: any) { + this.logger.warn( + `Could not load cached flag evaluations from persistent storage: ${e.message}`, + ); + return false; + } + } + + private async loadIndex(): Promise { + if (this.contextIndex !== undefined) { + return this.contextIndex; + } + + const json = await this.platform.storage?.get(this.indexKey); + if (!json) { + this.contextIndex = new ContextIndex(); + return this.contextIndex; + } + + try { + this.contextIndex = ContextIndex.fromJson(json); + this.logger.debug('Loaded context index from persistent storage'); + } catch (e: any) { + this.logger.warn(`Could not load index from persistent storage: ${e.message}`); + this.contextIndex = new ContextIndex(); + } + return this.contextIndex; + } + + private async storeCache(context: Context): Promise { + const index = await this.loadIndex(); + const storageKey = namespaceForContextData( + this.platform.crypto, + this.environmentNamespace, + context, + ); + index.notice(storageKey, this.timeStamper()); + + const pruned = index.prune(this.maxCachedContexts); + await Promise.all(pruned.map(async (it) => this.platform.storage?.clear(it.id))); + + // store index + await this.platform.storage?.set(this.indexKey, index.toJson()); + const allFlags = this.flagStore.getAll(); + + // mapping item descriptors to flags + const flags = Object.entries(allFlags).reduce((acc: Flags, [key, descriptor]) => { + if (descriptor.flag !== null && descriptor.flag !== undefined) { + acc[key] = descriptor.flag; + } + return acc; + }, {}); + + const jsonAll = JSON.stringify(flags); + // store flag data + await this.platform.storage?.set(storageKey, jsonAll); + } +} diff --git a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts new file mode 100644 index 0000000000..d9ce91b110 --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts @@ -0,0 +1,40 @@ +import { ItemDescriptor } from './ItemDescriptor'; + +/** + * This interface exists for testing purposes + */ +export default interface FlagStore { + init(newFlags: { [key: string]: ItemDescriptor }): void; + insertOrUpdate(key: string, update: ItemDescriptor): void; + get(key: string): ItemDescriptor | undefined; + getAll(): { [key: string]: ItemDescriptor }; +} + +/** + * In memory flag store. + */ +export class DefaultFlagStore implements FlagStore { + private flags: { [key: string]: ItemDescriptor } = {}; + + init(newFlags: { [key: string]: ItemDescriptor }) { + this.flags = Object.entries(newFlags).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = flag; + return acc; + }, + {}, + ); + } + + insertOrUpdate(key: string, update: ItemDescriptor) { + this.flags[key] = update; + } + + get(key: string): ItemDescriptor | undefined { + return this.flags[key]; + } + + getAll(): { [key: string]: ItemDescriptor } { + return this.flags; + } +} diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.test.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.test.ts new file mode 100644 index 0000000000..26b5d42daa --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.test.ts @@ -0,0 +1,239 @@ +import { Context, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { Flag } from '../types'; +import { DefaultFlagStore } from './FlagStore'; +import FlagUpdater, { FlagsChangeCallback } from './FlagUpdater'; + +function makeMockFlag(): Flag { + // the values of the flag object itself are not relevant for these tests, the + // version on the item descriptor is what matters + return { + version: 0, + flagVersion: 0, + value: undefined, + variation: 0, + trackEvents: false, + }; +} + +function makeMockLogger(): LDLogger { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +} + +describe('FlagUpdater tests', () => { + test('init calls init on underlying flag store', async () => { + const mockStore = new DefaultFlagStore(); + const mockStoreSpy = jest.spyOn(mockStore, 'init'); + const mockLogger = makeMockLogger(); + + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.init(context, flags); + expect(mockStoreSpy).toHaveBeenCalledTimes(1); + expect(mockStoreSpy).toHaveBeenLastCalledWith(flags); + }); + + test('triggers callbacks on init', async () => { + const mockStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const mockCallback: FlagsChangeCallback = jest.fn(); + + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.on(mockCallback); + updaterUnderTest.init(context, flags); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + test('init cached ignores context same as active context', async () => { + const mockStore = new DefaultFlagStore(); + const mockStoreSpy = jest.spyOn(mockStore, 'init'); + const mockLogger = makeMockLogger(); + + const activeContext = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const sameContext = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.init(activeContext, flags); + expect(mockStoreSpy).toHaveBeenCalledTimes(1); + updaterUnderTest.initCached(sameContext, flags); + expect(mockStoreSpy).toHaveBeenCalledTimes(1); + }); + + test('upsert ignores inactive context', async () => { + const mockStore = new DefaultFlagStore(); + const mockStoreSpy = jest.spyOn(mockStore, 'init'); + const mockLogger = makeMockLogger(); + + const activeContext = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const inactiveContext = Context.fromLDContext({ kind: 'anotherKind', key: 'another-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.init(activeContext, flags); + expect(mockStoreSpy).toHaveBeenCalledTimes(1); + + const didUpsert = updaterUnderTest.upsert(inactiveContext, 'flagA', { + version: 1, + flag: makeMockFlag(), + }); + expect(didUpsert).toEqual(false); + }); + + test('upsert rejects data with old versions', async () => { + const mockStore = new DefaultFlagStore(); + const mockStoreSpy = jest.spyOn(mockStore, 'init'); + const mockLogger = makeMockLogger(); + + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.init(context, flags); + expect(mockStoreSpy).toHaveBeenCalledTimes(1); + + const didUpsert = updaterUnderTest.upsert(context, 'flagA', { + version: 0, + flag: makeMockFlag(), + }); // version 0 should be ignored + expect(didUpsert).toEqual(false); + }); + + test('upsert updates underlying store', async () => { + const mockStore = new DefaultFlagStore(); + const mockStoreSpyInit = jest.spyOn(mockStore, 'init'); + const mockStoreSpyInsertOrUpdate = jest.spyOn(mockStore, 'insertOrUpdate'); + const mockLogger = makeMockLogger(); + + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.init(context, flags); + expect(mockStoreSpyInit).toHaveBeenCalledTimes(1); + + const didUpsert = updaterUnderTest.upsert(context, 'flagA', { + version: 2, + flag: makeMockFlag(), + }); // version is higher and should be inserted + expect(didUpsert).toEqual(true); + expect(mockStoreSpyInsertOrUpdate).toHaveBeenCalledTimes(1); + }); + + test('upsert triggers callbacks', async () => { + const mockStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const mockCallbackA: FlagsChangeCallback = jest.fn(); + const mockCallbackB: FlagsChangeCallback = jest.fn(); + + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.init(context, flags); + + // register the callbacks + updaterUnderTest.on(mockCallbackA); + updaterUnderTest.on(mockCallbackB); + + const didUpsert = updaterUnderTest.upsert(context, 'flagA', { + version: 2, + flag: makeMockFlag(), + }); // version is higher and should be inserted + expect(didUpsert).toEqual(true); + expect(mockCallbackA).toHaveBeenCalledTimes(1); + expect(mockCallbackB).toHaveBeenCalledTimes(1); + }); + + test('off removes callback', async () => { + const mockStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const mockCallback: FlagsChangeCallback = jest.fn(); + + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.init(context, flags); + + // register the callback + updaterUnderTest.on(mockCallback); + + updaterUnderTest.upsert(context, 'flagA', { + version: 2, + flag: makeMockFlag(), + }); // version is higher and should be inserted + expect(mockCallback).toHaveBeenCalledTimes(1); + + // remove the callback + updaterUnderTest.off(mockCallback); + updaterUnderTest.upsert(context, 'flagA', { + version: 3, + flag: makeMockFlag(), + }); // version is higher and should be inserted + expect(mockCallback).toHaveBeenCalledTimes(1); // only 1 call still, not 2 + }); + + test('off can be called many times safely', async () => { + const mockStore = new DefaultFlagStore(); + const mockLogger = makeMockLogger(); + const mockCallback: FlagsChangeCallback = jest.fn(); + + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + const flags = { + flagA: { + version: 1, + flag: makeMockFlag(), + }, + }; + const updaterUnderTest = new FlagUpdater(mockStore, mockLogger); + updaterUnderTest.init(context, flags); + updaterUnderTest.off(mockCallback); + updaterUnderTest.on(mockCallback); + updaterUnderTest.off(mockCallback); + updaterUnderTest.off(mockCallback); + updaterUnderTest.off(mockCallback); + }); +}); diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts new file mode 100644 index 0000000000..d96f9b3931 --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts @@ -0,0 +1,95 @@ +import { Context, LDLogger } from '@launchdarkly/js-sdk-common'; + +import calculateChangedKeys from './calculateChangedKeys'; +import FlagStore from './FlagStore'; +import { ItemDescriptor } from './ItemDescriptor'; + +/** + * This callback indicates that the details associated with one or more flags + * have changed. + * + * This could be the value of the flag, but it could also include changes + * to the evaluation reason, such as being included in an experiment. + * + * It can include new or deleted flags as well, so an evaluation may result + * in a FLAG_NOT_FOUND reason. + * + * This event does not include the value of the flag. It is expected that you + * will call a variation method for flag values which you require. + */ +export type FlagsChangeCallback = (context: Context, flagKeys: Array) => void; + +/** + * The flag updater handles logic required during the flag update process. + * It handles versions checking to handle out of order flag updates and + * also handles flag comparisons for change notification. + */ +export default class FlagUpdater { + private flagStore: FlagStore; + private logger: LDLogger; + private activeContextKey: string | undefined; + private changeCallbacks = new Array(); + + constructor(flagStore: FlagStore, logger: LDLogger) { + this.flagStore = flagStore; + this.logger = logger; + } + + init(context: Context, newFlags: { [key: string]: ItemDescriptor }) { + this.activeContextKey = context.canonicalKey; + const oldFlags = this.flagStore.getAll(); + this.flagStore.init(newFlags); + const changed = calculateChangedKeys(oldFlags, newFlags); + if (changed.length > 0) { + this.changeCallbacks.forEach((callback) => { + try { + callback(context, changed); + } catch (err) { + /* intentionally empty */ + } + }); + } + } + + initCached(context: Context, newFlags: { [key: string]: ItemDescriptor }) { + if (this.activeContextKey === context.canonicalKey) { + return; + } + + this.init(context, newFlags); + } + + upsert(context: Context, key: string, item: ItemDescriptor): boolean { + if (this.activeContextKey !== context.canonicalKey) { + this.logger.warn('Received an update for an inactive context.'); + return false; + } + + const currentValue = this.flagStore.get(key); + if (currentValue !== undefined && currentValue.version >= item.version) { + // this is an out of order update that can be ignored + return false; + } + + this.flagStore.insertOrUpdate(key, item); + this.changeCallbacks.forEach((callback) => { + try { + callback(context, [key]); + } catch (err) { + /* intentionally empty */ + } + }); + return true; + } + + on(callback: FlagsChangeCallback): void { + this.changeCallbacks.push(callback); + } + + off(callback: FlagsChangeCallback): void { + const index = this.changeCallbacks.indexOf(callback); + if (index > -1) { + this.changeCallbacks.splice(index, 1); + } + } +} diff --git a/packages/shared/sdk-client/src/flag-manager/ItemDescriptor.ts b/packages/shared/sdk-client/src/flag-manager/ItemDescriptor.ts new file mode 100644 index 0000000000..735607fe77 --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/ItemDescriptor.ts @@ -0,0 +1,10 @@ +import { Flag } from '../types'; + +/** + * An item descriptor is an abstraction that allows for Flag data to be + * handled using the same type in both a put or a patch. + */ +export interface ItemDescriptor { + version: number; + flag: Flag; +} diff --git a/packages/shared/sdk-client/src/flag-manager/calculateChangedKeys.ts b/packages/shared/sdk-client/src/flag-manager/calculateChangedKeys.ts new file mode 100644 index 0000000000..fa9d880ead --- /dev/null +++ b/packages/shared/sdk-client/src/flag-manager/calculateChangedKeys.ts @@ -0,0 +1,25 @@ +import { fastDeepEqual } from '@launchdarkly/js-sdk-common'; + +export default function calculateChangedKeys( + existingObject: { [k: string]: any }, + newObject: { [k: string]: any }, +) { + const changedKeys: string[] = []; + + // property deleted or updated + Object.entries(existingObject).forEach(([k, f]) => { + const subObject = newObject[k]; + if (!subObject || !fastDeepEqual(f, subObject)) { + changedKeys.push(k); + } + }); + + // property added + Object.keys(newObject).forEach((k) => { + if (!existingObject[k]) { + changedKeys.push(k); + } + }); + + return changedKeys; +} diff --git a/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts b/packages/shared/sdk-client/src/storage/getOrGenerateKey.test.ts similarity index 59% rename from packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts rename to packages/shared/sdk-client/src/storage/getOrGenerateKey.test.ts index 5ff2204c5c..bddafe191a 100644 --- a/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts +++ b/packages/shared/sdk-client/src/storage/getOrGenerateKey.test.ts @@ -11,17 +11,39 @@ describe('getOrGenerateKey', () => { crypto = basicPlatform.crypto; storage = basicPlatform.storage; - (crypto.randomUUID as jest.Mock).mockResolvedValue('test-org-key-1'); + (crypto.randomUUID as jest.Mock).mockReturnValueOnce('test-org-key-1'); }); afterEach(() => { jest.resetAllMocks(); }); + test('getOrGenerateKey create new key', async () => { + const key = await getOrGenerateKey('LaunchDarkly_AnonymousKeys_org', basicPlatform); + + expect(key).toEqual('test-org-key-1'); + expect(crypto.randomUUID).toHaveBeenCalled(); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); + expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org', 'test-org-key-1'); + }); + + test('getOrGenerateKey existing key', async () => { + (storage.get as jest.Mock).mockImplementation((storageKey: string) => + storageKey === 'LaunchDarkly_AnonymousKeys_org' ? 'random1' : undefined, + ); + + const key = await getOrGenerateKey('LaunchDarkly_AnonymousKeys_org', basicPlatform); + + expect(key).toEqual('random1'); + expect(crypto.randomUUID).not.toHaveBeenCalled(); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); + expect(storage.set).not.toHaveBeenCalled(); + }); + describe('anonymous namespace', () => { test('anonymous key does not exist so should be generated', async () => { (storage.get as jest.Mock).mockResolvedValue(undefined); - const k = await getOrGenerateKey('anonymous', 'org', basicPlatform); + const k = await getOrGenerateKey('LaunchDarkly_AnonymousKeys_org', basicPlatform); expect(crypto.randomUUID).toHaveBeenCalled(); expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); @@ -31,7 +53,7 @@ describe('getOrGenerateKey', () => { test('anonymous key exists so should not be generated', async () => { (storage.get as jest.Mock).mockResolvedValue('test-org-key-2'); - const k = await getOrGenerateKey('anonymous', 'org', basicPlatform); + const k = await getOrGenerateKey('LaunchDarkly_AnonymousKeys_org', basicPlatform); expect(crypto.randomUUID).not.toHaveBeenCalled(); expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); @@ -43,7 +65,7 @@ describe('getOrGenerateKey', () => { describe('context namespace', () => { test('context key does not exist so should be generated', async () => { (storage.get as jest.Mock).mockResolvedValue(undefined); - const k = await getOrGenerateKey('context', 'org', basicPlatform); + const k = await getOrGenerateKey('LaunchDarkly_ContextKeys_org', basicPlatform); expect(crypto.randomUUID).toHaveBeenCalled(); expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org'); @@ -53,7 +75,7 @@ describe('getOrGenerateKey', () => { test('context key exists so should not be generated', async () => { (storage.get as jest.Mock).mockResolvedValue('test-org-key-2'); - const k = await getOrGenerateKey('context', 'org', basicPlatform); + const k = await getOrGenerateKey('LaunchDarkly_ContextKeys_org', basicPlatform); expect(crypto.randomUUID).not.toHaveBeenCalled(); expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org'); @@ -61,11 +83,4 @@ describe('getOrGenerateKey', () => { expect(k).toEqual('test-org-key-2'); }); }); - - test('unsupported namespace', async () => { - // @ts-ignore - await expect(getOrGenerateKey('wrongNamespace', 'org', basicPlatform)).rejects.toThrow( - /unsupported/i, - ); - }); }); diff --git a/packages/shared/sdk-client/src/storage/getOrGenerateKey.ts b/packages/shared/sdk-client/src/storage/getOrGenerateKey.ts new file mode 100644 index 0000000000..f193f99c01 --- /dev/null +++ b/packages/shared/sdk-client/src/storage/getOrGenerateKey.ts @@ -0,0 +1,23 @@ +import { Platform } from '@launchdarkly/js-sdk-common'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { namespaceForGeneratedContextKey } from './namespaceUtils'; + +/** + * This function will retrieve a previously generated key for the given {@link storageKey} if it + * exists or generate and store one on the fly if it does not already exist. + * @param storageKey keyed storage location where the generated key should live. See {@link namespaceForGeneratedContextKey} + * for related exmaples of generating a storage key and usage. + * @param platform crypto and storage implementations for necessary operations + * @returns the generated key + */ +export const getOrGenerateKey = async (storageKey: string, { crypto, storage }: Platform) => { + let generatedKey = await storage?.get(storageKey); + + if (!generatedKey) { + generatedKey = crypto.randomUUID(); + await storage?.set(storageKey, generatedKey); + } + + return generatedKey; +}; diff --git a/packages/shared/sdk-client/src/storage/namespaceUtils.test.ts b/packages/shared/sdk-client/src/storage/namespaceUtils.test.ts new file mode 100644 index 0000000000..73058eeb0d --- /dev/null +++ b/packages/shared/sdk-client/src/storage/namespaceUtils.test.ts @@ -0,0 +1,38 @@ +import { concatNamespacesAndValues } from './namespaceUtils'; + +const mockHash = (input: string) => `${input}Hashed`; +const noop = (input: string) => input; + +describe('concatNamespacesAndValues tests', () => { + test('it handles one part', async () => { + const result = concatNamespacesAndValues([{ value: 'LaunchDarkly', transform: mockHash }]); + + expect(result).toEqual('LaunchDarklyHashed'); + }); + + test('it handles empty parts', async () => { + const result = concatNamespacesAndValues([]); + + expect(result).toEqual(''); + }); + + test('it handles many parts', async () => { + const result = concatNamespacesAndValues([ + { value: 'LaunchDarkly', transform: mockHash }, + { value: 'ContextKeys', transform: mockHash }, + { value: 'aKind', transform: mockHash }, + ]); + + expect(result).toEqual('LaunchDarklyHashed_ContextKeysHashed_aKindHashed'); + }); + + test('it handles mixture of hashing and no hashing', async () => { + const result = concatNamespacesAndValues([ + { value: 'LaunchDarkly', transform: mockHash }, + { value: 'ContextKeys', transform: noop }, + { value: 'aKind', transform: mockHash }, + ]); + + expect(result).toEqual('LaunchDarklyHashed_ContextKeys_aKindHashed'); + }); +}); diff --git a/packages/shared/sdk-client/src/storage/namespaceUtils.ts b/packages/shared/sdk-client/src/storage/namespaceUtils.ts new file mode 100644 index 0000000000..c977bf18ac --- /dev/null +++ b/packages/shared/sdk-client/src/storage/namespaceUtils.ts @@ -0,0 +1,68 @@ +import { Context, Crypto } from '@launchdarkly/js-sdk-common'; + +export type Namespace = 'LaunchDarkly' | 'AnonymousKeys' | 'ContextKeys' | 'ContextIndex'; + +/** + * Hashes the input and encodes it as base64 + */ +function hashAndBase64Encode(crypto: Crypto): (input: string) => string { + return (input) => crypto.createHash('sha256').update(input).digest('base64'); +} + +const noop = (input: string) => input; // no-op transform + +export function concatNamespacesAndValues( + parts: { value: Namespace | string; transform: (value: string) => string }[], +): string { + const processedParts = parts.map((part) => part.transform(part.value)); // use the transform from each part to transform the value + return processedParts.join('_'); +} + +export function namespaceForEnvironment(crypto: Crypto, sdkKey: string): string { + return concatNamespacesAndValues([ + { value: 'LaunchDarkly', transform: noop }, + { value: sdkKey, transform: hashAndBase64Encode(crypto) }, // hash sdk key and encode it + ]); +} + +/** + * @deprecated prefer {@link namespaceForGeneratedContextKey}. At one time we only generated keys for + * anonymous contexts and they were namespaced in LaunchDarkly_AnonymousKeys. Eventually we started + * generating context keys for non-anonymous contexts such as for the Auto Environment Attributes + * feature and those were namespaced in LaunchDarkly_ContextKeys. This function can be removed + * when the data under the LaunchDarkly_AnonymousKeys namespace is merged with data under the + * LaunchDarkly_ContextKeys namespace. + */ +export function namespaceForAnonymousGeneratedContextKey(kind: string): string { + return concatNamespacesAndValues([ + { value: 'LaunchDarkly', transform: noop }, + { value: 'AnonymousKeys', transform: noop }, + { value: kind, transform: noop }, // existing SDKs are not hashing or encoding this kind, though they should have + ]); +} + +export function namespaceForGeneratedContextKey(kind: string): string { + return concatNamespacesAndValues([ + { value: 'LaunchDarkly', transform: noop }, + { value: 'ContextKeys', transform: noop }, + { value: kind, transform: noop }, // existing SDKs are not hashing or encoding this kind, though they should have + ]); +} + +export function namespaceForContextIndex(environmentNamespace: string): string { + return concatNamespacesAndValues([ + { value: environmentNamespace, transform: noop }, + { value: 'ContextIndex', transform: noop }, + ]); +} + +export function namespaceForContextData( + crypto: Crypto, + environmentNamespace: string, + context: Context, +): string { + return concatNamespacesAndValues([ + { value: environmentNamespace, transform: noop }, // use existing namespace as is, don't transform + { value: context.canonicalKey, transform: hashAndBase64Encode(crypto) }, // hash and encode canonical key + ]); +} diff --git a/packages/shared/sdk-client/src/utils/calculateFlagChanges.ts b/packages/shared/sdk-client/src/utils/calculateFlagChanges.ts deleted file mode 100644 index 9b77370d36..0000000000 --- a/packages/shared/sdk-client/src/utils/calculateFlagChanges.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { fastDeepEqual } from '@launchdarkly/js-sdk-common'; - -import { Flags } from '../types'; - -// eslint-disable-next-line import/prefer-default-export -export default function calculateFlagChanges(flags: Flags, incomingFlags: Flags) { - const changedKeys: string[] = []; - - // flag deleted or updated - Object.entries(flags).forEach(([k, f]) => { - const incoming = incomingFlags[k]; - if (!incoming || !fastDeepEqual(f, incoming)) { - changedKeys.push(k); - } - }); - - // flag added - Object.keys(incomingFlags).forEach((k) => { - if (!flags[k]) { - changedKeys.push(k); - } - }); - - return changedKeys; -} diff --git a/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts b/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts deleted file mode 100644 index df49fcc8ec..0000000000 --- a/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Platform } from '@launchdarkly/js-sdk-common'; - -export type Namespace = 'anonymous' | 'context'; - -export const prefixNamespace = (namespace: Namespace, s: string) => { - let n: string; - - switch (namespace) { - case 'anonymous': - n = 'LaunchDarkly_AnonymousKeys_'; - break; - case 'context': - n = 'LaunchDarkly_ContextKeys_'; - break; - default: - throw new Error( - `Unsupported namespace ${namespace}. Only 'anonymous' or 'context' are supported.`, - ); - } - - return `${n}${s}`; -}; - -export const getOrGenerateKey = async ( - namespace: Namespace, - contextKind: string, - { crypto, storage }: Platform, -) => { - const storageKey = prefixNamespace(namespace, contextKind); - let contextKey = await storage?.get(storageKey); - - if (!contextKey) { - contextKey = crypto.randomUUID(); - await storage?.set(storageKey, contextKey); - } - - return contextKey; -}; diff --git a/packages/shared/sdk-client/src/utils/index.ts b/packages/shared/sdk-client/src/utils/index.ts deleted file mode 100644 index c16f6e1b1d..0000000000 --- a/packages/shared/sdk-client/src/utils/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { addAutoEnv } from './addAutoEnv'; -import calculateFlagChanges from './calculateFlagChanges'; -import ensureKey from './ensureKey'; - -export { calculateFlagChanges, ensureKey, addAutoEnv };