diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts index 8b1668bcb7..5b3a45b014 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts @@ -250,7 +250,7 @@ describe('sdk-client storage', () => { await jest.runAllTimersAsync(); expect(ldc.allFlags()).not.toHaveProperty('dev-test-flag'); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(3); expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( 1, indexStorageKey, @@ -291,7 +291,7 @@ describe('sdk-client storage', () => { await jest.runAllTimersAsync(); expect(ldc.allFlags()).toMatchObject({ 'another-dev-test-flag': false }); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(3); expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( 1, indexStorageKey, @@ -373,7 +373,7 @@ describe('sdk-client storage', () => { await ldc.identify(context); await jest.runAllTimersAsync(); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(3); expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( 1, indexStorageKey, @@ -421,7 +421,11 @@ describe('sdk-client storage', () => { await changePromise; await jest.runAllTimersAsync(); - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + const flagsInStorage = JSON.parse( + mockPlatform.storage.set.mock.calls + .filter(([key]: [string]) => key === flagStorageKey) + .pop()![1], + ) as Flags; expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': true }); expect(flagsInStorage['dev-test-flag'].reason).toEqual({ kind: 'RULE_MATCH', @@ -455,9 +459,13 @@ describe('sdk-client storage', () => { await changePromise; await jest.runAllTimersAsync(); - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + const flagsInStorage = JSON.parse( + mockPlatform.storage.set.mock.calls + .filter(([key]: [string]) => key === flagStorageKey) + .pop()![1], + ) as Flags; expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': false }); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(4); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(6); expect(flagsInStorage['dev-test-flag'].version).toEqual(patchResponse.version); expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); }); @@ -482,13 +490,12 @@ describe('sdk-client storage', () => { await changePromise; await jest.runAllTimersAsync(); - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + const flagsInStorage = JSON.parse( + mockPlatform.storage.set.mock.calls + .filter(([key]: [string]) => key === flagStorageKey) + .pop()![1], + ) as Flags; expect(ldc.allFlags()).toHaveProperty('another-dev-test-flag'); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 4, - flagStorageKey, - expect.stringContaining(JSON.stringify(patchResponse)), - ); expect(flagsInStorage).toHaveProperty('another-dev-test-flag'); expect(emitter.emit).toHaveBeenCalledWith('change', context, ['another-dev-test-flag']); }); @@ -516,7 +523,7 @@ describe('sdk-client storage', () => { await jest.runAllTimersAsync(); // the initial put is resulting in two sets, one for the index and one for the flag data - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(3); expect(emitter.emit).not.toHaveBeenCalledWith('change'); // this is defaultPutResponse @@ -556,13 +563,12 @@ describe('sdk-client storage', () => { await changePromise; await jest.runAllTimersAsync(); - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + const flagsInStorage = JSON.parse( + mockPlatform.storage.set.mock.calls + .filter(([key]: [string]) => key === flagStorageKey) + .pop()![1], + ) as Flags; expect(ldc.allFlags()).not.toHaveProperty('dev-test-flag'); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 4, - flagStorageKey, - expect.stringContaining('dev-test-flag'), - ); expect(flagsInStorage['dev-test-flag']).toMatchObject({ ...deleteResponse, deleted: true }); expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); }); @@ -591,7 +597,7 @@ describe('sdk-client storage', () => { expect(ldc.allFlags()).toHaveProperty('dev-test-flag'); // the initial put is resulting in two sets, one for the index and one for the flag data - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(3); expect(emitter.emit).not.toHaveBeenCalledWith('change'); }); @@ -619,7 +625,7 @@ describe('sdk-client storage', () => { expect(ldc.allFlags()).toHaveProperty('dev-test-flag'); // the initial put is resulting in two sets, one for the index and one for the flag data - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(3); expect(emitter.emit).not.toHaveBeenCalledWith('change'); }); @@ -645,9 +651,13 @@ describe('sdk-client storage', () => { await changePromise; await jest.runAllTimersAsync(); - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + const flagsInStorage = JSON.parse( + mockPlatform.storage.set.mock.calls + .filter(([key]: [string]) => key === flagStorageKey) + .pop()![1], + ) as Flags; - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(4); // two index saves and two flag saves + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(6); // 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/__tests__/datasource/fdv2/CacheInitializer.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/CacheInitializer.test.ts new file mode 100644 index 0000000000..9cf6a72456 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/CacheInitializer.test.ts @@ -0,0 +1,337 @@ +import { Context, Crypto, Storage } from '@launchdarkly/js-sdk-common'; + +import { createCacheInitializerFactory } from '../../../src/datasource/fdv2/CacheInitializer'; +import { ChangeSetResult } from '../../../src/datasource/fdv2/FDv2SourceResult'; +import { Initializer } from '../../../src/datasource/fdv2/Initializer'; +import { FRESHNESS_SUFFIX, hashContext } from '../../../src/storage/freshness'; +import { namespaceForContextData } from '../../../src/storage/namespaceUtils'; +import { Flag, Flags } from '../../../src/types'; +import { + makeMemoryStorage, + makeMockCrypto, + makeMockFlag, + makeMockLogger, +} from '../../flag-manager/flagManagerTestHelpers'; + +const TEST_NAMESPACE = 'TestNamespace'; +const noSelector = () => undefined; + +async function storeFlagsForContext( + storage: Storage, + crypto: Crypto, + context: Context, + flags: Flags, +) { + const storageKey = await namespaceForContextData(crypto, TEST_NAMESPACE, context); + await storage.set(storageKey, JSON.stringify(flags)); +} + +function createInitializer( + storage: Storage | undefined, + crypto: Crypto, + context: Context, + logger?: ReturnType, +): Initializer { + const factory = createCacheInitializerFactory({ + storage, + crypto, + environmentNamespace: TEST_NAMESPACE, + context, + logger, + }); + return factory(noSelector); +} + +describe('CacheInitializer', () => { + let crypto: Crypto; + let context: Context; + + beforeEach(() => { + crypto = makeMockCrypto(); + context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + }); + + it('returns a changeSet with cached flags when cache is present', async () => { + const storage = makeMemoryStorage(); + const flags: Flags = { + flag1: makeMockFlag(1, 'value1'), + flag2: makeMockFlag(2, 'value2'), + }; + await storeFlagsForContext(storage, crypto, context, flags); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + if (result.type === 'changeSet') { + expect(result.payload.updates).toHaveLength(2); + + const flag1Update = result.payload.updates.find((u) => u.key === 'flag1'); + expect(flag1Update).toBeDefined(); + expect(flag1Update!.kind).toBe('flagEval'); + expect(flag1Update!.version).toBe(1); + + const flag2Update = result.payload.updates.find((u) => u.key === 'flag2'); + expect(flag2Update).toBeDefined(); + expect(flag2Update!.kind).toBe('flagEval'); + expect(flag2Update!.version).toBe(2); + } + }); + + it('returns a payload with no state field (no selector)', async () => { + const storage = makeMemoryStorage(); + await storeFlagsForContext(storage, crypto, context, { flag1: makeMockFlag() }); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + if (result.type === 'changeSet') { + expect(result.payload.state).toBeUndefined(); + } + }); + + it('returns a payload with type full', async () => { + const storage = makeMemoryStorage(); + await storeFlagsForContext(storage, crypto, context, { flag1: makeMockFlag() }); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + if (result.type === 'changeSet') { + expect(result.payload.type).toBe('full'); + } + }); + + it('does not set fdv1Fallback', async () => { + const storage = makeMemoryStorage(); + await storeFlagsForContext(storage, crypto, context, { flag1: makeMockFlag() }); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.fdv1Fallback).toBe(false); + }); + + it('strips version from flag when constructing the evaluation result object', async () => { + const storage = makeMemoryStorage(); + const flag = makeMockFlag(5, 'hello'); + await storeFlagsForContext(storage, crypto, context, { myFlag: flag }); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + if (result.type === 'changeSet') { + const update = result.payload.updates[0]; + expect(update.version).toBe(5); + // The object should NOT have a 'version' field — it's a FlagEvaluationResult + expect(update.object).not.toHaveProperty('version'); + expect(update.object.value).toBe('hello'); + expect(update.object.flagVersion).toBe(5); + expect(update.object.variation).toBe(0); + expect(update.object.trackEvents).toBe(false); + } + }); + + it('returns interrupted on cache miss', async () => { + const storage = makeMemoryStorage(); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.type).toBe('status'); + if (result.type === 'status') { + expect(result.state).toBe('interrupted'); + } + }); + + it('returns interrupted when storage is undefined', async () => { + const logger = makeMockLogger(); + const initializer = createInitializer(undefined, crypto, context, logger); + const result = await initializer.run(); + + expect(result.type).toBe('status'); + if (result.type === 'status') { + expect(result.state).toBe('interrupted'); + } + expect(logger.debug).toHaveBeenCalledWith('No storage available for cache initializer'); + }); + + it('returns interrupted on corrupt JSON (treated as cache miss)', async () => { + const storage = makeMemoryStorage(); + const storageKey = await namespaceForContextData(crypto, TEST_NAMESPACE, context); + await storage.set(storageKey, 'not valid json!!!'); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.type).toBe('status'); + if (result.type === 'status') { + expect(result.state).toBe('interrupted'); + } + }); + + it('returns shutdown when close is called before cache loads', async () => { + // Create a storage that delays responses + let resolveGet: ((value: string | null) => void) | undefined; + const slowStorage: Storage = { + get: () => + new Promise((resolve) => { + resolveGet = resolve; + }), + set: async () => {}, + clear: async () => {}, + }; + + const initializer = createInitializer(slowStorage, crypto, context); + const runPromise = initializer.run(); + + // Close before the storage responds + initializer.close(); + + const result = await runPromise; + expect(result.type).toBe('status'); + if (result.type === 'status') { + expect(result.state).toBe('shutdown'); + } + + // Resolve the pending get to avoid dangling promise + resolveGet?.(null); + }); + + it('finds data at legacy canonical key', async () => { + const storage = makeMemoryStorage(); + const flags: Flags = { legacyFlag: makeMockFlag(3, 'legacy-value') }; + + // Store under canonical key (pre-10.3.1 location) + await storage.set(context.canonicalKey, JSON.stringify(flags)); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + if (result.type === 'changeSet') { + expect(result.payload.updates).toHaveLength(1); + expect(result.payload.updates[0].key).toBe('legacyFlag'); + expect(result.payload.updates[0].version).toBe(3); + } + }); + + it('ignores selectorGetter parameter', async () => { + const storage = makeMemoryStorage(); + await storeFlagsForContext(storage, crypto, context, { flag1: makeMockFlag() }); + + const selectorGetter = jest.fn(() => 'some-selector'); + const factory = createCacheInitializerFactory({ + storage, + crypto, + environmentNamespace: TEST_NAMESPACE, + context, + }); + + const initializer = factory(selectorGetter); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + // selectorGetter should never have been called + expect(selectorGetter).not.toHaveBeenCalled(); + }); + + it('handles empty flag set in cache', async () => { + const storage = makeMemoryStorage(); + await storeFlagsForContext(storage, crypto, context, {}); + + const initializer = createInitializer(storage, crypto, context); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + if (result.type === 'changeSet') { + expect(result.payload.updates).toHaveLength(0); + expect(result.payload.type).toBe('full'); + } + }); + + it('preserves all flag fields in the evaluation result object', async () => { + const storage = makeMemoryStorage(); + const flag: Flag = { + version: 1, + flagVersion: 10, + value: { complex: 'value' }, + variation: 2, + trackEvents: true, + trackReason: true, + reason: { kind: 'OFF' }, + debugEventsUntilDate: 999, + prerequisites: ['other-flag'], + }; + await storeFlagsForContext(storage, crypto, context, { complexFlag: flag }); + + const initializer = createInitializer(storage, crypto, context); + const result = (await initializer.run()) as { type: 'changeSet'; payload: any }; + const obj = result.payload.updates[0].object; + + expect(obj.flagVersion).toBe(10); + expect(obj.value).toEqual({ complex: 'value' }); + expect(obj.variation).toBe(2); + expect(obj.trackEvents).toBe(true); + expect(obj.trackReason).toBe(true); + expect(obj.reason).toEqual({ kind: 'OFF' }); + expect(obj.debugEventsUntilDate).toBe(999); + expect(obj.prerequisites).toEqual(['other-flag']); + expect(obj).not.toHaveProperty('version'); + }); + + it('includes freshness timestamp when freshness record exists', async () => { + const storage = makeMemoryStorage(); + await storeFlagsForContext(storage, crypto, context, { flag1: makeMockFlag() }); + + // Write a freshness record + const storageKey = await namespaceForContextData(crypto, TEST_NAMESPACE, context); + const contextHash = await hashContext(crypto, context); + await storage.set( + `${storageKey}${FRESHNESS_SUFFIX}`, + JSON.stringify({ timestamp: 88000, contextHash }), + ); + + const initializer = createInitializer(storage, crypto, context); + const result = (await initializer.run()) as ChangeSetResult; + + expect(result.type).toBe('changeSet'); + expect(result.freshness).toBe(88000); + }); + + it('returns undefined freshness when no freshness record exists', async () => { + const storage = makeMemoryStorage(); + await storeFlagsForContext(storage, crypto, context, { flag1: makeMockFlag() }); + + const initializer = createInitializer(storage, crypto, context); + const result = (await initializer.run()) as ChangeSetResult; + + expect(result.type).toBe('changeSet'); + expect(result.freshness).toBeUndefined(); + }); + + it('returns undefined freshness when context attributes changed', async () => { + const storage = makeMemoryStorage(); + const contextV1 = Context.fromLDContext({ kind: 'user', key: 'test-user', name: 'Alice' }); + await storeFlagsForContext(storage, crypto, contextV1, { flag1: makeMockFlag() }); + + // Write freshness for contextV1 + const storageKey = await namespaceForContextData(crypto, TEST_NAMESPACE, contextV1); + const v1Hash = await hashContext(crypto, contextV1); + await storage.set( + `${storageKey}${FRESHNESS_SUFFIX}`, + JSON.stringify({ timestamp: 88000, contextHash: v1Hash }), + ); + + // Load with same key but different attributes + const contextV2 = Context.fromLDContext({ kind: 'user', key: 'test-user', name: 'Bob' }); + const initializer = createInitializer(storage, crypto, contextV2); + const result = (await initializer.run()) as ChangeSetResult; + + expect(result.type).toBe('changeSet'); + expect(result.freshness).toBeUndefined(); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/calculatePollDelay.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/calculatePollDelay.test.ts new file mode 100644 index 0000000000..79c3ef0b29 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/calculatePollDelay.test.ts @@ -0,0 +1,35 @@ +import { calculatePollDelay } from '../../../src/datasource/fdv2/calculatePollDelay'; + +describe('calculatePollDelay', () => { + it('returns 0 when freshness is undefined (poll immediately)', () => { + expect(calculatePollDelay(undefined, 60000, 1000)).toBe(0); + }); + + it('returns remaining interval when data was recently received', () => { + // Freshness at 1000, now at 1500, interval 2000 → 1500 remaining + expect(calculatePollDelay(1000, 2000, 1500)).toBe(1500); + }); + + it('returns 0 when data is stale beyond poll interval', () => { + // Freshness at 1000, now at 5000, interval 2000 → 0 (stale) + expect(calculatePollDelay(1000, 2000, 5000)).toBe(0); + }); + + it('returns 0 when elapsed time exactly matches interval', () => { + expect(calculatePollDelay(1000, 2000, 3000)).toBe(0); + }); + + it('returns full interval when freshness equals now', () => { + expect(calculatePollDelay(5000, 60000, 5000)).toBe(60000); + }); + + it('clamps to poll interval when freshness is in the future', () => { + // Freshness at 10000, now at 5000 (clock skew) → clamp to interval + expect(calculatePollDelay(10000, 2000, 5000)).toBe(2000); + }); + + it('clamps to poll interval when freshness is slightly in the future', () => { + // Freshness 1ms ahead of now + expect(calculatePollDelay(5001, 60000, 5000)).toBe(60000); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts index d8fe440004..c0bc009504 100644 --- a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts @@ -1,92 +1,18 @@ -import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common'; +import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import DefaultFlagManager from '../../src/flag-manager/FlagManager'; import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater'; -import { ItemDescriptor } from '../../src/flag-manager/ItemDescriptor'; -import { Flag } from '../../src/types'; +import { + makeMemoryStorage, + makeMockCrypto, + makeMockItemDescriptor, + makeMockLogger, + makeMockPlatform, +} from './flagManagerTestHelpers'; const TEST_SDK_KEY = 'test-sdk-key'; const TEST_MAX_CACHED_CONTEXTS = 5; -function makeMockPlatform(storage: Storage, crypto: Crypto): Platform { - return { - storage, - crypto, - info: { - platformData: jest.fn(), - sdkData: jest.fn(), - }, - requests: { - fetch: jest.fn(), - createEventSource: jest.fn(), - getEventSourceCapabilities: jest.fn(), - }, - }; -} - -function makeMemoryStorage(): Storage { - const data = new Map(); - return { - get: async (key: string) => { - const value = data.get(key); - return value !== undefined ? value : null; - }, - 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; - return `${counter}`; - }), - }; -} - -function makeMockLogger(): LDLogger { - return { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - debug: jest.fn(), - }; -} - -function makeMockFlag(version: number = 1, value: any = 'test-value'): Flag { - return { - version, - flagVersion: version, - value, - variation: 0, - trackEvents: false, - }; -} - -function makeMockItemDescriptor(version: number = 1, value: any = 'test-value'): ItemDescriptor { - return { - version, - flag: makeMockFlag(version, value), - }; -} - describe('FlagManager override tests', () => { let flagManager: DefaultFlagManager; let mockPlatform: Platform; diff --git a/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts index 2a509f4e1a..542ae23291 100644 --- a/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ -import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common'; +import { Context } from '@launchdarkly/js-sdk-common'; import FlagPersistence from '../../src/flag-manager/FlagPersistence'; import { createDefaultFlagStore } from '../../src/flag-manager/FlagStore'; @@ -8,7 +7,16 @@ import { namespaceForContextData, namespaceForContextIndex, } from '../../src/storage/namespaceUtils'; -import { Flag, Flags } from '../../src/types'; +import { Flags } from '../../src/types'; +import { + makeCorruptStorage, + makeIncrementingStamper, + makeMemoryStorage, + makeMockCrypto, + makeMockFlag, + makeMockLogger, + makeMockPlatform, +} from './flagManagerTestHelpers'; const TEST_NAMESPACE = 'TestNamespace'; @@ -34,10 +42,7 @@ describe('FlagPersistence tests', () => { const flagStore = createDefaultFlagStore(); const mockLogger = makeMockLogger(); const fpUnderTest = new FlagPersistence( - makeMockPlatform( - makeCorruptStorage(), // storage that corrupts data - makeMockCrypto(), - ), + makeMockPlatform(makeCorruptStorage(), makeMockCrypto()), TEST_NAMESPACE, 5, flagStore, @@ -102,7 +107,6 @@ describe('FlagPersistence tests', () => { const context = Context.fromLDContext({ kind: 'org', key: 'TestyPizza' }); - // put mock old flags into the storage const mockOldFlags: Flags = { flagA: makeMockFlag(), }; @@ -111,7 +115,6 @@ describe('FlagPersistence tests', () => { 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(); }); @@ -161,7 +164,7 @@ describe('FlagPersistence tests', () => { const fpUnderTest = new FlagPersistence( mockPlatform, TEST_NAMESPACE, - 1, // max of 1 for this test + 1, flagStore, flagUpdater, mockLogger, @@ -198,6 +201,38 @@ describe('FlagPersistence tests', () => { expect(await memoryStorage.get(context2DataKey)).toContain('flagA'); }); + test('init prunes freshness keys alongside cached contexts', async () => { + const memoryStorage = makeMemoryStorage(); + const crypto = makeMockCrypto(); + const mockPlatform = makeMockPlatform(memoryStorage, crypto); + const flagStore = createDefaultFlagStore(); + const mockLogger = makeMockLogger(); + const flagUpdater = createFlagUpdater(flagStore, mockLogger); + + const fpUnderTest = new FlagPersistence( + mockPlatform, + TEST_NAMESPACE, + 1, + 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); + + const context1DataKey = await namespaceForContextData(crypto, TEST_NAMESPACE, context1); + expect(await memoryStorage.get(`${context1DataKey}_freshness`)).not.toBeNull(); + + await fpUnderTest.init(context2, flags); + + expect(await memoryStorage.get(context1DataKey)).toBeNull(); + expect(await memoryStorage.get(`${context1DataKey}_freshness`)).toBeNull(); + }); + test('init kicks timestamp', async () => { const memoryStorage = makeMemoryStorage(); const mockPlatform = makeMockPlatform(memoryStorage, makeMockCrypto()); @@ -266,7 +301,6 @@ describe('FlagPersistence tests', () => { context, ); - // check memory flag store and persistence expect(flagStore.get('flagA')?.version).toEqual(2); expect(await memoryStorage.get(contextDataKey)).toContain('"version":2'); }); @@ -318,102 +352,31 @@ describe('FlagPersistence tests', () => { }); }); -function makeMockPlatform(storage: Storage, crypto: Crypto): Platform { - return { - storage, - crypto, - info: { - platformData: jest.fn(), - sdkData: jest.fn(), - }, - requests: { - fetch: jest.fn(), - createEventSource: jest.fn(), - getEventSourceCapabilities: 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; - }; -} +describe('FlagPersistence freshness', () => { + test('init stores freshness record to storage', async () => { + const memoryStorage = makeMemoryStorage(); + const crypto = makeMockCrypto(); + const flagStore = createDefaultFlagStore(); + const mockLogger = makeMockLogger(); + + const fpUnderTest = new FlagPersistence( + makeMockPlatform(memoryStorage, crypto), + TEST_NAMESPACE, + 5, + flagStore, + createFlagUpdater(flagStore, mockLogger), + mockLogger, + () => 42000, + ); + + const context = Context.fromLDContext({ kind: 'user', key: 'test' }); + await fpUnderTest.init(context, { flagA: { version: 1, flag: makeMockFlag() } }); + + const contextDataKey = await namespaceForContextData(crypto, TEST_NAMESPACE, context); + const freshnessJson = await memoryStorage.get(`${contextDataKey}_freshness`); + expect(freshnessJson).not.toBeNull(); + const record = JSON.parse(freshnessJson!); + expect(record.timestamp).toBe(42000); + expect(record.contextHash).toBeDefined(); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/flag-manager/flagManagerTestHelpers.ts b/packages/shared/sdk-client/__tests__/flag-manager/flagManagerTestHelpers.ts new file mode 100644 index 0000000000..7c31beb46f --- /dev/null +++ b/packages/shared/sdk-client/__tests__/flag-manager/flagManagerTestHelpers.ts @@ -0,0 +1,110 @@ +import { Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common'; + +import { ItemDescriptor } from '../../src/flag-manager/ItemDescriptor'; +import { Flag } from '../../src/types'; + +export function makeMockPlatform(storage: Storage, crypto: Crypto): Platform { + return { + storage, + crypto, + info: { + platformData: jest.fn(), + sdkData: jest.fn(), + }, + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + }; +} + +export function makeMemoryStorage(): Storage { + const data = new Map(); + return { + get: async (key: string) => { + const value = data.get(key); + return value !== undefined ? value : null; + }, + set: async (key: string, value: string) => { + data.set(key, value); + }, + clear: async (key: string) => { + data.delete(key); + }, + }; +} + +export function makeCorruptStorage(): Storage { + const data = new Map(); + return { + get: async (key: string) => { + const value = data.get(key); + return value !== undefined ? 'corruption!!!!!' : null; + }, + set: async (key: string, value: string) => { + data.set(key, value); + }, + clear: async (key: string) => { + data.delete(key); + }, + }; +} + +export function makeMockCrypto(): Crypto { + 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; + return `${counter}`; + }), + }; +} + +export function makeMockLogger(): LDLogger { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +} + +export function makeMockFlag(version: number = 1, value: any = undefined): Flag { + return { + version, + flagVersion: version, + value, + variation: 0, + trackEvents: false, + }; +} + +export function makeMockItemDescriptor( + version: number = 1, + value: any = undefined, +): ItemDescriptor { + return { + version, + flag: makeMockFlag(version, value), + }; +} + +export function makeIncrementingStamper(): () => number { + let count = 0; + return () => { + count += 1; + return count; + }; +} diff --git a/packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts b/packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts new file mode 100644 index 0000000000..7030d26d3b --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts @@ -0,0 +1,118 @@ +import { Context, Crypto, internal, LDLogger, Storage } from '@launchdarkly/js-sdk-common'; + +import { readFreshness } from '../../storage/freshness'; +import { loadCachedFlags } from '../../storage/loadCachedFlags'; +import { Flag } from '../../types'; +import { + changeSet, + errorInfoFromUnknown, + FDv2SourceResult, + interrupted, + shutdown, +} from './FDv2SourceResult'; +import { Initializer } from './Initializer'; +import { InitializerFactory } from './SourceManager'; + +/** + * Configuration for creating a cache initializer. + */ +export interface CacheInitializerConfig { + /** Platform storage for reading cached data. */ + storage: Storage | undefined; + /** Platform crypto for computing storage keys. */ + crypto: Crypto; + /** Environment namespace (hashed SDK key). */ + environmentNamespace: string; + /** The context to load cached data for. */ + context: Context; + /** Optional logger. */ + logger?: LDLogger; +} + +/** + * Strips the `version` field from a stored {@link Flag} to produce the + * `FlagEvaluationResult` shape expected in an FDv2 `Update.object`. + * + * The version is carried on the `Update` envelope, not on the object itself. + */ +function flagToEvaluationResult(flag: Flag): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { version, ...evalResult } = flag; + return evalResult; +} + +/** + * Reads cached flag data and freshness from platform storage and returns + * them as an {@link FDv2SourceResult}. + */ +async function loadFromCache(config: CacheInitializerConfig): Promise { + const { storage, crypto, environmentNamespace, context, logger } = config; + + if (!storage) { + logger?.debug('No storage available for cache initializer'); + return interrupted(errorInfoFromUnknown('No storage available'), false); + } + + const cached = await loadCachedFlags(storage, crypto, environmentNamespace, context, logger); + if (!cached) { + logger?.debug('Cache miss for context'); + return interrupted(errorInfoFromUnknown('Cache miss'), false); + } + + const updates: internal.Update[] = Object.entries(cached.flags).map( + ([key, flag]): internal.Update => ({ + kind: 'flagEval', + key, + version: flag.version, + object: flagToEvaluationResult(flag), + }), + ); + + const payload: internal.Payload = { + id: 'cache', + version: 0, + // No `state` field. The orchestrator sees a changeSet without a selector, + // records dataReceived=true, and continues to the next initializer. + type: 'full', + updates, + }; + + const freshness = await readFreshness(storage, crypto, environmentNamespace, context, logger); + + logger?.debug('Loaded cached flag evaluations via cache initializer'); + return changeSet(payload, false, undefined, freshness); +} + +/** + * Creates an {@link InitializerFactory} that produces cache initializers. + * + * The cache initializer reads flag data and freshness from persistent storage + * for the given context and returns them as a changeSet without a selector. + * This allows the orchestrator to provide cached data immediately while + * continuing to the next initializer for network-verified data. + * + * Per spec Requirement 4.1.2, the payload has `persist=false` semantics + * (no selector) so the consuming layer should not re-persist it. + * + * @internal + */ +export function createCacheInitializerFactory(config: CacheInitializerConfig): InitializerFactory { + // The selectorGetter is ignored — cache data has no selector. + return (_selectorGetter: () => string | undefined): Initializer => { + let shutdownResolve: ((result: FDv2SourceResult) => void) | undefined; + const shutdownPromise = new Promise((resolve) => { + shutdownResolve = resolve; + }); + + return { + async run(): Promise { + return Promise.race([shutdownPromise, loadFromCache(config)]); + }, + + close(): void { + shutdownResolve?.(shutdown()); + shutdownResolve = undefined; + }, + }; + }; +} diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts index 0f7e8c9326..7b62e3b424 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts @@ -20,6 +20,8 @@ export interface ChangeSetResult { payload: internal.Payload; fdv1Fallback: boolean; environmentId?: string; + /** Freshness timestamp from cache, if this result originated from cached data. */ + freshness?: number; } /** @@ -50,8 +52,9 @@ export function changeSet( payload: internal.Payload, fdv1Fallback: boolean, environmentId?: string, + freshness?: number, ): FDv2SourceResult { - return { type: 'changeSet', payload, fdv1Fallback, environmentId }; + return { type: 'changeSet', payload, fdv1Fallback, environmentId, freshness }; } /** diff --git a/packages/shared/sdk-client/src/datasource/fdv2/calculatePollDelay.ts b/packages/shared/sdk-client/src/datasource/fdv2/calculatePollDelay.ts new file mode 100644 index 0000000000..e392c836be --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/fdv2/calculatePollDelay.ts @@ -0,0 +1,29 @@ +/** + * Calculates how long to wait before the next poll, based on when data was + * last received (the freshness timestamp). + * + * - If `freshness` is `undefined` (no data, cache miss, or attribute change), + * returns 0 — poll immediately (per Req 5.2.4). + * - Otherwise returns `max(0, pollIntervalMs - (now - freshness))`. + * + * @param freshness Timestamp (ms since epoch) when data was last received, + * or `undefined` if stale/unknown. + * @param pollIntervalMs The configured polling interval in milliseconds. + * @param now The current time in milliseconds since epoch. + * @returns The number of milliseconds to wait before the next poll. + * + * @internal + */ +export function calculatePollDelay( + freshness: number | undefined, + pollIntervalMs: number, + now: number, +): number { + if (freshness === undefined) { + return 0; + } + const elapsed = now - freshness; + // Clamp to [0, pollIntervalMs] to guard against future timestamps + // (e.g., clock skew or corrupt data). + return Math.max(0, Math.min(pollIntervalMs, pollIntervalMs - elapsed)); +} diff --git a/packages/shared/sdk-client/src/datasource/fdv2/index.ts b/packages/shared/sdk-client/src/datasource/fdv2/index.ts index 78879c5ff9..52805ee3d8 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/index.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/index.ts @@ -1,6 +1,9 @@ export type { AsyncQueue } from './AsyncQueue'; export { createAsyncQueue } from './AsyncQueue'; +export type { CacheInitializerConfig } from './CacheInitializer'; +export { createCacheInitializerFactory } from './CacheInitializer'; + export type { FDv2PollResponse, FDv2Requestor } from './FDv2Requestor'; export { makeFDv2Requestor } from './FDv2Requestor'; @@ -25,6 +28,7 @@ export { export type { Initializer } from './Initializer'; export type { Synchronizer } from './Synchronizer'; +export { calculatePollDelay } from './calculatePollDelay'; export { poll } from './PollingBase'; export { createPollingInitializer } from './PollingInitializer'; export { createPollingSynchronizer } from './PollingSynchronizer'; diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts index 118b35df3b..b6723f98ed 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts @@ -1,5 +1,7 @@ import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; +import { FRESHNESS_SUFFIX, FreshnessRecord, hashContext } from '../storage/freshness'; +import { loadCachedFlags } from '../storage/loadCachedFlags'; import { namespaceForContextData, namespaceForContextIndex } from '../storage/namespaceUtils'; import { Flags } from '../types'; import ContextIndex from './ContextIndex'; @@ -11,6 +13,11 @@ 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. + * + * Freshness metadata (timestamp + context attribute hash) is stored in a + * separate storage key (`{contextKey}_freshness`) alongside the flag data. + * Both keys are managed together — when a context is evicted, both the flag + * data and freshness record are cleared. */ export default class FlagPersistence { private _contextIndex: ContextIndex | undefined; @@ -56,47 +63,56 @@ export default class FlagPersistence { * {@link FlagUpdater} this {@link FlagPersistence} was constructed with. */ async loadCached(context: Context): Promise { - const storageKey = await namespaceForContextData( + if (!this._platform.storage) { + return false; + } + + const cached = await loadCachedFlags( + this._platform.storage, this._platform.crypto, this._environmentNamespace, context, + this._logger, ); - 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; - } + if (!cached) { + 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); + // Migrate data from version <10.3.1 stored under the canonical key + if (cached.fromLegacyKey) { + await this._platform.storage.set(cached.storageKey, JSON.stringify(cached.flags)); + 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; + // mapping flags to item descriptors + const descriptors = Object.entries(cached.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; + } + + private async _storeFreshness( + contextStorageKey: string, + context: Context, + timestamp: number, + ): Promise { + const contextHash = await hashContext(this._platform.crypto, context); + if (contextHash === undefined) { + this._logger.error('Could not serialize context for freshness tracking'); + return; } + const record: FreshnessRecord = { timestamp, contextHash }; + await this._platform.storage?.set( + `${contextStorageKey}${FRESHNESS_SUFFIX}`, + JSON.stringify(record), + ); } private async _loadIndex(): Promise { @@ -121,16 +137,22 @@ export default class FlagPersistence { } private async _storeCache(context: Context): Promise { + const now = this._timeStamper(); const index = await this._loadIndex(); const storageKey = await namespaceForContextData( this._platform.crypto, this._environmentNamespace, context, ); - index.notice(storageKey, this._timeStamper()); + index.notice(storageKey, now); const pruned = index.prune(this._maxCachedContexts); - await Promise.all(pruned.map(async (it) => this._platform.storage?.clear(it.id))); + await Promise.all( + pruned.flatMap((it) => [ + this._platform.storage?.clear(it.id), + this._platform.storage?.clear(`${it.id}${FRESHNESS_SUFFIX}`), + ]), + ); // store index await this._platform.storage?.set(await this._indexKeyPromise, index.toJson()); @@ -145,7 +167,14 @@ export default class FlagPersistence { }, {}); const jsonAll = JSON.stringify(flags); - // store flag data + // store flag data first, so freshness is never newer than the flags it describes await this._platform.storage?.set(storageKey, jsonAll); + + // store freshness — best-effort, must not block flag persistence + try { + await this._storeFreshness(storageKey, context, now); + } catch (e: any) { + this._logger.warn(`Failed to store freshness data: ${e.message}`); + } } } diff --git a/packages/shared/sdk-client/src/storage/freshness.ts b/packages/shared/sdk-client/src/storage/freshness.ts new file mode 100644 index 0000000000..4e90c31f65 --- /dev/null +++ b/packages/shared/sdk-client/src/storage/freshness.ts @@ -0,0 +1,65 @@ +import { Context, Crypto, LDLogger, Storage } from '@launchdarkly/js-sdk-common'; + +import digest from '../crypto/digest'; +import { namespaceForContextData } from './namespaceUtils'; + +/** + * Suffix appended to context storage keys to form the freshness storage key. + */ +export const FRESHNESS_SUFFIX = '_freshness'; + +/** + * Persisted freshness record stored at `{contextStorageKey}_freshness`. + */ +export interface FreshnessRecord { + /** Timestamp in ms since epoch when data was last received. */ + timestamp: number; + /** SHA-256 hash of the full context's canonical JSON. */ + contextHash: string; +} + +/** + * Computes a SHA-256 hash of the context's full canonical JSON. + * Returns `undefined` if the context cannot be serialized. + */ +export async function hashContext(crypto: Crypto, context: Context): Promise { + const json = context.canonicalUnfilteredJson(); + if (!json) { + return undefined; + } + return digest(crypto.createHash('sha256').update(json), 'base64'); +} + +/** + * Reads the freshness timestamp from storage for the given context. + * + * Returns `undefined` if no freshness record exists, the data is corrupt, + * or the context attributes have changed since the freshness was recorded. + */ +export async function readFreshness( + storage: Storage, + crypto: Crypto, + environmentNamespace: string, + context: Context, + logger?: LDLogger, +): Promise { + const contextStorageKey = await namespaceForContextData(crypto, environmentNamespace, context); + const json = await storage.get(`${contextStorageKey}${FRESHNESS_SUFFIX}`); + if (json === null || json === undefined) { + return undefined; + } + + try { + const record: FreshnessRecord = JSON.parse(json); + const currentHash = await hashContext(crypto, context); + if (currentHash === undefined || record.contextHash !== currentHash) { + return undefined; + } + return typeof record.timestamp === 'number' && !Number.isNaN(record.timestamp) + ? record.timestamp + : undefined; + } catch (e: any) { + logger?.warn(`Could not read freshness data from persistent storage: ${e.message}`); + return undefined; + } +} diff --git a/packages/shared/sdk-client/src/storage/loadCachedFlags.ts b/packages/shared/sdk-client/src/storage/loadCachedFlags.ts new file mode 100644 index 0000000000..1085e49dd8 --- /dev/null +++ b/packages/shared/sdk-client/src/storage/loadCachedFlags.ts @@ -0,0 +1,76 @@ +import { Context, Crypto, LDLogger, Storage } from '@launchdarkly/js-sdk-common'; + +import { Flag, Flags } from '../types'; +import { namespaceForContextData } from './namespaceUtils'; + +function isValidFlag(value: unknown): value is Flag { + return value !== null && typeof value === 'object' && typeof (value as Flag).version === 'number'; +} + +/** + * Result of loading cached flags from storage. + */ +export interface CachedFlagData { + /** The parsed flag data. */ + flags: Flags; + /** The storage key where the flags were found (or should be stored). */ + storageKey: string; + /** Whether the flags were found at the legacy canonical key location. */ + fromLegacyKey: boolean; +} + +/** + * Loads cached flag data from storage for the given context. + * + * Checks the current storage key first, then falls back to the legacy + * canonical key location (pre-10.3.1). Does NOT perform migration — the + * caller is responsible for migrating data if {@link CachedFlagData.fromLegacyKey} + * is true. + * + * @returns The cached flag data, or `undefined` on cache miss or parse error. + */ +export async function loadCachedFlags( + storage: Storage, + crypto: Crypto, + environmentNamespace: string, + context: Context, + logger?: LDLogger, +): Promise { + const storageKey = await namespaceForContextData(crypto, environmentNamespace, context); + let flagsJson = await storage.get(storageKey); + let fromLegacyKey = false; + + if (flagsJson === null || flagsJson === undefined) { + // Fallback: in version <10.3.1 flag data was stored under the canonical key. + flagsJson = await storage.get(context.canonicalKey); + if (flagsJson === null || flagsJson === undefined) { + return undefined; + } + fromLegacyKey = true; + } + + try { + const parsed = JSON.parse(flagsJson); + if (parsed === null || typeof parsed !== 'object') { + logger?.warn('Cached flag data is not a valid object'); + return undefined; + } + + const entries = Object.entries(parsed); + const invalidKey = entries.find(([, value]) => !isValidFlag(value)); + if (invalidKey) { + logger?.warn(`Discarding cached flags due to invalid entry: ${invalidKey[0]}`); + return undefined; + } + + const flags: Flags = entries.reduce((acc: Flags, [key, value]) => { + acc[key] = value as Flag; + return acc; + }, {}); + + return { flags, storageKey, fromLegacyKey }; + } catch (e: any) { + logger?.warn(`Could not parse cached flag evaluations from persistent storage: ${e.message}`); + return undefined; + } +}