diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index 6c747a0e76..df936c5427 100644 --- a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -187,7 +187,7 @@ describe('dataSystem validation', () => { getImplementationHooks: () => [], credentialType: 'clientSideId', dataSystemDefaults: { - initialConnectionMode: 'one-shot', + foregroundConnectionMode: 'one-shot', automaticModeSwitching: false, }, }, @@ -203,61 +203,69 @@ describe('dataSystem validation', () => { getImplementationHooks: () => [], credentialType: 'clientSideId', dataSystemDefaults: { - initialConnectionMode: 'one-shot', + foregroundConnectionMode: 'one-shot', automaticModeSwitching: false, }, }, ); expect(config.dataSystem).toBeDefined(); - expect(config.dataSystem!.initialConnectionMode).toBe('one-shot'); expect(config.dataSystem!.automaticModeSwitching).toBe(false); }); it('validates dataSystem with user overrides applied over platform defaults', () => { const config = new ConfigurationImpl( // @ts-ignore dataSystem is @internal - { dataSystem: { initialConnectionMode: 'polling' } }, + { + dataSystem: { + automaticModeSwitching: { type: 'manual', initialConnectionMode: 'polling' }, + }, + }, { getImplementationHooks: () => [], credentialType: 'mobileKey', dataSystemDefaults: { - initialConnectionMode: 'streaming', + foregroundConnectionMode: 'streaming', backgroundConnectionMode: 'background', automaticModeSwitching: true, }, }, ); expect(config.dataSystem).toBeDefined(); - expect(config.dataSystem!.initialConnectionMode).toBe('polling'); + expect(config.dataSystem!.automaticModeSwitching).toEqual({ + type: 'manual', + initialConnectionMode: 'polling', + }); expect(config.dataSystem!.backgroundConnectionMode).toBe('background'); - expect(config.dataSystem!.automaticModeSwitching).toBe(true); }); it('warns and falls back to default for invalid dataSystem sub-fields', () => { console.error = jest.fn(); const config = new ConfigurationImpl( // @ts-ignore dataSystem is @internal - { dataSystem: { initialConnectionMode: 'turbo' } }, + { dataSystem: { backgroundConnectionMode: 'turbo' } }, { getImplementationHooks: () => [], credentialType: 'clientSideId', dataSystemDefaults: { - initialConnectionMode: 'one-shot', + foregroundConnectionMode: 'one-shot', automaticModeSwitching: false, }, }, ); expect(config.dataSystem).toBeDefined(); - expect(config.dataSystem!.initialConnectionMode).toBe('one-shot'); expect(console.error).toHaveBeenCalledWith( - expect.stringContaining('dataSystem.initialConnectionMode'), + expect.stringContaining('dataSystem.backgroundConnectionMode'), ); }); it('does not deep-validate dataSystem when dataSystemDefaults is not provided', () => { const config = new ConfigurationImpl( // @ts-ignore dataSystem is @internal - { dataSystem: { initialConnectionMode: 'polling' } }, + { + dataSystem: { + automaticModeSwitching: { type: 'manual', initialConnectionMode: 'polling' }, + }, + }, { getImplementationHooks: () => [], credentialType: 'clientSideId', @@ -276,7 +284,7 @@ describe('dataSystem validation', () => { getImplementationHooks: () => [], credentialType: 'clientSideId', dataSystemDefaults: { - initialConnectionMode: 'one-shot', + foregroundConnectionMode: 'one-shot', automaticModeSwitching: false, }, }, @@ -288,18 +296,23 @@ describe('dataSystem validation', () => { it('validates automaticModeSwitching as a granular config object', () => { const config = new ConfigurationImpl( // @ts-ignore dataSystem is @internal - { dataSystem: { automaticModeSwitching: { lifecycle: true, network: false } } }, + { + dataSystem: { + automaticModeSwitching: { type: 'automatic', lifecycle: true, network: false }, + }, + }, { getImplementationHooks: () => [], credentialType: 'mobileKey', dataSystemDefaults: { - initialConnectionMode: 'streaming', + foregroundConnectionMode: 'streaming', automaticModeSwitching: true, }, }, ); expect(config.dataSystem).toBeDefined(); expect(config.dataSystem!.automaticModeSwitching).toEqual({ + type: 'automatic', lifecycle: true, network: false, }); @@ -312,7 +325,7 @@ describe('dataSystem validation', () => { getImplementationHooks: () => [], credentialType: 'clientSideId', dataSystemDefaults: { - initialConnectionMode: 'one-shot', + foregroundConnectionMode: 'one-shot', automaticModeSwitching: false, }, }, diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts new file mode 100644 index 0000000000..d2610ca5a4 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -0,0 +1,1024 @@ +import { Context, ServiceEndpoints } from '@launchdarkly/js-sdk-common'; + +import { MODE_TABLE } from '../../src/datasource/ConnectionModeConfig'; +import { createFDv1PollingSynchronizer } from '../../src/datasource/fdv2/FDv1PollingSynchronizer'; +import { createFDv2DataSource } from '../../src/datasource/fdv2/FDv2DataSource'; +import { makeFDv2Requestor } from '../../src/datasource/fdv2/FDv2Requestor'; +import { createSynchronizerSlot } from '../../src/datasource/fdv2/SourceManager'; +import { + createFDv2DataManagerBase, + FDv2DataManagerBaseConfig, + FDv2DataManagerControl, +} from '../../src/datasource/FDv2DataManagerBase'; +import { BROWSER_TRANSITION_TABLE } from '../../src/datasource/ModeResolver'; +import { makeRequestor } from '../../src/datasource/Requestor'; +import { + createStateDebounceManager, + PendingState, +} from '../../src/datasource/StateDebounceManager'; +import { namespaceForEnvironment } from '../../src/storage/namespaceUtils'; + +jest.mock('../../src/datasource/fdv2/FDv2DataSource'); +jest.mock('../../src/datasource/StateDebounceManager'); +jest.mock('../../src/storage/namespaceUtils'); +jest.mock('../../src/datasource/fdv2/FDv2Requestor'); +jest.mock('../../src/datasource/Requestor'); +jest.mock('../../src/datasource/fdv2/FDv1PollingSynchronizer'); + +const mockCreateFDv2DataSource = createFDv2DataSource as jest.MockedFunction< + typeof createFDv2DataSource +>; +const mockCreateStateDebounceManager = createStateDebounceManager as jest.MockedFunction< + typeof createStateDebounceManager +>; +const mockNamespaceForEnvironment = namespaceForEnvironment as jest.MockedFunction< + typeof namespaceForEnvironment +>; +const mockMakeFDv2Requestor = makeFDv2Requestor as jest.MockedFunction; + +function makeLogger() { + return { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +} + +function makePlatform() { + return { + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: { + btoa: jest.fn((s: string) => Buffer.from(s).toString('base64')), + }, + crypto: { + createHash: jest.fn(() => ({ + update: jest.fn().mockReturnThis(), + asyncDigest: jest.fn().mockResolvedValue('hashed'), + })), + randomUUID: jest.fn(() => 'test-uuid'), + }, + storage: undefined, + } as any; +} + +function makeConfig(overrides: Partial = {}) { + return { + logger: makeLogger(), + serviceEndpoints: new ServiceEndpoints('https://stream', 'https://poll', 'https://events'), + withReasons: false, + useReport: false, + streamInitialReconnectDelay: 1, + pollInterval: 300, + dataSystem: undefined, + ...overrides, + } as any; +} + +function makeFlagManager() { + return { + init: jest.fn(), + upsert: jest.fn(), + applyChanges: jest.fn().mockResolvedValue(undefined), + get: jest.fn(), + getAll: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as any; +} + +function makeSourceFactoryProvider() { + return { + createInitializerFactory: jest.fn((_entry: any) => jest.fn()), + createSynchronizerSlot: jest.fn((_entry: any) => createSynchronizerSlot(jest.fn())), + }; +} + +// Captured config and callbacks from mocks. +let capturedDataSourceConfigs: any[]; +let capturedOnReconcile: ((pendingState: PendingState) => void) | undefined; +let mockDataSource: { start: jest.Mock; close: jest.Mock }; +let mockDebounceManager: { + setNetworkState: jest.Mock; + setLifecycleState: jest.Mock; + setRequestedMode: jest.Mock; + close: jest.Mock; +}; + +function makeBaseConfig( + overrides: Partial = {}, +): FDv2DataManagerBaseConfig { + return { + platform: makePlatform(), + flagManager: makeFlagManager(), + credential: 'test-credential', + config: makeConfig(), + baseHeaders: { authorization: 'test-credential' }, + emitter: { emit: jest.fn(), on: jest.fn(), off: jest.fn() } as any, + transitionTable: BROWSER_TRANSITION_TABLE, + foregroundMode: 'one-shot', + backgroundMode: undefined, + modeTable: MODE_TABLE, + sourceFactoryProvider: makeSourceFactoryProvider(), + buildQueryParams: jest.fn(() => []), + ...overrides, + }; +} + +function makeContext() { + return Context.fromLDContext({ kind: 'user', key: 'test-key' }); +} + +beforeEach(() => { + jest.clearAllMocks(); + + capturedDataSourceConfigs = []; + capturedOnReconcile = undefined; + + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + mockDebounceManager = { + setNetworkState: jest.fn(), + setLifecycleState: jest.fn(), + setRequestedMode: jest.fn(), + close: jest.fn(), + }; + + mockCreateStateDebounceManager.mockImplementation((cfg: any) => { + capturedOnReconcile = cfg.onReconcile; + return mockDebounceManager; + }); + + mockNamespaceForEnvironment.mockResolvedValue('test-namespace'); + mockMakeFDv2Requestor.mockReturnValue({} as any); +}); + +async function identifyManager( + manager: FDv2DataManagerControl, + identifyOptions?: any, +): Promise<{ resolve: jest.Mock; reject: jest.Mock }> { + const resolve = jest.fn(); + const reject = jest.fn(); + await manager.identify(resolve, reject, makeContext(), identifyOptions); + // Flush microtasks so the start() promise resolves. + await Promise.resolve(); + await Promise.resolve(); + return { resolve, reject }; +} + +it('creates a data source for the resolved mode on identify', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + expect(mockCreateFDv2DataSource).toHaveBeenCalledTimes(1); + expect(mockDataSource.start).toHaveBeenCalledTimes(1); + + manager.close(); +}); + +it('tears down the previous data source on re-identify', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + const firstDataSource = mockDataSource; + const firstDebounceManager = mockDebounceManager; + + // Create new mocks for second identify. + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + + await identifyManager(manager); + + expect(firstDataSource.close).toHaveBeenCalledTimes(1); + expect(firstDebounceManager.close).toHaveBeenCalledTimes(1); + + manager.close(); +}); + +it('resolves identify immediately when bootstrap is provided', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const { resolve } = await identifyManager(manager, { bootstrap: {} }); + + expect(resolve).toHaveBeenCalledTimes(1); + + manager.close(); +}); + +it('does not create a data source when bootstrap is used with one-shot mode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + await identifyManager(manager, { bootstrap: {} }); + + // one-shot has no synchronizers, so no data source should be created after bootstrap. + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('starts synchronizers when bootstrap is used with streaming mode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); + await identifyManager(manager, { bootstrap: {} }); + + // streaming has synchronizers, so a data source should be created. + expect(mockCreateFDv2DataSource).toHaveBeenCalledTimes(1); + // But with no initializers (bootstrap already provided data). + const dsConfig = capturedDataSourceConfigs[0]; + expect(dsConfig.initializerFactories).toHaveLength(0); + expect(dsConfig.synchronizerSlots.length).toBeGreaterThan(0); + + manager.close(); +}); + +it('includes initializers on mode switch when no selector has been obtained', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + await identifyManager(manager); + + // Reset mock to capture second data source creation. + mockCreateFDv2DataSource.mockClear(); + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + // Simulate mode switch via reconcile: one-shot -> streaming. + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + expect(mockCreateFDv2DataSource).toHaveBeenCalledTimes(1); + const dsConfig = capturedDataSourceConfigs[capturedDataSourceConfigs.length - 1]; + // Should include initializers because no selector yet. + expect(dsConfig.initializerFactories.length).toBeGreaterThan(0); + expect(dsConfig.synchronizerSlots.length).toBeGreaterThan(0); + + manager.close(); +}); + +it('closes data source on mode switch from streaming to one-shot and updates current mode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); + await identifyManager(manager); + + const firstDataSource = mockDataSource; + + mockCreateFDv2DataSource.mockClear(); + // one-shot post-init has no sources, so createFDv2DataSource won't be called again + // because includeInitializers will be true (no selector) but the factories will be built. + // Actually, one-shot has initializers and no synchronizers. Since no selector, initializers included. + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'one-shot', + }); + + expect(firstDataSource.close).toHaveBeenCalledTimes(1); + expect(manager.getCurrentMode()).toBe('one-shot'); + + manager.close(); +}); + +it('does nothing on mode switch when mode is unchanged', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + await identifyManager(manager); + + mockCreateFDv2DataSource.mockClear(); + + // Reconcile with same mode — should be a no-op. + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'one-shot', + }); + + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('uses only synchronizers on mode switch after selector has been obtained', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + await identifyManager(manager); + + // Simulate that a selector was obtained via dataCallback. + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ type: 'full', updates: [], state: 'selector-123' }); + + mockCreateFDv2DataSource.mockClear(); + capturedDataSourceConfigs = []; + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + // Mode switch to streaming. + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + expect(mockCreateFDv2DataSource).toHaveBeenCalledTimes(1); + const newDsConfig = capturedDataSourceConfigs[0]; + // No initializers because selector is present. + expect(newDsConfig.initializerFactories).toHaveLength(0); + expect(newDsConfig.synchronizerSlots.length).toBeGreaterThan(0); + + manager.close(); +}); + +describe('given a manager with streaming as the initial foreground mode', () => { + let manager: FDv2DataManagerControl; + + beforeEach(() => { + manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); + }); + + afterEach(() => { + manager.close(); + }); + + it('resolves to streaming when setForcedStreaming is called with true', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(true); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + }); + + it('resolves to streaming when setForcedStreaming is undefined and automatic is true', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(undefined); + manager.setAutomaticStreamingState!(true); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenLastCalledWith('streaming'); + }); + + it('resolves to configured mode when setForcedStreaming is undefined and automatic is false', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(undefined); + manager.setAutomaticStreamingState!(false); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenLastCalledWith('streaming'); + }); +}); + +describe('given a manager with one-shot as the initial foreground mode', () => { + let manager: FDv2DataManagerControl; + + beforeEach(() => { + manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + }); + + afterEach(() => { + manager.close(); + }); + + it('resolves to streaming when setForcedStreaming is called with true', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(true); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + }); + + it('resolves to one-shot when setForcedStreaming is called with false', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(false); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('one-shot'); + }); + + it('resolves to streaming when automatic streaming is true and forced is undefined', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setAutomaticStreamingState!(true); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + }); + + it('resolves to one-shot when automatic streaming is false and forced is undefined', async () => { + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setAutomaticStreamingState!(false); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('one-shot'); + }); +}); + +it('falls back to one-shot when setForcedStreaming is false and configured mode is streaming', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setForcedStreaming!(false); + + // forced=false and configured=streaming -> falls back to one-shot. + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('one-shot'); + + manager.close(); +}); + +it('triggers flush callback when lifecycle transitions to background', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const flushCallback = jest.fn(); + manager.setFlushCallback(flushCallback); + + await identifyManager(manager); + + manager.setLifecycleState('background'); + + expect(flushCallback).toHaveBeenCalledTimes(1); + + manager.close(); +}); + +it('does not trigger flush callback when lifecycle is already background', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const flushCallback = jest.fn(); + manager.setFlushCallback(flushCallback); + + await identifyManager(manager); + + manager.setLifecycleState('background'); + flushCallback.mockClear(); + + // Setting background again should not flush. + manager.setLifecycleState('background'); + expect(flushCallback).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('delegates setNetworkState to debounce manager', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.setNetworkState('unavailable'); + + expect(mockDebounceManager.setNetworkState).toHaveBeenCalledWith('unavailable'); + + manager.close(); +}); + +it('delegates setLifecycleState to debounce manager', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.setLifecycleState('background'); + + expect(mockDebounceManager.setLifecycleState).toHaveBeenCalledWith('background'); + + manager.close(); +}); + +it('setConnectionMode overrides all automatic behavior', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); + await identifyManager(manager); + + mockDebounceManager.setRequestedMode.mockClear(); + manager.setConnectionMode('polling'); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('polling'); + + manager.close(); +}); + +it('setConnectionMode overrides forced streaming', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + await identifyManager(manager); + + manager.setForcedStreaming!(true); + mockDebounceManager.setRequestedMode.mockClear(); + + manager.setConnectionMode('polling'); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('polling'); + + // onReconcile should resolve to 'polling' (override bypasses transition table) + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'polling', + }); + expect(manager.getCurrentMode()).toBe('polling'); + + manager.close(); +}); + +it('clearing setConnectionMode returns to streaming logic', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + await identifyManager(manager); + + manager.setForcedStreaming!(true); + manager.setConnectionMode('polling'); + mockDebounceManager.setRequestedMode.mockClear(); + + // Clear the override — should fall back to forced streaming + manager.setConnectionMode(undefined); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + + manager.close(); +}); + +it('setConnectionMode override bypasses network unavailable', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); + await identifyManager(manager); + + manager.setConnectionMode('streaming'); + + // Even though network is unavailable, the override should win + capturedOnReconcile!({ + networkState: 'unavailable', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + expect(manager.getCurrentMode()).toBe('streaming'); + + manager.close(); +}); + +it('skips cache initializer on mode switch when bootstrapped', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + foregroundMode: 'streaming', + sourceFactoryProvider, + }), + ); + + await identifyManager(manager, { bootstrap: {} }); + + // After bootstrap identify, the data source was created for streaming + // synchronizers only (no initializers). + sourceFactoryProvider.createInitializerFactory.mockClear(); + + // Now simulate a mode switch that would include initializers. + mockCreateFDv2DataSource.mockClear(); + capturedDataSourceConfigs = []; + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + // Switch to polling (which has cache initializer). + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'polling', + }); + + // Verify that 'cache' type was NOT passed to createInitializerFactory. + const cacheInitCalls = sourceFactoryProvider.createInitializerFactory.mock.calls.filter( + (call: any[]) => call[0].type === 'cache', + ); + expect(cacheInitCalls).toHaveLength(0); + + manager.close(); +}); + +it('adds withReasons query param when config.withReasons is true', async () => { + const buildQueryParams = jest.fn(() => [{ key: 'auth', value: 'test-credential' }]); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + config: makeConfig({ withReasons: true }), + buildQueryParams, + }), + ); + + await identifyManager(manager); + + // The requestor should have been created with withReasons param. + // Check that makeFDv2Requestor was called and the queryParams include withReasons. + expect(mockMakeFDv2Requestor).toHaveBeenCalledTimes(1); + const queryParams = mockMakeFDv2Requestor.mock.calls[0][6]; + expect(queryParams).toContainEqual({ key: 'withReasons', value: 'true' }); + + manager.close(); +}); + +it('closes data source and debounce manager on close', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.close(); + + expect(mockDataSource.close).toHaveBeenCalledTimes(1); + expect(mockDebounceManager.close).toHaveBeenCalledTimes(1); +}); + +it('does not create data source after close', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.close(); + mockCreateFDv2DataSource.mockClear(); + + // Attempt to identify after close. + const resolve = jest.fn(); + const reject = jest.fn(); + await manager.identify(resolve, reject, makeContext()); + + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); +}); + +it('resolves identify when data source start completes', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const { resolve, reject } = await identifyManager(manager); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(reject).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('rejects identify when data source start fails', async () => { + const error = new Error('start failed'); + mockDataSource.start.mockRejectedValueOnce(error); + + const manager = createFDv2DataManagerBase(makeBaseConfig()); + const resolve = jest.fn(); + const reject = jest.fn(); + await manager.identify(resolve, reject, makeContext()); + // Flush microtasks for the rejected promise. + await Promise.resolve(); + await Promise.resolve(); + + expect(reject).toHaveBeenCalledTimes(1); + expect(reject).toHaveBeenCalledWith(error); + + manager.close(); +}); + +it('exposes configuredForegroundMode from the initial config', () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'polling' })); + + expect(manager.configuredForegroundMode).toBe('polling'); + + manager.close(); +}); + +it('reports the initial resolved mode via getCurrentMode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + await identifyManager(manager); + + expect(manager.getCurrentMode()).toBe('one-shot'); + + manager.close(); +}); + +it('does not reconcile after close', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.close(); + mockCreateFDv2DataSource.mockClear(); + + // Calling onReconcile after close should be a no-op. + capturedOnReconcile?.({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); +}); + +it('resolves to offline when network is unavailable via reconcile', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); + await identifyManager(manager); + + const firstDataSource = mockDataSource; + mockCreateFDv2DataSource.mockClear(); + + capturedOnReconcile!({ + networkState: 'unavailable', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + // Should close previous data source. + expect(firstDataSource.close).toHaveBeenCalledTimes(1); + // Offline mode resolves via the browser transition table. + expect(manager.getCurrentMode()).toBe('offline'); + + manager.close(); +}); + +it('sets up debounce manager with correct initial state after identify', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); + await identifyManager(manager); + + expect(mockCreateStateDebounceManager).toHaveBeenCalledTimes(1); + const config = mockCreateStateDebounceManager.mock.calls[0][0]; + expect(config.initialState).toEqual({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + expect(config.onReconcile).toBeInstanceOf(Function); + + manager.close(); +}); + +it('calls flagManager.applyChanges with type full for a full payload', async () => { + const flagManager = makeFlagManager(); + const manager = createFDv2DataManagerBase(makeBaseConfig({ flagManager })); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ + type: 'full', + updates: [{ kind: 'flag-eval', key: 'flag1', version: 1, object: { value: true } }], + state: 'selector-1', + }); + + expect(flagManager.applyChanges).toHaveBeenCalledTimes(1); + expect(flagManager.applyChanges).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'full', + ); + + manager.close(); +}); + +it('calls flagManager.applyChanges with type partial for a partial payload', async () => { + const flagManager = makeFlagManager(); + const manager = createFDv2DataManagerBase(makeBaseConfig({ flagManager })); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ + type: 'partial', + updates: [{ kind: 'flag-eval', key: 'flag1', version: 2, object: { value: false } }], + state: 'selector-2', + }); + + expect(flagManager.applyChanges).toHaveBeenCalledTimes(1); + expect(flagManager.applyChanges).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'partial', + ); + + manager.close(); +}); + +it('calls flagManager.applyChanges with type none on none payload to update freshness', async () => { + const flagManager = makeFlagManager(); + const manager = createFDv2DataManagerBase(makeBaseConfig({ flagManager })); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ + type: 'none', + updates: [], + state: 'selector-3', + }); + + // Spec 5.2.2: transfer-none confirms data is still current. + // applyChanges with type none persists cache (updating freshness). + expect(flagManager.applyChanges).toHaveBeenCalledTimes(1); + expect(flagManager.applyChanges).toHaveBeenCalledWith(expect.anything(), {}, 'none'); + + manager.close(); +}); + +it('stores selector from payload state for subsequent data source creations', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); + await identifyManager(manager); + + // Deliver a payload with a selector. + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ type: 'none', updates: [], state: 'my-selector' }); + + // Now switch mode. Since selector exists, no initializers. + mockCreateFDv2DataSource.mockClear(); + capturedDataSourceConfigs = []; + mockDataSource = { + start: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + }; + mockCreateFDv2DataSource.mockImplementation((cfg: any) => { + capturedDataSourceConfigs.push(cfg); + return mockDataSource; + }); + + capturedOnReconcile!({ + networkState: 'available', + lifecycleState: 'foreground', + requestedMode: 'streaming', + }); + + if (capturedDataSourceConfigs.length > 0) { + expect(capturedDataSourceConfigs[0].initializerFactories).toHaveLength(0); + } + + manager.close(); +}); + +it('warns and skips unsupported initializer entry types', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + // Return undefined for one entry to trigger the warning path. + // @ts-ignore - mock returns undefined for unsupported types + sourceFactoryProvider.createInitializerFactory.mockImplementation((entry: any) => + entry.type === 'polling' ? jest.fn() : undefined, + ); + const cfg = makeConfig(); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + config: cfg, + sourceFactoryProvider, + // Use streaming mode which has cache + polling initializers. + foregroundMode: 'streaming', + }), + ); + await identifyManager(manager); + + // cache entry returns undefined → warning logged. + expect(cfg.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Unsupported initializer type'), + ); + + manager.close(); +}); + +it('warns and skips unsupported synchronizer entry types', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + // Return undefined for all synchronizer entries to trigger the warning path. + // @ts-ignore - mock returns undefined for unsupported types + sourceFactoryProvider.createSynchronizerSlot.mockReturnValue(undefined); + const cfg = makeConfig(); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + config: cfg, + sourceFactoryProvider, + // streaming mode has streaming + polling synchronizers. + foregroundMode: 'streaming', + }), + ); + await identifyManager(manager); + + expect(cfg.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Unsupported synchronizer type'), + ); + + manager.close(); +}); + +it('appends a blocked FDv1 fallback synchronizer when fdv1Endpoints are configured', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + const fdv1Endpoints = { + polling: jest.fn(() => ({ + pathGet: jest.fn(), + pathReport: jest.fn(), + pathPost: jest.fn(), + pathPing: jest.fn(), + })), + streaming: jest.fn(() => ({ + pathGet: jest.fn(), + pathReport: jest.fn(), + pathPost: jest.fn(), + pathPing: jest.fn(), + })), + }; + + (makeRequestor as jest.Mock).mockReturnValue({}); + (createFDv1PollingSynchronizer as jest.Mock).mockReturnValue({ close: jest.fn() }); + + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + sourceFactoryProvider, + fdv1Endpoints, + // streaming mode has synchronizers, so FDv1 fallback will be appended. + foregroundMode: 'streaming', + }), + ); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + // The last synchronizer slot should be the FDv1 fallback (blocked). + const lastSlot = dsConfig.synchronizerSlots[dsConfig.synchronizerSlots.length - 1]; + expect(lastSlot.isFDv1Fallback).toBe(true); + expect(lastSlot.state).toBe('blocked'); + + manager.close(); +}); + +it('resolves identify immediately when initial mode has no sources', async () => { + // Use a custom mode table where the initial mode has empty initializers and synchronizers. + const sourceFactoryProvider = makeSourceFactoryProvider(); + // @ts-ignore - mock returns undefined for unsupported types + sourceFactoryProvider.createInitializerFactory.mockReturnValue(undefined); + // @ts-ignore - mock returns undefined for unsupported types + sourceFactoryProvider.createSynchronizerSlot.mockReturnValue(undefined); + + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + sourceFactoryProvider, + // offline mode: [cache] initializer, [] synchronizers. + // With provider returning undefined for cache, both arrays are empty. + foregroundMode: 'offline', + }), + ); + + const { resolve } = await identifyManager(manager); + + // Should resolve immediately — offline with no sources. + expect(resolve).toHaveBeenCalledTimes(1); + // No data source should have been created. + expect(mockCreateFDv2DataSource).not.toHaveBeenCalled(); + + manager.close(); +}); + +it('does not identify after close', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + manager.close(); + + const cfg = makeConfig(); + // Re-create with our logger to check debug message. + const manager2 = createFDv2DataManagerBase(makeBaseConfig({ config: cfg })); + await identifyManager(manager2); + manager2.close(); + + // Now close and try to identify. + mockCreateFDv2DataSource.mockClear(); + const resolve = jest.fn(); + const reject = jest.fn(); + await manager2.identify(resolve, reject, makeContext()); + + // After close, identify should be a no-op. + expect(resolve).not.toHaveBeenCalled(); + expect(cfg.logger.debug).toHaveBeenCalledWith( + expect.stringContaining('Identify called after close'), + ); +}); + +it('populates polling and streaming config in the factory context', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + let capturedCtx: any; + // @ts-ignore - mock captures ctx argument + sourceFactoryProvider.createInitializerFactory.mockImplementation((_entry: any, ctx: any) => { + capturedCtx = ctx; + return jest.fn(); + }); + + const manager = createFDv2DataManagerBase(makeBaseConfig({ sourceFactoryProvider })); + await identifyManager(manager); + + expect(capturedCtx.polling).toBeDefined(); + expect(capturedCtx.polling.paths).toBeDefined(); + expect(capturedCtx.polling.intervalSeconds).toBeDefined(); + expect(capturedCtx.streaming).toBeDefined(); + expect(capturedCtx.streaming.paths).toBeDefined(); + expect(capturedCtx.streaming.initialReconnectDelaySeconds).toBeDefined(); + + manager.close(); +}); diff --git a/packages/shared/sdk-client/__tests__/datasource/LDClientDataSystemOptions.test.ts b/packages/shared/sdk-client/__tests__/datasource/LDClientDataSystemOptions.test.ts index 9df05668c3..bd6981c5f6 100644 --- a/packages/shared/sdk-client/__tests__/datasource/LDClientDataSystemOptions.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/LDClientDataSystemOptions.test.ts @@ -34,14 +34,13 @@ function validateDataSystemOptions( } describe('given valid options', () => { - it('passes through valid connection modes unchanged', () => { + it('passes through valid backgroundConnectionMode', () => { const result = validateDataSystemOptions( - { initialConnectionMode: 'polling', backgroundConnectionMode: 'offline' }, + { backgroundConnectionMode: 'offline' }, BROWSER_DATA_SYSTEM_DEFAULTS, logger, ); - expect(result.initialConnectionMode).toBe('polling'); expect(result.backgroundConnectionMode).toBe('offline'); expect(logger.warn).not.toHaveBeenCalled(); }); @@ -57,25 +56,43 @@ describe('given valid options', () => { expect(logger.warn).not.toHaveBeenCalled(); }); - it('passes through automaticModeSwitching granular config', () => { + it('passes through automatic mode config with type discriminant', () => { const result = validateDataSystemOptions( - { automaticModeSwitching: { lifecycle: true, network: false } }, + { automaticModeSwitching: { type: 'automatic', lifecycle: true, network: false } }, BROWSER_DATA_SYSTEM_DEFAULTS, logger, ); - expect(result.automaticModeSwitching).toEqual({ lifecycle: true, network: false }); + expect(result.automaticModeSwitching).toEqual({ + type: 'automatic', + lifecycle: true, + network: false, + }); expect(logger.warn).not.toHaveBeenCalled(); }); - it('passes through partial granular config', () => { + it('passes through partial automatic config', () => { const result = validateDataSystemOptions( - { automaticModeSwitching: { network: true } }, + { automaticModeSwitching: { type: 'automatic', network: true } }, BROWSER_DATA_SYSTEM_DEFAULTS, logger, ); - expect(result.automaticModeSwitching).toEqual({ network: true }); + expect(result.automaticModeSwitching).toEqual({ type: 'automatic', network: true }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('passes through manual mode config with initialConnectionMode', () => { + const result = validateDataSystemOptions( + { automaticModeSwitching: { type: 'manual', initialConnectionMode: 'polling' } }, + BROWSER_DATA_SYSTEM_DEFAULTS, + logger, + ); + + expect(result.automaticModeSwitching).toEqual({ + type: 'manual', + initialConnectionMode: 'polling', + }); expect(logger.warn).not.toHaveBeenCalled(); }); }); @@ -84,7 +101,7 @@ describe('given undefined or null input', () => { it('returns platform defaults for undefined', () => { const result = validateDataSystemOptions(undefined, MOBILE_DATA_SYSTEM_DEFAULTS, logger); - expect(result.initialConnectionMode).toBe('streaming'); + expect(result.foregroundConnectionMode).toBe('streaming'); expect(result.backgroundConnectionMode).toBe('background'); expect(result.automaticModeSwitching).toBe(true); expect(logger.warn).not.toHaveBeenCalled(); @@ -93,7 +110,7 @@ describe('given undefined or null input', () => { it('returns platform defaults for null', () => { const result = validateDataSystemOptions(null, MOBILE_DATA_SYSTEM_DEFAULTS, logger); - expect(result.initialConnectionMode).toBe('streaming'); + expect(result.foregroundConnectionMode).toBe('streaming'); expect(logger.warn).not.toHaveBeenCalled(); }); }); @@ -102,53 +119,18 @@ describe('given non-object input', () => { it('returns defaults and warns for a string', () => { const result = validateDataSystemOptions('streaming', BROWSER_DATA_SYSTEM_DEFAULTS, logger); - expect(result.initialConnectionMode).toBe('one-shot'); + expect(result.foregroundConnectionMode).toBe('one-shot'); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('got string')); }); it('returns defaults and warns for a number', () => { const result = validateDataSystemOptions(42, BROWSER_DATA_SYSTEM_DEFAULTS, logger); - expect(result.initialConnectionMode).toBe('one-shot'); + expect(result.foregroundConnectionMode).toBe('one-shot'); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('got number')); }); }); -describe('given invalid initialConnectionMode', () => { - it('falls back to platform default for an unknown mode string', () => { - const result = validateDataSystemOptions( - { initialConnectionMode: 'turbo' }, - BROWSER_DATA_SYSTEM_DEFAULTS, - logger, - ); - - expect(result.initialConnectionMode).toBe('one-shot'); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('initialConnectionMode')); - }); - - it('falls back to platform default when mode is a number', () => { - const result = validateDataSystemOptions( - { initialConnectionMode: 1 }, - MOBILE_DATA_SYSTEM_DEFAULTS, - logger, - ); - - expect(result.initialConnectionMode).toBe('streaming'); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('got number')); - }); - - it('falls back to platform default when mode is a boolean', () => { - const result = validateDataSystemOptions( - { initialConnectionMode: true }, - BROWSER_DATA_SYSTEM_DEFAULTS, - logger, - ); - - expect(result.initialConnectionMode).toBe('one-shot'); - expect(logger.warn).toHaveBeenCalled(); - }); -}); - describe('given invalid backgroundConnectionMode', () => { it('falls back to platform default for an unknown mode string', () => { const result = validateDataSystemOptions( @@ -182,7 +164,7 @@ describe('given invalid automaticModeSwitching', () => { ); expect(result.automaticModeSwitching).toBe(true); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('boolean | object')); + expect(logger.warn).toHaveBeenCalled(); }); it('falls back to platform default when value is a number', () => { @@ -196,26 +178,17 @@ describe('given invalid automaticModeSwitching', () => { expect(logger.warn).toHaveBeenCalled(); }); - it('coerces invalid lifecycle field to boolean in granular config and warns', () => { + it('warns and drops invalid initialConnectionMode in manual mode config', () => { const result = validateDataSystemOptions( - { automaticModeSwitching: { lifecycle: 'yes', network: true } }, + { automaticModeSwitching: { type: 'manual', initialConnectionMode: 'turbo' } }, BROWSER_DATA_SYSTEM_DEFAULTS, logger, ); - expect(result.automaticModeSwitching).toEqual({ lifecycle: true, network: true }); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('lifecycle')); - }); - - it('coerces invalid network field to boolean in granular config and warns', () => { - const result = validateDataSystemOptions( - { automaticModeSwitching: { lifecycle: false, network: 0 } }, - BROWSER_DATA_SYSTEM_DEFAULTS, - logger, - ); - - expect(result.automaticModeSwitching).toEqual({ lifecycle: false, network: false }); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('network')); + // Invalid initialConnectionMode is dropped, type is preserved + expect((result.automaticModeSwitching as any).type).toBe('manual'); + expect((result.automaticModeSwitching as any).initialConnectionMode).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('initialConnectionMode')); }); }); @@ -223,21 +196,20 @@ describe('given omitted fields', () => { it('fills in platform defaults for omitted fields', () => { const result = validateDataSystemOptions({}, MOBILE_DATA_SYSTEM_DEFAULTS, logger); - expect(result.initialConnectionMode).toBe('streaming'); + expect(result.foregroundConnectionMode).toBe('streaming'); expect(result.backgroundConnectionMode).toBe('background'); expect(result.automaticModeSwitching).toBe(true); expect(logger.warn).not.toHaveBeenCalled(); }); - it('allows overriding only some fields', () => { + it('allows overriding only backgroundConnectionMode', () => { const result = validateDataSystemOptions( - { initialConnectionMode: 'polling' }, + { backgroundConnectionMode: 'offline' }, MOBILE_DATA_SYSTEM_DEFAULTS, logger, ); - expect(result.initialConnectionMode).toBe('polling'); - expect(result.backgroundConnectionMode).toBe('background'); + expect(result.backgroundConnectionMode).toBe('offline'); expect(result.automaticModeSwitching).toBe(true); expect(logger.warn).not.toHaveBeenCalled(); }); @@ -246,11 +218,10 @@ describe('given omitted fields', () => { describe('given no logger', () => { it('validates without throwing when logger is undefined', () => { const result = validateDataSystemOptions( - { initialConnectionMode: 999, automaticModeSwitching: 'bad' }, + { automaticModeSwitching: 'bad' }, BROWSER_DATA_SYSTEM_DEFAULTS, ); - expect(result.initialConnectionMode).toBe('one-shot'); expect(result.automaticModeSwitching).toBe(false); }); }); diff --git a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts index bcd45c1fc4..35ffd121ca 100644 --- a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts @@ -8,18 +8,6 @@ import { ModeDefinition } from './ModeDefinition'; * Configuration for the FDv2 client-side data system. */ export interface LDClientDataSystemOptions { - /** - * The initial connection mode the SDK should use. - * - * If not specified, the platform SDK provides a default: - * - Browser: 'one-shot' - * - React Native: 'streaming' - * - Electron: 'streaming' - * - * See {@link FDv2ConnectionMode} for the available modes. - */ - initialConnectionMode?: FDv2ConnectionMode; - /** * The connection mode to use when the application transitions to the background. * @@ -34,20 +22,19 @@ export interface LDClientDataSystemOptions { backgroundConnectionMode?: FDv2ConnectionMode; /** - * Controls automatic mode switching in response to platform events. + * Controls how the SDK switches between connection modes. * * - `true` — enable all automatic switching (lifecycle + network) - * - `false` — disable all automatic switching; the user manages modes manually - * - `{ lifecycle?: boolean, network?: boolean }` — granular control over - * which platform events trigger automatic mode switches - * - * `lifecycle` controls foreground/background transitions (mobile) and - * visibility changes (browser). `network` controls pause/resume of data - * sources when network availability changes. + * - `false` — disable all automatic switching; uses the platform default + * foreground mode + * - {@link AutomaticModeSwitchingConfig} — granular control over which + * platform events trigger automatic mode switches + * - {@link ManualModeSwitching} — disable automatic switching and specify + * the initial connection mode explicitly * - * Default is true for mobile SDKs, false/ignored for browser. + * Default is `true` for mobile SDKs, `false` for browser. */ - automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig; + automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig | ManualModeSwitching; /** * Override the data source pipeline for specific connection modes. @@ -76,6 +63,9 @@ export interface LDClientDataSystemOptions { * Granular control over which platform events trigger automatic mode switches. */ export interface AutomaticModeSwitchingConfig { + /** Discriminant — selects automatic mode switching. */ + readonly type: 'automatic'; + /** * Whether to automatically switch modes in response to application lifecycle * events (foreground/background on mobile, visibility changes on browser). @@ -93,14 +83,31 @@ export interface AutomaticModeSwitchingConfig { readonly network?: boolean; } +/** + * Disable automatic switching and specify the initial connection mode. + * + * Subsequent mode transitions must be triggered explicitly via + * {@link FDv2DataManagerControl.setConnectionMode}. + */ +export interface ManualModeSwitching { + /** Discriminant — selects manual mode switching. */ + readonly type: 'manual'; + + /** + * The connection mode to use when the SDK starts. Overrides the + * platform default from {@link PlatformDataSystemDefaults.foregroundConnectionMode}. + */ + initialConnectionMode: FDv2ConnectionMode; +} + /** * Platform-specific default configuration for the FDv2 data system. */ export interface PlatformDataSystemDefaults { - /** The default initial connection mode for this platform. */ - readonly initialConnectionMode: FDv2ConnectionMode; + /** The default foreground connection mode for this platform. */ + readonly foregroundConnectionMode: FDv2ConnectionMode; /** The default background connection mode, if any. */ readonly backgroundConnectionMode?: FDv2ConnectionMode; /** Whether automatic mode switching is enabled by default. */ - readonly automaticModeSwitching: boolean | AutomaticModeSwitchingConfig; + readonly automaticModeSwitching: boolean | AutomaticModeSwitchingConfig | ManualModeSwitching; } diff --git a/packages/shared/sdk-client/src/api/datasource/index.ts b/packages/shared/sdk-client/src/api/datasource/index.ts index 95a688808b..c45fc75bd3 100644 --- a/packages/shared/sdk-client/src/api/datasource/index.ts +++ b/packages/shared/sdk-client/src/api/datasource/index.ts @@ -12,6 +12,7 @@ export type { ModeDefinition } from './ModeDefinition'; export type { LDClientDataSystemOptions, AutomaticModeSwitchingConfig, + ManualModeSwitching, PlatformDataSystemDefaults, } from './LDClientDataSystemOptions'; export type { diff --git a/packages/shared/sdk-client/src/configuration/validateOptions.ts b/packages/shared/sdk-client/src/configuration/validateOptions.ts index f0f1b08afe..eb80835325 100644 --- a/packages/shared/sdk-client/src/configuration/validateOptions.ts +++ b/packages/shared/sdk-client/src/configuration/validateOptions.ts @@ -110,13 +110,21 @@ export default function validateOptions( * Creates a validator for nested objects. When used in a validator map, * `validateOptions` will recursively validate the nested object's properties. * Defaults for nested fields are passed through from the parent. + * + * @param validators - Validator map for the nested object's fields. + * @param options - Optional configuration. + * @param options.defaults - Built-in defaults for nested fields. + * @param options.is - Custom `is` predicate. When provided, replaces the + * default "is object" check. Use this to discriminate between object shapes + * in an `anyOf` (e.g., matching on a `type` discriminant field). */ export function validatorOf( validators: Record, - builtInDefaults?: Record, + options?: { defaults?: Record; is?: (u: unknown) => boolean }, ): CompoundValidator { + const builtInDefaults = options?.defaults; return { - is: (u: unknown) => TypeValidators.Object.is(u), + is: options?.is ?? ((u: unknown) => TypeValidators.Object.is(u)), getType: () => 'object', validate(value: unknown, name: string, logger?: LDLogger, defaults?: unknown) { if (!TypeValidators.Object.is(value)) { diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 2350878f34..37ee22b3d9 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -46,10 +46,9 @@ export default function createValidators( inspectors: TypeValidators.createTypeArray('LDInspection', {}), cleanOldPersistentData: TypeValidators.Boolean, dataSystem: options?.dataSystemDefaults - ? validatorOf( - dataSystemValidators, - options.dataSystemDefaults as unknown as Record, - ) + ? validatorOf(dataSystemValidators, { + defaults: options.dataSystemDefaults as unknown as Record, + }) : TypeValidators.Object, }; } diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts new file mode 100644 index 0000000000..3bf22e01e3 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -0,0 +1,606 @@ +import { Context, internal, LDHeaders, Platform } from '@launchdarkly/js-sdk-common'; + +import { + FDv2ConnectionMode, + ModeDefinition, + ModeResolutionTable, + ModeState, +} from '../api/datasource'; +import { LDIdentifyOptions } from '../api/LDIdentifyOptions'; +import { Configuration } from '../configuration/Configuration'; +import { DataManager } from '../DataManager'; +import { FlagManager } from '../flag-manager/FlagManager'; +import LDEmitter from '../LDEmitter'; +import { namespaceForEnvironment } from '../storage/namespaceUtils'; +import { ModeTable } from './ConnectionModeConfig'; +import { createDataSourceStatusManager, DataSourceStatusManager } from './DataSourceStatusManager'; +import { DataSourceEndpoints, fdv2Endpoints } from './Endpoints'; +import { createFDv1PollingSynchronizer } from './fdv2/FDv1PollingSynchronizer'; +import { createFDv2DataSource, FDv2DataSource } from './fdv2/FDv2DataSource'; +import { makeFDv2Requestor } from './fdv2/FDv2Requestor'; +import { createSynchronizerSlot, InitializerFactory, SynchronizerSlot } from './fdv2/SourceManager'; +import { flagEvalPayloadToItemDescriptors } from './flagEvalMapper'; +import { resolveConnectionMode } from './ModeResolver'; +import { makeRequestor } from './Requestor'; +import { SourceFactoryContext, SourceFactoryProvider } from './SourceFactoryProvider'; +import { + createStateDebounceManager, + LifecycleState, + NetworkState, + PendingState, + StateDebounceManager, +} from './StateDebounceManager'; + +const logTag = '[FDv2DataManagerBase]'; + +/** + * Configuration for creating an {@link FDv2DataManagerControl}. + */ +export interface FDv2DataManagerBaseConfig { + platform: Platform; + flagManager: FlagManager; + credential: string; + config: Configuration; + baseHeaders: LDHeaders; + emitter: LDEmitter; + + /** Mode resolution table for this platform. */ + transitionTable: ModeResolutionTable; + /** The configured foreground connection mode. Use {@link resolveForegroundMode} to derive. */ + foregroundMode: FDv2ConnectionMode; + /** The background connection mode, if any. */ + backgroundMode: FDv2ConnectionMode | undefined; + /** The mode table mapping modes to data source definitions. */ + modeTable: ModeTable; + /** Provider that converts DataSourceEntry descriptors to concrete factories. */ + sourceFactoryProvider: SourceFactoryProvider; + /** + * Platform-specific function to build query params for each identify call. + * Browser returns `[{ key: 'auth', value: credential }]` + optional hash. + * Mobile returns `[]` (uses Authorization header instead). + */ + buildQueryParams: (identifyOptions?: LDIdentifyOptions) => { key: string; value: string }[]; + + /** + * FDv1 endpoint factory for fallback. When provided, a blocked FDv1 + * polling synchronizer slot is automatically appended to every data + * source. It is activated when an FDv2 response includes the + * `x-ld-fd-fallback` header. + * + * Browser: `browserFdv1Endpoints(clientSideId)` + * Mobile: `mobileFdv1Endpoints()` + */ + fdv1Endpoints?: DataSourceEndpoints; + + /** Fallback condition timeout in ms (default 120s). */ + fallbackTimeoutMs?: number; + /** Recovery condition timeout in ms (default 300s). */ + recoveryTimeoutMs?: number; +} + +/** + * The public interface returned by {@link createFDv2DataManagerBase}. + * Extends {@link DataManager} with mode control methods. + */ +export interface FDv2DataManagerControl extends DataManager { + /** Update the pending network state. Goes through debounce. */ + setNetworkState(state: NetworkState): void; + /** Update the pending lifecycle state. Goes through debounce. */ + setLifecycleState(state: LifecycleState): void; + /** + * Set an explicit connection mode override that bypasses all automatic + * behavior (transition table, streaming, lifecycle). Pass undefined to + * clear the override and return to automatic behavior. + */ + setConnectionMode(mode?: FDv2ConnectionMode): void; + /** Get the currently resolved connection mode. */ + getCurrentMode(): FDv2ConnectionMode; + /** The configured default foreground mode (from config, not auto-promoted). */ + readonly configuredForegroundMode: FDv2ConnectionMode; + /** + * Set a callback to flush pending analytics events. Called immediately + * (not debounced) when the lifecycle transitions to background. + */ + setFlushCallback(callback: () => void): void; +} + +/** + * Creates a shared FDv2 data manager that owns mode resolution, debouncing, + * selector state, and FDv2DataSource lifecycle. Platform SDKs (browser, RN) + * wrap this with platform-specific config and event wiring. + */ +export function createFDv2DataManagerBase( + baseConfig: FDv2DataManagerBaseConfig, +): FDv2DataManagerControl { + const { + platform, + flagManager, + config, + baseHeaders, + emitter, + transitionTable, + foregroundMode: configuredForegroundMode, + backgroundMode, + modeTable, + sourceFactoryProvider, + buildQueryParams, + fdv1Endpoints, + fallbackTimeoutMs, + recoveryTimeoutMs, + } = baseConfig; + + const { logger } = config; + const statusManager: DataSourceStatusManager = createDataSourceStatusManager(emitter); + const endpoints = fdv2Endpoints(); + + // Merge user-provided connection mode overrides into the mode table. + const effectiveModeTable: ModeTable = config.dataSystem?.connectionModes + ? { ...modeTable, ...config.dataSystem.connectionModes } + : modeTable; + + // --- Mutable state --- + let selector: string | undefined; + let currentResolvedMode: FDv2ConnectionMode = configuredForegroundMode; + let foregroundMode: FDv2ConnectionMode = configuredForegroundMode; + let dataSource: FDv2DataSource | undefined; + let debounceManager: StateDebounceManager | undefined; + let identifiedContext: Context | undefined; + let factoryContext: SourceFactoryContext | undefined; + let initialized = false; + let bootstrapped = false; + let closed = false; + let flushCallback: (() => void) | undefined; + + // Explicit connection mode override — bypasses transition table entirely. + let connectionModeOverride: FDv2ConnectionMode | undefined; + + // Forced/automatic streaming state for browser listener-driven streaming. + let forcedStreaming: boolean | undefined; + let automaticStreamingState = false; + + // Outstanding identify promise callbacks — needed so that mode switches + // during identify can wire the new data source's completion to the + // original identify promise. + let pendingIdentifyResolve: (() => void) | undefined; + let pendingIdentifyReject: ((err: Error) => void) | undefined; + + // Current debounce input state. + let networkState: NetworkState = 'available'; + let lifecycleState: LifecycleState = 'foreground'; + + // --- Helpers --- + + function getModeDefinition(mode: FDv2ConnectionMode): ModeDefinition { + return effectiveModeTable[mode]; + } + + function buildModeState(): ModeState { + return { + lifecycle: lifecycleState, + networkAvailable: networkState === 'available', + foregroundMode, + backgroundMode: backgroundMode ?? 'offline', + }; + } + + /** + * Resolve the current effective connection mode. + * + * Priority: + * 1. connectionModeOverride (set via setConnectionMode) — bypasses everything + * 2. Transition table (network/lifecycle state + foreground/background modes) + */ + function resolveMode(): FDv2ConnectionMode { + if (connectionModeOverride !== undefined) { + return connectionModeOverride; + } + return resolveConnectionMode(transitionTable, buildModeState()); + } + + /** + * Resolve the foreground mode input for the transition table based on + * forced/automatic streaming state. + * + * Priority: forcedStreaming > automaticStreaming > configuredForegroundMode + */ + function resolveStreamingForeground(): FDv2ConnectionMode { + if (forcedStreaming === true) { + return 'streaming'; + } + if (forcedStreaming === false) { + return configuredForegroundMode === 'streaming' ? 'one-shot' : configuredForegroundMode; + } + return automaticStreamingState ? 'streaming' : configuredForegroundMode; + } + + /** + * Compute the effective foreground mode from streaming state and push it + * through the debounce manager. Used by setForcedStreaming and + * setAutomaticStreamingState. + */ + function pushForegroundMode(): void { + foregroundMode = resolveStreamingForeground(); + debounceManager?.setRequestedMode(foregroundMode); + } + + /** + * Convert a ModeDefinition's entries into concrete InitializerFactory[] + * and SynchronizerSlot[] using the source factory provider. + */ + function buildFactories( + modeDef: ModeDefinition, + ctx: SourceFactoryContext, + includeInitializers: boolean, + ): { + initializerFactories: InitializerFactory[]; + synchronizerSlots: SynchronizerSlot[]; + } { + const initializerFactories: InitializerFactory[] = []; + if (includeInitializers) { + modeDef.initializers + // Skip cache when bootstrapped — bootstrap data was applied to the + // flag store before identify, so the cache would only load older data. + .filter((entry) => !(bootstrapped && entry.type === 'cache')) + .forEach((entry) => { + const factory = sourceFactoryProvider.createInitializerFactory(entry, ctx); + if (factory) { + initializerFactories.push(factory); + } else { + logger.warn( + `${logTag} Unsupported initializer type '${entry.type}'. It will be skipped.`, + ); + } + }); + } + + const synchronizerSlots: SynchronizerSlot[] = []; + modeDef.synchronizers.forEach((entry) => { + const slot = sourceFactoryProvider.createSynchronizerSlot(entry, ctx); + if (slot) { + synchronizerSlots.push(slot); + } else { + logger.warn(`${logTag} Unsupported synchronizer type '${entry.type}'. It will be skipped.`); + } + }); + + // Append a blocked FDv1 fallback synchronizer when configured and + // when there are FDv2 synchronizers to fall back from. + if (fdv1Endpoints && synchronizerSlots.length > 0) { + const fdv1RequestorFactory = () => + makeRequestor( + ctx.plainContextString, + ctx.serviceEndpoints, + fdv1Endpoints.polling(), + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + config.withReasons, + config.useReport, + ); + + const fdv1SyncFactory = () => + createFDv1PollingSynchronizer(fdv1RequestorFactory(), config.pollInterval * 1000, logger); + + synchronizerSlots.push(createSynchronizerSlot(fdv1SyncFactory, { isFDv1Fallback: true })); + } + + return { initializerFactories, synchronizerSlots }; + } + + /** + * The data callback shared across all FDv2DataSource instances for + * the current identify. Handles selector tracking and flag updates. + */ + function dataCallback(payload: internal.Payload): void { + logger.debug( + `${logTag} dataCallback: type=${payload.type}, updates=${payload.updates.length}, state=${payload.state}`, + ); + + selector = payload.state; + + const context = identifiedContext; + if (!context) { + logger.warn(`${logTag} dataCallback called without an identified context.`); + return; + } + + const descriptors = flagEvalPayloadToItemDescriptors(payload.updates ?? []); + // Flag updates and change events happen synchronously inside applyChanges. + // The returned promise is only for async cache persistence — we intentionally + // do not await it so the data source pipeline is not blocked by storage I/O. + flagManager.applyChanges(context, descriptors, payload.type).catch((e) => { + logger.warn(`${logTag} Failed to persist flag cache: ${e}`); + }); + } + + /** + * Create and start a new FDv2DataSource for the given mode. + * + * @param mode The connection mode to use. + * @param includeInitializers Whether to include initializers (true on + * first identify, false on mode switch after initialization). + */ + function createAndStartDataSource(mode: FDv2ConnectionMode, includeInitializers: boolean): void { + if (!factoryContext) { + logger.warn(`${logTag} Cannot create data source without factory context.`); + return; + } + + const modeDef = getModeDefinition(mode); + const { initializerFactories, synchronizerSlots } = buildFactories( + modeDef, + factoryContext, + includeInitializers, + ); + + currentResolvedMode = mode; + + // If there are no sources at all (e.g., offline or one-shot mode + // post-initialization), don't create a data source. + if (initializerFactories.length === 0 && synchronizerSlots.length === 0) { + logger.debug(`${logTag} Mode '${mode}' has no sources. No data source created.`); + if (!initialized && pendingIdentifyResolve) { + // Offline mode during initial identify — resolve immediately. + // The SDK will use cached data if any. + initialized = true; + pendingIdentifyResolve(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + return; + } + + const selectorGetter = () => selector; + + dataSource = createFDv2DataSource({ + initializerFactories, + synchronizerSlots, + dataCallback, + statusManager, + selectorGetter, + logger, + fallbackTimeoutMs, + recoveryTimeoutMs, + }); + + dataSource + .start() + .then(() => { + initialized = true; + if (pendingIdentifyResolve) { + pendingIdentifyResolve(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + }) + .catch((err) => { + if (pendingIdentifyReject) { + pendingIdentifyReject(err instanceof Error ? err : new Error(String(err))); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + } + }); + } + + /** + * Reconciliation callback invoked when the debounce timer fires. + * Resolves the new mode and switches data sources if needed. + */ + function onReconcile(pendingState: PendingState): void { + if (closed || !factoryContext) { + return; + } + + // Update local state from the debounced pending state. + networkState = pendingState.networkState; + lifecycleState = pendingState.lifecycleState; + foregroundMode = pendingState.requestedMode; + + const newMode = resolveMode(); + + if (newMode === currentResolvedMode) { + logger.debug(`${logTag} Reconcile: mode unchanged (${newMode}). No action.`); + return; + } + + logger.debug( + `${logTag} Reconcile: mode switching from '${currentResolvedMode}' to '${newMode}'.`, + ); + + // Close the current data source. + dataSource?.close(); + dataSource = undefined; + + // Include initializers if we don't have a selector yet. This covers: + // - Not yet initialized (normal case) + // - Initialized from bootstrap (no selector) — need initializers to + // get a full payload via poll before starting synchronizers + // When we have a selector, only synchronizers change (spec 5.3.8). + const includeInitializers = !selector; + + createAndStartDataSource(newMode, includeInitializers); + } + + // --- Public interface --- + + return { + get configuredForegroundMode(): FDv2ConnectionMode { + return configuredForegroundMode; + }, + + async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + if (closed) { + logger.debug(`${logTag} Identify called after close.`); + return; + } + + // Tear down previous state. + dataSource?.close(); + dataSource = undefined; + debounceManager?.close(); + debounceManager = undefined; + selector = undefined; + initialized = false; + bootstrapped = false; + identifiedContext = context; + pendingIdentifyResolve = identifyResolve; + pendingIdentifyReject = identifyReject; + + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const queryParams = buildQueryParams(identifyOptions); + if (config.withReasons) { + queryParams.push({ key: 'withReasons', value: 'true' }); + } + const streamingEndpoints = endpoints.streaming(); + const pollingEndpoints = endpoints.polling(); + + const requestor = makeFDv2Requestor( + plainContextString, + config.serviceEndpoints, + pollingEndpoints, + platform.requests, + platform.encoding!, + baseHeaders, + queryParams, + ); + + const environmentNamespace = await namespaceForEnvironment( + platform.crypto, + baseConfig.credential, + ); + + // Re-check after the await — close() may have been called while + // namespaceForEnvironment was pending. + if (closed) { + logger.debug(`${logTag} Identify aborted: closed during async setup.`); + return; + } + + factoryContext = { + requestor, + requests: platform.requests, + encoding: platform.encoding!, + serviceEndpoints: config.serviceEndpoints, + baseHeaders, + queryParams, + plainContextString, + logger, + polling: { + paths: pollingEndpoints, + intervalSeconds: config.pollInterval, + }, + streaming: { + paths: streamingEndpoints, + initialReconnectDelaySeconds: config.streamInitialReconnectDelay, + }, + storage: platform.storage, + crypto: platform.crypto, + environmentNamespace, + context, + }; + + // Ensure foreground mode reflects current streaming state before resolving. + foregroundMode = resolveStreamingForeground(); + + // Resolve the initial mode. + const mode = resolveMode(); + logger.debug(`${logTag} Identify: initial mode resolved to '${mode}'.`); + + bootstrapped = identifyOptions?.bootstrap !== undefined; + + if (bootstrapped) { + // Bootstrap data was already applied to the flag store by the + // caller (BrowserClient.start → presetFlags) before identify + // was called. Resolve immediately — flag evaluations will use + // the bootstrap data synchronously. + initialized = true; + statusManager.requestStateUpdate('VALID'); + // selector remains undefined — bootstrap data has no selector. + pendingIdentifyResolve?.(); + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + + // Only create a data source if the mode has synchronizers. + // For one-shot (no synchronizers), there's nothing more to do. + const modeDef = getModeDefinition(mode); + if (modeDef.synchronizers.length > 0) { + // Start synchronizers without initializers — we already have + // data from bootstrap. Initializers will run on mode switches + // if selector is still undefined (see onReconcile). + createAndStartDataSource(mode, false); + } + } else { + // Normal identify — create and start the data source with full pipeline. + createAndStartDataSource(mode, true); + } + + // Set up debouncing for subsequent state changes. + debounceManager = createStateDebounceManager({ + initialState: { + networkState, + lifecycleState, + requestedMode: foregroundMode, + }, + onReconcile, + }); + }, + + close(): void { + closed = true; + dataSource?.close(); + dataSource = undefined; + debounceManager?.close(); + debounceManager = undefined; + pendingIdentifyResolve = undefined; + pendingIdentifyReject = undefined; + }, + + setNetworkState(state: NetworkState): void { + networkState = state; + debounceManager?.setNetworkState(state); + }, + + setLifecycleState(state: LifecycleState): void { + // Flush immediately when going to background — the app may be + // about to close. This is not debounced (CONNMODE spec 3.3.1). + if (state === 'background' && lifecycleState !== 'background') { + flushCallback?.(); + } + lifecycleState = state; + debounceManager?.setLifecycleState(state); + }, + + setConnectionMode(mode?: FDv2ConnectionMode): void { + connectionModeOverride = mode; + if (mode !== undefined) { + debounceManager?.setRequestedMode(mode); + } else { + pushForegroundMode(); + } + }, + + getCurrentMode(): FDv2ConnectionMode { + return currentResolvedMode; + }, + + setFlushCallback(callback: () => void): void { + flushCallback = callback; + }, + + setForcedStreaming(streaming?: boolean): void { + forcedStreaming = streaming; + pushForegroundMode(); + }, + + setAutomaticStreamingState(streaming: boolean): void { + automaticStreamingState = streaming; + pushForegroundMode(); + }, + }; +} diff --git a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts index 639b875176..430b057db7 100644 --- a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts @@ -4,15 +4,28 @@ import type { PlatformDataSystemDefaults } from '../api/datasource'; import { anyOf, validatorOf } from '../configuration/validateOptions'; import { connectionModesValidator, connectionModeValidator } from './ConnectionModeConfig'; -const modeSwitchingValidators = { +function hasType(u: unknown, type: string): boolean { + return TypeValidators.Object.is(u) && (u as Record).type === type; +} + +const automaticModeValidators = { + type: TypeValidators.oneOf('automatic'), lifecycle: TypeValidators.Boolean, network: TypeValidators.Boolean, }; -const dataSystemValidators = { +const manualModeValidators = { + type: TypeValidators.oneOf('manual'), initialConnectionMode: connectionModeValidator, +}; + +const dataSystemValidators = { backgroundConnectionMode: connectionModeValidator, - automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(modeSwitchingValidators)), + automaticModeSwitching: anyOf( + TypeValidators.Boolean, + validatorOf(automaticModeValidators, { is: (u) => hasType(u, 'automatic') }), + validatorOf(manualModeValidators, { is: (u) => hasType(u, 'manual') }), + ), connectionModes: connectionModesValidator, }; @@ -20,7 +33,7 @@ const dataSystemValidators = { * Default FDv2 data system configuration for browser SDKs. */ const BROWSER_DATA_SYSTEM_DEFAULTS: PlatformDataSystemDefaults = { - initialConnectionMode: 'one-shot', + foregroundConnectionMode: 'one-shot', backgroundConnectionMode: undefined, automaticModeSwitching: false, }; @@ -29,7 +42,7 @@ const BROWSER_DATA_SYSTEM_DEFAULTS: PlatformDataSystemDefaults = { * Default FDv2 data system configuration for mobile (React Native) SDKs. */ const MOBILE_DATA_SYSTEM_DEFAULTS: PlatformDataSystemDefaults = { - initialConnectionMode: 'streaming', + foregroundConnectionMode: 'streaming', backgroundConnectionMode: 'background', automaticModeSwitching: true, }; @@ -38,7 +51,7 @@ const MOBILE_DATA_SYSTEM_DEFAULTS: PlatformDataSystemDefaults = { * Default FDv2 data system configuration for desktop SDKs (Electron, etc.). */ const DESKTOP_DATA_SYSTEM_DEFAULTS: PlatformDataSystemDefaults = { - initialConnectionMode: 'streaming', + foregroundConnectionMode: 'streaming', backgroundConnectionMode: undefined, automaticModeSwitching: false, }; diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 3243cec7fc..ce5289524a 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -87,6 +87,7 @@ export type { ModeDefinition, LDClientDataSystemOptions, AutomaticModeSwitchingConfig, + ManualModeSwitching, PlatformDataSystemDefaults, LifecycleState, ModeState, @@ -124,3 +125,20 @@ export type { SourceFactoryProvider, } from './datasource/SourceFactoryProvider'; export { createDefaultSourceFactoryProvider } from './datasource/SourceFactoryProvider'; + +// FDv2 shared data manager — mode switching, debouncing, and data source lifecycle. +export type { + FDv2DataManagerBaseConfig, + FDv2DataManagerControl, +} from './datasource/FDv2DataManagerBase'; +export { createFDv2DataManagerBase } from './datasource/FDv2DataManagerBase'; + +// State debounce manager. +export type { + StateDebounceManager, + StateDebounceManagerConfig, + NetworkState, + PendingState, + ReconciliationCallback, +} from './datasource/StateDebounceManager'; +export { createStateDebounceManager } from './datasource/StateDebounceManager';