From e6a4d765ceae345e3d85e7713ecd0b804926e314 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:11:20 -0700 Subject: [PATCH 01/28] feat: add FDv2 types, refined validators, and DataManager interface extensions Add InitializerEntry/SynchronizerEntry types for type-safe mode definitions, refine protocolHandler validation to use isNullish for version/target fields, add connectionModes config option, split validator arrays for initializers vs synchronizers, and extend DataManager with optional streaming/flush methods. --- .../src/internal/fdv2/protocolHandler.ts | 38 +++++++++++++------ .../datasource/ConnectionModeConfig.test.ts | 38 +++++++++++++++++++ packages/shared/sdk-client/src/DataManager.ts | 27 +++++++++++++ .../src/api/datasource/DataSourceEntry.ts | 16 ++++++++ .../datasource/LDClientDataSystemOptions.ts | 24 +++++++++++- .../src/api/datasource/ModeDefinition.ts | 6 +-- .../sdk-client/src/api/datasource/index.ts | 2 + .../src/datasource/ConnectionModeConfig.ts | 11 ++++-- .../datasource/LDClientDataSystemOptions.ts | 3 +- .../src/datasource/fdv2/FDv2DataSource.ts | 4 ++ packages/shared/sdk-client/src/index.ts | 7 ++++ packages/shared/sdk-client/src/types/index.ts | 4 +- 12 files changed, 158 insertions(+), 22 deletions(-) diff --git a/packages/shared/common/src/internal/fdv2/protocolHandler.ts b/packages/shared/common/src/internal/fdv2/protocolHandler.ts index 5d13654ebf..3051c262ce 100644 --- a/packages/shared/common/src/internal/fdv2/protocolHandler.ts +++ b/packages/shared/common/src/internal/fdv2/protocolHandler.ts @@ -1,4 +1,5 @@ import { LDLogger } from '../../api'; +import { isNullish } from '../../validators'; import { DeleteObject, FDv2Event, @@ -111,7 +112,10 @@ export function createProtocolHandler( } function processIntentNone(intent: PayloadIntent): ProtocolAction { - if (!intent.id || !intent.target) { + if (!intent.id || isNullish(intent.target)) { + logger?.warn( + `Ignoring 'none' intent with missing fields: id=${intent.id}, target=${intent.target}`, + ); return ACTION_NONE; } @@ -164,14 +168,15 @@ export function createProtocolHandler( } function processPutObject(data: PutObject): ProtocolAction { - if ( - protocolState === 'inactive' || - !tempId || - !data.kind || - !data.key || - !data.version || - !data.object - ) { + if (protocolState === 'inactive' || !tempId) { + logger?.warn('Received put-object before server-intent was established. Ignoring.'); + return ACTION_NONE; + } + + if (!data.kind || !data.key || isNullish(data.version) || !data.object) { + logger?.warn( + `Ignoring put-object with missing fields: kind=${data.kind}, key=${data.key}, version=${data.version}`, + ); return ACTION_NONE; } @@ -191,7 +196,15 @@ export function createProtocolHandler( } function processDeleteObject(data: DeleteObject): ProtocolAction { - if (protocolState === 'inactive' || !tempId || !data.kind || !data.key || !data.version) { + if (protocolState === 'inactive' || !tempId) { + logger?.warn('Received delete-object before server-intent was established. Ignoring.'); + return ACTION_NONE; + } + + if (!data.kind || !data.key || isNullish(data.version)) { + logger?.warn( + `Ignoring delete-object with missing fields: kind=${data.kind}, key=${data.key}, version=${data.version}`, + ); return ACTION_NONE; } @@ -214,7 +227,10 @@ export function createProtocolHandler( }; } - if (!tempId || data.state === null || data.state === undefined || !data.version) { + if (!tempId || isNullish(data.state) || isNullish(data.version)) { + logger?.warn( + `Ignoring payload-transferred with missing fields: state=${data.state}, version=${data.version}`, + ); resetAll(); return ACTION_NONE; } diff --git a/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts b/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts index b2422620dc..25ae203897 100644 --- a/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts @@ -257,6 +257,44 @@ describe('given entries with invalid type field', () => { }); }); +describe('given cache entries in synchronizers', () => { + it('discards a cache entry from synchronizers and warns', () => { + const result = validateModeDefinition( + { initializers: [], synchronizers: [{ type: 'cache' }] }, + 'testMode', + logger, + ); + + expect(result.synchronizers).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('got cache')); + }); + + it('keeps valid synchronizer entries and discards cache', () => { + const result = validateModeDefinition( + { + initializers: [], + synchronizers: [{ type: 'polling' }, { type: 'cache' }, { type: 'streaming' }], + }, + 'testMode', + logger, + ); + + expect(result.synchronizers).toEqual([{ type: 'polling' }, { type: 'streaming' }]); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('allows cache as an initializer', () => { + const result = validateModeDefinition( + { initializers: [{ type: 'cache' }], synchronizers: [] }, + 'testMode', + logger, + ); + + expect(result.initializers).toEqual([{ type: 'cache' }]); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + describe('given polling entries with invalid config', () => { it('drops pollInterval when it is a string and warns', () => { const result = validateModeDefinition( diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index d379952c31..644b06d399 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -53,6 +53,33 @@ export interface DataManager { * Closes the data manager. Any active connections are closed. */ close(): void; + + /** + * Force streaming on or off. When `true`, the data manager should + * maintain a streaming connection. When `false`, streaming is disabled. + * When `undefined`, the forced state is cleared and automatic behavior + * takes over. + * + * Optional — only browser data managers implement this. + */ + setForcedStreaming?(streaming?: boolean): void; + + /** + * Update the automatic streaming state based on whether change listeners + * are registered. When `true` and forced streaming is not set, the data + * manager should activate streaming. + * + * Optional — only browser data managers implement this. + */ + setAutomaticStreamingState?(streaming: boolean): void; + + /** + * Set a callback to flush pending analytics events. Called immediately + * (not debounced) when the lifecycle transitions to background. + * + * Optional — only FDv2 data managers implement this. + */ + setFlushCallback?(callback: () => void): void; } /** diff --git a/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts b/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts index 873576edb9..0385fe6156 100644 --- a/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts +++ b/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts @@ -14,6 +14,7 @@ export interface EndpointConfig { /** * Configuration for a cache data source entry. + * Cache is only valid as an initializer (not a synchronizer). */ export interface CacheDataSourceEntry { readonly type: 'cache'; @@ -45,6 +46,21 @@ export interface StreamingDataSourceEntry { readonly endpoints?: EndpointConfig; } +/** + * An entry in the initializers list of a mode definition. Initializers + * can be cache, polling, or streaming sources. + */ +export type InitializerEntry = + | CacheDataSourceEntry + | PollingDataSourceEntry + | StreamingDataSourceEntry; + +/** + * An entry in the synchronizers list of a mode definition. Synchronizers + * can be polling or streaming sources (not cache). + */ +export type SynchronizerEntry = PollingDataSourceEntry | StreamingDataSourceEntry; + /** * A data source entry in a mode table. Each entry identifies a data source type * and carries type-specific configuration overrides. diff --git a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts index 470a481326..bcd45c1fc4 100644 --- a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts @@ -1,4 +1,5 @@ import FDv2ConnectionMode from './FDv2ConnectionMode'; +import { ModeDefinition } from './ModeDefinition'; // When FDv2 becomes the default, this should be integrated into the // main LDOptions interface (api/LDOptions.ts). @@ -48,8 +49,27 @@ export interface LDClientDataSystemOptions { */ automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig; - // Req 5.3.5 TBD — custom named modes reserved for future use. - // customModes?: Record; + /** + * Override the data source pipeline for specific connection modes. + * + * Each key is a connection mode name (`'streaming'`, `'polling'`, `'offline'`, + * `'one-shot'`, `'background'`). The value defines the initializers and + * synchronizers for that mode, replacing the built-in defaults. + * + * Only the modes you specify are overridden — unspecified modes retain + * their built-in definitions. + * + * @example + * ``` + * connectionModes: { + * streaming: { + * initializers: [{ type: 'polling' }], + * synchronizers: [{ type: 'streaming' }], + * }, + * } + * ``` + */ + connectionModes?: Partial>; } /** diff --git a/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts b/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts index 475eb4fb9b..97d221f923 100644 --- a/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts +++ b/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts @@ -1,4 +1,4 @@ -import { DataSourceEntry } from './DataSourceEntry'; +import { InitializerEntry, SynchronizerEntry } from './DataSourceEntry'; /** * Defines the data pipeline for a connection mode: which data sources @@ -10,7 +10,7 @@ export interface ModeDefinition { * Sources are tried in order; the first that successfully provides a full * data set transitions the SDK out of the initialization phase. */ - readonly initializers: ReadonlyArray; + readonly initializers: ReadonlyArray; /** * Ordered list of data sources for ongoing synchronization after @@ -18,5 +18,5 @@ export interface ModeDefinition { * failover to the next source if the primary fails. * An empty array means no synchronization occurs (e.g., offline, one-shot). */ - readonly synchronizers: ReadonlyArray; + readonly synchronizers: ReadonlyArray; } diff --git a/packages/shared/sdk-client/src/api/datasource/index.ts b/packages/shared/sdk-client/src/api/datasource/index.ts index e9a50e129d..95a688808b 100644 --- a/packages/shared/sdk-client/src/api/datasource/index.ts +++ b/packages/shared/sdk-client/src/api/datasource/index.ts @@ -4,6 +4,8 @@ export type { CacheDataSourceEntry, PollingDataSourceEntry, StreamingDataSourceEntry, + InitializerEntry, + SynchronizerEntry, DataSourceEntry, } from './DataSourceEntry'; export type { ModeDefinition } from './ModeDefinition'; diff --git a/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts b/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts index 03b0eb7082..51573af7e3 100644 --- a/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts +++ b/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts @@ -42,15 +42,20 @@ const streamingEntryValidators = { endpoints: validatorOf(endpointValidators), }; -const dataSourceEntryArrayValidator = arrayOf('type', { +const initializerEntryArrayValidator = arrayOf('type', { cache: cacheEntryValidators, polling: pollingEntryValidators, streaming: streamingEntryValidators, }); +const synchronizerEntryArrayValidator = arrayOf('type', { + polling: pollingEntryValidators, + streaming: streamingEntryValidators, +}); + const modeDefinitionValidators = { - initializers: dataSourceEntryArrayValidator, - synchronizers: dataSourceEntryArrayValidator, + initializers: initializerEntryArrayValidator, + synchronizers: synchronizerEntryArrayValidator, }; const MODE_DEFINITION_DEFAULTS: Record = { diff --git a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts index c1595dcb22..639b875176 100644 --- a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts @@ -2,7 +2,7 @@ import { TypeValidators } from '@launchdarkly/js-sdk-common'; import type { PlatformDataSystemDefaults } from '../api/datasource'; import { anyOf, validatorOf } from '../configuration/validateOptions'; -import { connectionModeValidator } from './ConnectionModeConfig'; +import { connectionModesValidator, connectionModeValidator } from './ConnectionModeConfig'; const modeSwitchingValidators = { lifecycle: TypeValidators.Boolean, @@ -13,6 +13,7 @@ const dataSystemValidators = { initialConnectionMode: connectionModeValidator, backgroundConnectionMode: connectionModeValidator, automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(modeSwitchingValidators)), + connectionModes: connectionModesValidator, }; /** diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index d8c122c908..6585163fc3 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -208,6 +208,10 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour recoveryTimeoutMs, ); + if (conditions.promise) { + logger?.warn('Fallback condition active for current synchronizer.'); + } + // try/finally ensures conditions are closed on all code paths. let synchronizerRunning = true; try { diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 192f812dba..048f24cc95 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -81,6 +81,8 @@ export type { CacheDataSourceEntry, PollingDataSourceEntry, StreamingDataSourceEntry, + InitializerEntry, + SynchronizerEntry, DataSourceEntry, ModeDefinition, LDClientDataSystemOptions, @@ -94,6 +96,10 @@ export type { ModeResolutionTable, } from './api/datasource'; +// FDv2 data source status manager. +export { createDataSourceStatusManager } from './datasource/DataSourceStatusManager'; +export type { DataSourceStatusManager } from './datasource/DataSourceStatusManager'; + // FDv2 data system validators and platform defaults. export { dataSystemValidators, @@ -104,6 +110,7 @@ export { // FDv2 connection mode type system — internal implementation. export type { ModeTable } from './datasource/ConnectionModeConfig'; +export { MODE_TABLE } from './datasource/ConnectionModeConfig'; export { resolveConnectionMode, MOBILE_TRANSITION_TABLE, diff --git a/packages/shared/sdk-client/src/types/index.ts b/packages/shared/sdk-client/src/types/index.ts index 9ffe3dbe31..19be292b8b 100644 --- a/packages/shared/sdk-client/src/types/index.ts +++ b/packages/shared/sdk-client/src/types/index.ts @@ -21,10 +21,10 @@ export type DeleteFlag = Pick; /** * Represents a pre-evaluated flag result for a specific context, as delivered - * by the FDv2 protocol via `put-object` events with `kind: 'flag_eval'`. + * by the FDv2 protocol via `put-object` events with `kind: 'flag-eval'`. * * This is the shape of the `object` field in a `put-object` event with - * `kind: 'flag_eval'`. It contains all the same fields as {@link Flag} except + * `kind: 'flag-eval'`. It contains all the same fields as {@link Flag} except * `version`, which is provided separately in the `put-object` envelope. * * There is no aggregate payload-level version field; per-flag versioning is From a4d5ad06bb8bd6b2eb7cdc780dd9a19fbcbc8fef Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:22:10 -0700 Subject: [PATCH 02/28] feat: add FlagManager.applyChanges for FDv2 full/partial/none semantics Add applyChanges method to FlagManager and FlagPersistence that handles full replacement, partial upsert, and no-change persistence in a single call, designed for the FDv2 data path. --- .../flag-manager/FlagManager.test.ts | 65 +++++++++++++++++++ .../src/flag-manager/FlagManager.ts | 25 ++++++- .../src/flag-manager/FlagPersistence.ts | 26 +++++++- 3 files changed, 114 insertions(+), 2 deletions(-) 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 5cb1fb0c76..314cd31279 100644 --- a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts @@ -3,6 +3,7 @@ import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import DefaultFlagManager from '../../src/flag-manager/FlagManager'; import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater'; import { + makeIncrementingStamper, makeMemoryStorage, makeMockCrypto, makeMockItemDescriptor, @@ -177,3 +178,67 @@ describe('FlagManager override tests', () => { expect(allFlags['override-only-flag'].flag.value).toBe('override-value'); }); }); + +describe('given a flag manager with storage', () => { + let flagManager: DefaultFlagManager; + let mockPlatform: Platform; + let mockLogger: LDLogger; + let storage: ReturnType; + + beforeEach(() => { + mockLogger = makeMockLogger(); + storage = makeMemoryStorage(); + mockPlatform = makeMockPlatform(storage, makeMockCrypto()); + flagManager = new DefaultFlagManager( + mockPlatform, + TEST_SDK_KEY, + TEST_MAX_CACHED_CONTEXTS, + false, + mockLogger, + makeIncrementingStamper(), + ); + }); + + it('replaces all flags when applyChanges is called with type full', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + existing: makeMockItemDescriptor(1, 'old'), + }); + + await flagManager.applyChanges( + context, + { 'new-flag': makeMockItemDescriptor(2, 'new') }, + 'full', + ); + + // type=full replaces, so existing flag should be gone. + expect(flagManager.get('existing')).toBeUndefined(); + expect(flagManager.get('new-flag')?.flag.value).toBe('new'); + }); + + it('upserts individual flags when applyChanges is called with type partial', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + existing: makeMockItemDescriptor(1, 'old'), + }); + + await flagManager.applyChanges(context, { added: makeMockItemDescriptor(2, 'new') }, 'partial'); + + // type=partial upserts, so existing flag should remain. + expect(flagManager.get('existing')?.flag.value).toBe('old'); + expect(flagManager.get('added')?.flag.value).toBe('new'); + }); + + it('persists cache when applyChanges is called with type none', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + flag1: makeMockItemDescriptor(1, 'value'), + }); + + // applyChanges with type none should still persist (updating freshness). + await flagManager.applyChanges(context, {}, 'none'); + + // Flag should still be present (no changes). + expect(flagManager.get('flag1')?.flag.value).toBe('value'); + }); +}); diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index 273b623a08..0ae031594c 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -1,4 +1,4 @@ -import { Context, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import { namespaceForEnvironment } from '../storage/namespaceUtils'; import FlagPersistence from './FlagPersistence'; @@ -55,6 +55,21 @@ export interface FlagManager { */ setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void; + /** + * Applies a changeset to the flag store. + * - `'full'`: replaces all flags (like {@link init}). + * - `'partial'`: upserts individual flags (like calling {@link upsert} for each entry). + * - `'none'`: persists cache (updating freshness) without changing any flags. + * + * Designed for the FDv2 data path where init/upsert semantics, selector + * tracking, and freshness updates are all handled in one call. + */ + applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise; + /** * Register a flag change callback. */ @@ -217,6 +232,14 @@ export default class DefaultFlagManager implements FlagManager { return (await this._flagPersistencePromise).loadCached(context); } + async applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise { + return (await this._flagPersistencePromise).applyChanges(context, updates, type); + } + on(callback: FlagsChangeCallback): void { this._flagUpdater.on(callback); } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts index df41882309..54c69eba93 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts @@ -1,4 +1,4 @@ -import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import { FRESHNESS_SUFFIX, FreshnessRecord, hashContext } from '../storage/freshness'; import { loadCachedFlags } from '../storage/loadCachedFlags'; @@ -59,6 +59,30 @@ export default class FlagPersistence { return false; } + /** + * Applies a changeset to the flag store. + * - `'full'`: replaces all flags via {@link FlagUpdater.init}. + * - `'partial'`: upserts individual flags via {@link FlagUpdater.upsert}. + * - `'none'`: no flag changes, only persists cache to update freshness. + * + * Always persists to cache afterwards, which updates the freshness timestamp + * even when no flags change (e.g., transfer-none). + */ + async applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise { + if (type === 'full') { + this._flagUpdater.init(context, updates); + } else if (type === 'partial') { + Object.entries(updates).forEach(([key, descriptor]) => { + this._flagUpdater.upsert(context, key, descriptor); + }); + } + await this._storeCache(context); + } + /** * Loads the flags from persistence for the provided context and gives those to the * {@link FlagUpdater} this {@link FlagPersistence} was constructed with. From cb9d6b6c885b5edcb42ba636a401c5483efb0af0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:29:36 -0700 Subject: [PATCH 03/28] feat: add SourceFactoryProvider for declarative data source creation Add SourceFactoryProvider that converts declarative InitializerEntry and SynchronizerEntry config into concrete initializer factories and synchronizer slots, with support for per-entry endpoint and interval overrides. --- .../datasource/SourceFactoryProvider.test.ts | 310 ++++++++++++++++++ .../src/datasource/SourceFactoryProvider.ts | 233 +++++++++++++ packages/shared/sdk-client/src/index.ts | 7 + 3 files changed, 550 insertions(+) create mode 100644 packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts create mode 100644 packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts diff --git a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts new file mode 100644 index 0000000000..2faeec0b8c --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts @@ -0,0 +1,310 @@ +import { + Context, + Crypto, + Encoding, + LDLogger, + Requests, + ServiceEndpoints, +} from '@launchdarkly/js-sdk-common'; + +import { InitializerEntry, SynchronizerEntry } from '../../src/api/datasource'; +import { DataSourcePaths } from '../../src/datasource/DataSourceConfig'; +import { createCacheInitializerFactory } from '../../src/datasource/fdv2/CacheInitializer'; +import { FDv2Requestor, makeFDv2Requestor } from '../../src/datasource/fdv2/FDv2Requestor'; +import { createPollingInitializer } from '../../src/datasource/fdv2/PollingInitializer'; +import { createPollingSynchronizer } from '../../src/datasource/fdv2/PollingSynchronizer'; +import { createSynchronizerSlot } from '../../src/datasource/fdv2/SourceManager'; +import { createStreamingBase } from '../../src/datasource/fdv2/StreamingFDv2Base'; +import { createStreamingInitializer } from '../../src/datasource/fdv2/StreamingInitializerFDv2'; +import { createStreamingSynchronizer } from '../../src/datasource/fdv2/StreamingSynchronizerFDv2'; +import { + createDefaultSourceFactoryProvider, + SourceFactoryContext, +} from '../../src/datasource/SourceFactoryProvider'; + +jest.mock('../../src/datasource/fdv2/PollingInitializer'); +jest.mock('../../src/datasource/fdv2/PollingSynchronizer'); +jest.mock('../../src/datasource/fdv2/StreamingFDv2Base'); +jest.mock('../../src/datasource/fdv2/StreamingInitializerFDv2'); +jest.mock('../../src/datasource/fdv2/StreamingSynchronizerFDv2'); +jest.mock('../../src/datasource/fdv2/CacheInitializer'); +jest.mock('../../src/datasource/fdv2/FDv2Requestor'); +jest.mock('../../src/datasource/fdv2/PollingBase'); + +const mockCreatePollingInitializer = createPollingInitializer as jest.Mock; +const mockCreatePollingSynchronizer = createPollingSynchronizer as jest.Mock; +const mockCreateStreamingBase = createStreamingBase as jest.Mock; +const mockCreateStreamingInitializer = createStreamingInitializer as jest.Mock; +const mockCreateStreamingSynchronizer = createStreamingSynchronizer as jest.Mock; +const mockCreateCacheInitializerFactory = createCacheInitializerFactory as jest.Mock; +const mockMakeFDv2Requestor = makeFDv2Requestor as jest.Mock; +const mockCreateSynchronizerSlot = createSynchronizerSlot as jest.Mock; + +jest.mock('../../src/datasource/fdv2/SourceManager', () => ({ + createSynchronizerSlot: jest.fn((factory: any) => ({ + factory, + isFDv1Fallback: false, + state: 'available', + })), +})); + +function makeContext(): Context { + return Context.fromLDContext({ kind: 'user', key: 'test-user' }); +} + +function makePaths(): DataSourcePaths { + return { + pathGet: jest.fn().mockReturnValue('/eval/test-path'), + pathReport: jest.fn().mockReturnValue('/eval/report-path'), + pathPost: jest.fn().mockReturnValue('/eval/post-path'), + pathPing: jest.fn().mockReturnValue('/eval/ping-path'), + }; +} + +function makeSourceFactoryContext(overrides?: Partial): SourceFactoryContext { + return { + requestor: { poll: jest.fn() } as unknown as FDv2Requestor, + requests: {} as Requests, + encoding: {} as Encoding, + serviceEndpoints: new ServiceEndpoints( + 'https://stream.example.com', + 'https://poll.example.com', + 'https://events.example.com', + ), + pollingPaths: makePaths(), + streamingPaths: makePaths(), + baseHeaders: { authorization: 'sdk-key' }, + queryParams: [], + plainContextString: '{"kind":"user","key":"test-user"}', + selectorGetter: () => undefined, + streamInitialReconnectDelay: 1, + pollInterval: 30, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as LDLogger, + storage: undefined, + crypto: {} as Crypto, + environmentNamespace: 'test-env', + context: makeContext(), + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockCreatePollingInitializer.mockReturnValue({ close: jest.fn() }); + mockCreatePollingSynchronizer.mockReturnValue({ close: jest.fn() }); + mockCreateStreamingBase.mockReturnValue({ + start: jest.fn(), + close: jest.fn(), + takeResult: jest.fn(), + }); + mockCreateStreamingInitializer.mockReturnValue({ close: jest.fn() }); + mockCreateStreamingSynchronizer.mockReturnValue({ close: jest.fn() }); + mockCreateCacheInitializerFactory.mockReturnValue(jest.fn()); + mockMakeFDv2Requestor.mockReturnValue({ poll: jest.fn() }); +}); + +// --- createInitializerFactory --- + +it('creates a PollingInitializer for a polling initializer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { type: 'polling' }; + + const factory = provider.createInitializerFactory(entry, ctx); + + expect(factory).toBeDefined(); + const selectorGetter = () => 'some-selector'; + factory!(selectorGetter); + expect(mockCreatePollingInitializer).toHaveBeenCalledWith( + ctx.requestor, + ctx.logger, + selectorGetter, + ); +}); + +it('creates a StreamingInitializer for a streaming initializer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { type: 'streaming' }; + + const factory = provider.createInitializerFactory(entry, ctx); + + expect(factory).toBeDefined(); + const selectorGetter = () => 'some-selector'; + factory!(selectorGetter); + expect(mockCreateStreamingBase).toHaveBeenCalledWith( + expect.objectContaining({ + requests: ctx.requests, + serviceEndpoints: ctx.serviceEndpoints, + initialRetryDelayMillis: ctx.streamInitialReconnectDelay * 1000, + }), + ); + expect(mockCreateStreamingInitializer).toHaveBeenCalledWith( + mockCreateStreamingBase.mock.results[0].value, + ); +}); + +it('creates a CacheInitializer for a cache initializer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { type: 'cache' }; + + const factory = provider.createInitializerFactory(entry, ctx); + + expect(mockCreateCacheInitializerFactory).toHaveBeenCalledWith({ + storage: ctx.storage, + crypto: ctx.crypto, + environmentNamespace: ctx.environmentNamespace, + context: ctx.context, + logger: ctx.logger, + }); + expect(factory).toBe(mockCreateCacheInitializerFactory.mock.results[0].value); +}); + +it('returns undefined for an unknown initializer entry type', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry = { type: 'unknown' } as unknown as InitializerEntry; + + const factory = provider.createInitializerFactory(entry, ctx); + + expect(factory).toBeUndefined(); +}); + +// --- createSynchronizerSlot --- + +it('creates a PollingSynchronizer slot for a polling synchronizer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: SynchronizerEntry = { type: 'polling' }; + + const slot = provider.createSynchronizerSlot(entry, ctx); + + expect(slot).toBeDefined(); + expect(mockCreateSynchronizerSlot).toHaveBeenCalled(); + + // Invoke the factory that was passed to createSynchronizerSlot + const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; + const selectorGetter = () => 'sel'; + factoryArg(selectorGetter); + expect(mockCreatePollingSynchronizer).toHaveBeenCalledWith( + ctx.requestor, + ctx.logger, + selectorGetter, + ctx.pollInterval * 1000, + ); +}); + +it('creates a StreamingSynchronizer slot for a streaming synchronizer entry', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: SynchronizerEntry = { type: 'streaming' }; + + const slot = provider.createSynchronizerSlot(entry, ctx); + + expect(slot).toBeDefined(); + expect(mockCreateSynchronizerSlot).toHaveBeenCalled(); + + // Invoke the factory that was passed to createSynchronizerSlot + const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; + const selectorGetter = () => 'sel'; + factoryArg(selectorGetter); + expect(mockCreateStreamingBase).toHaveBeenCalledWith( + expect.objectContaining({ + requests: ctx.requests, + serviceEndpoints: ctx.serviceEndpoints, + initialRetryDelayMillis: ctx.streamInitialReconnectDelay * 1000, + }), + ); + expect(mockCreateStreamingSynchronizer).toHaveBeenCalledWith( + mockCreateStreamingBase.mock.results[0].value, + ); +}); + +it('returns undefined for an unknown synchronizer entry type', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry = { type: 'unknown' } as unknown as SynchronizerEntry; + + const slot = provider.createSynchronizerSlot(entry, ctx); + + expect(slot).toBeUndefined(); +}); + +// --- per-entry overrides --- + +it('creates a new requestor when polling entry has endpoint overrides', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { + type: 'polling', + endpoints: { pollingBaseUri: 'https://custom-poll.example.com' }, + }; + + const factory = provider.createInitializerFactory(entry, ctx); + expect(factory).toBeDefined(); + + const selectorGetter = () => undefined; + factory!(selectorGetter); + + expect(mockMakeFDv2Requestor).toHaveBeenCalledWith( + ctx.plainContextString, + expect.objectContaining({ + polling: 'https://custom-poll.example.com', + streaming: 'https://stream.example.com', + }), + ctx.pollingPaths, + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + ); + + // Should use the new requestor, not the context one + const newRequestor = mockMakeFDv2Requestor.mock.results[0].value; + expect(mockCreatePollingInitializer).toHaveBeenCalledWith( + newRequestor, + ctx.logger, + selectorGetter, + ); +}); + +it('uses per-entry pollInterval override for polling synchronizer', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext({ pollInterval: 30 }); + const entry: SynchronizerEntry = { type: 'polling', pollInterval: 60 }; + + provider.createSynchronizerSlot(entry, ctx); + + const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; + const selectorGetter = () => undefined; + factoryArg(selectorGetter); + + expect(mockCreatePollingSynchronizer).toHaveBeenCalledWith( + ctx.requestor, + ctx.logger, + selectorGetter, + 60000, + ); +}); + +it('uses per-entry initialReconnectDelay override for streaming initializer', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext({ streamInitialReconnectDelay: 1 }); + const entry: InitializerEntry = { type: 'streaming', initialReconnectDelay: 5 }; + + const factory = provider.createInitializerFactory(entry, ctx); + expect(factory).toBeDefined(); + factory!(() => undefined); + + expect(mockCreateStreamingBase).toHaveBeenCalledWith( + expect.objectContaining({ + initialRetryDelayMillis: 5000, + }), + ); +}); diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts new file mode 100644 index 0000000000..5ca2a3a679 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -0,0 +1,233 @@ +import { + Context, + Crypto, + Encoding, + LDHeaders, + LDLogger, + Requests, + ServiceEndpoints, + Storage, +} from '@launchdarkly/js-sdk-common'; + +import { EndpointConfig, InitializerEntry, SynchronizerEntry } from '../api/datasource'; +import { DataSourcePaths } from './DataSourceConfig'; +import { createCacheInitializerFactory } from './fdv2/CacheInitializer'; +import { FDv2Requestor, makeFDv2Requestor } from './fdv2/FDv2Requestor'; +import { poll as fdv2Poll } from './fdv2/PollingBase'; +import { createPollingInitializer } from './fdv2/PollingInitializer'; +import { createPollingSynchronizer } from './fdv2/PollingSynchronizer'; +import { createSynchronizerSlot, InitializerFactory, SynchronizerSlot } from './fdv2/SourceManager'; +import { createStreamingBase, PingHandler } from './fdv2/StreamingFDv2Base'; +import { createStreamingInitializer } from './fdv2/StreamingInitializerFDv2'; +import { createStreamingSynchronizer } from './fdv2/StreamingSynchronizerFDv2'; + +/** + * Context needed to create concrete initializer/synchronizer factories + * for a given identify call. Built once per identify and reused across + * mode switches. + */ +export interface SourceFactoryContext { + /** The FDv2 requestor for polling requests. */ + requestor: FDv2Requestor; + /** Platform request abstraction. */ + requests: Requests; + /** Platform encoding abstraction. */ + encoding: Encoding; + /** Service endpoint configuration. */ + serviceEndpoints: ServiceEndpoints; + /** The polling endpoint paths. */ + pollingPaths: DataSourcePaths; + /** The streaming endpoint paths. */ + streamingPaths: DataSourcePaths; + /** Default HTTP headers. */ + baseHeaders: LDHeaders; + /** Query parameters for requests (e.g., auth, secure mode hash). */ + queryParams: { key: string; value: string }[]; + /** JSON-serialized evaluation context. */ + plainContextString: string; + /** Getter for the current selector (basis) string. */ + selectorGetter: () => string | undefined; + /** Initial reconnect delay for streaming, in seconds. */ + streamInitialReconnectDelay: number; + /** Poll interval in seconds. */ + pollInterval: number; + /** Logger. */ + logger: LDLogger; + + // Cache-related fields (needed for cache initializer). + /** 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 being identified. */ + context: Context; +} + +/** + * Converts declarative {@link InitializerEntry} and {@link SynchronizerEntry} + * descriptors from the mode table into concrete {@link InitializerFactory} + * and {@link SynchronizerSlot} instances that the {@link FDv2DataSource} + * orchestrator can use. + */ +export interface SourceFactoryProvider { + /** + * Create an initializer factory from an initializer entry descriptor. + * Returns `undefined` if the entry type is not supported. + */ + createInitializerFactory( + entry: InitializerEntry, + ctx: SourceFactoryContext, + ): InitializerFactory | undefined; + + /** + * Create a synchronizer slot from a synchronizer entry descriptor. + * Returns `undefined` if the entry type is not supported. + */ + createSynchronizerSlot( + entry: SynchronizerEntry, + ctx: SourceFactoryContext, + ): SynchronizerSlot | undefined; +} + +function createPingHandler(ctx: SourceFactoryContext): PingHandler { + return { + handlePing: () => fdv2Poll(ctx.requestor, ctx.selectorGetter(), false, ctx.logger), + }; +} + +/** + * Create a {@link ServiceEndpoints} with per-entry endpoint overrides applied. + * Returns the original endpoints if no overrides are specified. + */ +function resolveEndpoints(ctx: SourceFactoryContext, endpoints?: EndpointConfig): ServiceEndpoints { + if (!endpoints?.pollingBaseUri && !endpoints?.streamingBaseUri) { + return ctx.serviceEndpoints; + } + return new ServiceEndpoints( + endpoints.streamingBaseUri ?? ctx.serviceEndpoints.streaming, + endpoints.pollingBaseUri ?? ctx.serviceEndpoints.polling, + ctx.serviceEndpoints.events, + ctx.serviceEndpoints.analyticsEventPath, + ctx.serviceEndpoints.diagnosticEventPath, + ctx.serviceEndpoints.includeAuthorizationHeader, + ctx.serviceEndpoints.payloadFilterKey, + ); +} + +/** + * Get the FDv2 requestor for a polling entry. If the entry has custom + * endpoints, creates a new requestor targeting those endpoints. Otherwise + * returns the shared requestor from the context. + */ +function resolvePollingRequestor( + ctx: SourceFactoryContext, + endpoints?: EndpointConfig, +): FDv2Requestor { + if (!endpoints?.pollingBaseUri) { + return ctx.requestor; + } + const overriddenEndpoints = resolveEndpoints(ctx, endpoints); + return makeFDv2Requestor( + ctx.plainContextString, + overriddenEndpoints, + ctx.pollingPaths, + ctx.requests, + ctx.encoding, + ctx.baseHeaders, + ctx.queryParams, + ); +} + +/** + * Creates a {@link SourceFactoryProvider} that handles `cache`, `polling`, + * and `streaming` data source entries. + */ +export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { + return { + createInitializerFactory( + entry: InitializerEntry, + ctx: SourceFactoryContext, + ): InitializerFactory | undefined { + switch (entry.type) { + case 'polling': { + const requestor = resolvePollingRequestor(ctx, entry.endpoints); + return (sg: () => string | undefined) => + createPollingInitializer(requestor, ctx.logger, sg); + } + + case 'streaming': { + const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); + return (sg: () => string | undefined) => { + const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); + const base = createStreamingBase({ + requests: ctx.requests, + serviceEndpoints: entryEndpoints, + streamUriPath, + parameters: ctx.queryParams, + selectorGetter: sg, + headers: ctx.baseHeaders, + initialRetryDelayMillis: + (entry.initialReconnectDelay ?? ctx.streamInitialReconnectDelay) * 1000, + logger: ctx.logger, + pingHandler: createPingHandler(ctx), + }); + return createStreamingInitializer(base); + }; + } + + case 'cache': + return createCacheInitializerFactory({ + storage: ctx.storage, + crypto: ctx.crypto, + environmentNamespace: ctx.environmentNamespace, + context: ctx.context, + logger: ctx.logger, + }); + + default: + return undefined; + } + }, + + createSynchronizerSlot( + entry: SynchronizerEntry, + ctx: SourceFactoryContext, + ): SynchronizerSlot | undefined { + switch (entry.type) { + case 'polling': { + const intervalMs = (entry.pollInterval ?? ctx.pollInterval) * 1000; + const requestor = resolvePollingRequestor(ctx, entry.endpoints); + const factory = (sg: () => string | undefined) => + createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs); + return createSynchronizerSlot(factory); + } + + case 'streaming': { + const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); + const factory = (sg: () => string | undefined) => { + const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); + const base = createStreamingBase({ + requests: ctx.requests, + serviceEndpoints: entryEndpoints, + streamUriPath, + parameters: ctx.queryParams, + selectorGetter: sg, + headers: ctx.baseHeaders, + initialRetryDelayMillis: + (entry.initialReconnectDelay ?? ctx.streamInitialReconnectDelay) * 1000, + logger: ctx.logger, + pingHandler: createPingHandler(ctx), + }); + return createStreamingSynchronizer(base); + }; + return createSynchronizerSlot(factory); + } + + default: + return undefined; + } + }, + }; +} diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 048f24cc95..3243cec7fc 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -117,3 +117,10 @@ export { BROWSER_TRANSITION_TABLE, DESKTOP_TRANSITION_TABLE, } from './datasource/ModeResolver'; + +// FDv2 source factory provider — converts declarative config to concrete factories. +export type { + SourceFactoryContext, + SourceFactoryProvider, +} from './datasource/SourceFactoryProvider'; +export { createDefaultSourceFactoryProvider } from './datasource/SourceFactoryProvider'; From 455e0571a568c145c6e5658cc152493647b44720 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:35:31 -0700 Subject: [PATCH 04/28] feat: add FDv2DataManagerBase for mode switching and data source lifecycle Add shared data manager that orchestrates FDv2 connection mode switching, state debouncing, streaming control, and data source lifecycle management. Implements the DataManager interface with support for foreground/background modes, forced/automatic streaming, and flush callbacks. --- .../datasource/FDv2DataManagerBase.test.ts | 971 ++++++++++++++++++ .../src/datasource/FDv2DataManagerBase.ts | 585 +++++++++++ packages/shared/sdk-client/src/index.ts | 17 + 3 files changed, 1573 insertions(+) create mode 100644 packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts create mode 100644 packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts 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..6dde24cd1f --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -0,0 +1,971 @@ +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(), + 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, + initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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('delegates setRequestedMode to debounce manager', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig()); + await identifyManager(manager); + + manager.setRequestedMode('streaming'); + + expect(mockDebounceManager.setRequestedMode).toHaveBeenCalledWith('streaming'); + + manager.close(); +}); + +it('skips cache initializer on mode switch when bootstrapped', async () => { + const sourceFactoryProvider = makeSourceFactoryProvider(); + const manager = createFDv2DataManagerBase( + makeBaseConfig({ + initialForegroundMode: '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({ initialForegroundMode: 'polling' })); + + expect(manager.configuredForegroundMode).toBe('polling'); + + manager.close(); +}); + +it('reports the initial resolved mode via getCurrentMode', async () => { + const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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({ initialForegroundMode: '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. + initialForegroundMode: '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. + initialForegroundMode: '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. + initialForegroundMode: '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. + initialForegroundMode: '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('exposes the selectorGetter in the factory context that reads current selector', 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); + + // Initially selector is undefined. + expect(capturedCtx.selectorGetter()).toBeUndefined(); + + // Deliver a payload with a selector via dataCallback. + const dsConfig = capturedDataSourceConfigs[0]; + dsConfig.dataCallback({ type: 'full', updates: [], state: 'new-selector' }); + + // Now selectorGetter should return the selector. + expect(capturedCtx.selectorGetter()).toBe('new-selector'); + + manager.close(); +}); 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..df9200c473 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -0,0 +1,585 @@ +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 initial foreground connection mode. */ + initialForegroundMode: 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; + /** Update the requested connection mode. Goes through debounce. */ + setRequestedMode(mode: FDv2ConnectionMode): void; + /** + * Set the effective foreground mode directly. Used by browser + * listener-driven streaming to promote/demote the foreground mode. + * Goes through debounce. + */ + setForegroundMode(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, + initialForegroundMode, + 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 = initialForegroundMode; + let foregroundMode: FDv2ConnectionMode = initialForegroundMode; + 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; + + // 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', + }; + } + + function resolveMode(): FDv2ConnectionMode { + return resolveConnectionMode(transitionTable, buildModeState()); + } + + /** + * Determine the foreground mode based on forced/automatic streaming state. + * + * +-----------+-----------+---------------------------+ + * | forced | automatic | result | + * +-----------+-----------+---------------------------+ + * | true | any | 'streaming' | + * | false | any | configured, never streaming| + * | undefined | true | 'streaming' | + * | undefined | false | configured mode | + * +-----------+-----------+---------------------------+ + */ + function resolveStreamingMode(): FDv2ConnectionMode { + if (forcedStreaming === true) { + return 'streaming'; + } + if (forcedStreaming === false) { + // Explicitly forced off — use configured mode, but never streaming. + return initialForegroundMode === 'streaming' ? 'one-shot' : initialForegroundMode; + } + // forcedStreaming === undefined — automatic behavior. + return automaticStreamingState ? 'streaming' : initialForegroundMode; + } + + /** + * 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}`, + ); + + if (payload.state) { + selector = payload.state; + } + + const context = identifiedContext; + if (!context) { + logger.warn(`${logTag} dataCallback called without an identified context.`); + return; + } + + if (payload.type === 'none') { + // Spec 5.2.2: transfer-none confirms data is still current. + // Persist cache to update freshness timestamp without changing flags. + flagManager.applyChanges(context, {}, 'none'); + return; + } + + const descriptors = flagEvalPayloadToItemDescriptors(payload.updates); + flagManager.applyChanges(context, descriptors, payload.type); + } + + /** + * 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 initialForegroundMode; + }, + + 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, + ); + + factoryContext = { + requestor, + requests: platform.requests, + encoding: platform.encoding!, + serviceEndpoints: config.serviceEndpoints, + pollingPaths: pollingEndpoints, + streamingPaths: streamingEndpoints, + baseHeaders, + queryParams, + plainContextString, + selectorGetter: () => selector, + streamInitialReconnectDelay: config.streamInitialReconnectDelay, + pollInterval: config.pollInterval, + logger, + storage: platform.storage, + crypto: platform.crypto, + environmentNamespace, + context, + }; + + // Resolve the initial mode. + const mode = resolveMode(); + logger.debug(`${logTag} Identify: initial mode resolved to '${mode}'.`); + + bootstrapped = !!(identifyOptions as any)?.bootstrap; + + 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); + }, + + setRequestedMode(mode: FDv2ConnectionMode): void { + foregroundMode = mode; + debounceManager?.setRequestedMode(mode); + }, + + setForegroundMode(mode: FDv2ConnectionMode): void { + foregroundMode = mode; + debounceManager?.setRequestedMode(mode); + }, + + getCurrentMode(): FDv2ConnectionMode { + return currentResolvedMode; + }, + + setFlushCallback(callback: () => void): void { + flushCallback = callback; + }, + + setForcedStreaming(streaming?: boolean): void { + forcedStreaming = streaming; + this.setForegroundMode(resolveStreamingMode()); + }, + + setAutomaticStreamingState(streaming: boolean): void { + automaticStreamingState = streaming; + this.setForegroundMode(resolveStreamingMode()); + }, + }; +} diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 3243cec7fc..4b7e252b47 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -124,3 +124,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'; From a908f6f5d0edee922dd61498c413a00acf09d9fe Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:59:26 -0700 Subject: [PATCH 05/28] feat: wire FDv2 data manager into BrowserClient Conditionally use FDv2DataManagerBase when dataSystem config is present, falling back to BrowserDataManager for FDv1. Update CI bundle size limits to account for new FDv2 code paths. --- .github/workflows/browser.yml | 2 +- .github/workflows/combined-browser.yml | 2 +- .github/workflows/common.yml | 2 +- .github/workflows/sdk-client.yml | 2 +- .../browser/__tests__/BrowserClient.test.ts | 68 ++++++++++ packages/sdk/browser/src/BrowserClient.ts | 126 +++++++++++------- 6 files changed, 149 insertions(+), 53 deletions(-) diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index be1be7e032..3cccf15cae 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -41,7 +41,7 @@ jobs: target_file: 'packages/sdk/browser/dist/index.js' package_name: '@launchdarkly/js-client-sdk' pr_number: ${{ github.event.number }} - size_limit: 25000 + size_limit: 34000 # Contract Tests - name: Install contract test dependencies diff --git a/.github/workflows/combined-browser.yml b/.github/workflows/combined-browser.yml index 159a6f9e7a..e83eb4b87b 100644 --- a/.github/workflows/combined-browser.yml +++ b/.github/workflows/combined-browser.yml @@ -41,4 +41,4 @@ jobs: target_file: 'packages/sdk/combined-browser/dist/index.js' package_name: '@launchdarkly/browser' pr_number: ${{ github.event.number }} - size_limit: 200000 + size_limit: 194000 diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index f0161d845d..9034131d1d 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -35,4 +35,4 @@ jobs: target_file: 'packages/shared/common/dist/esm/index.mjs' package_name: '@launchdarkly/js-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 26000 + size_limit: 29000 diff --git a/.github/workflows/sdk-client.yml b/.github/workflows/sdk-client.yml index 8b3ba882b1..3f3d91f7cb 100644 --- a/.github/workflows/sdk-client.yml +++ b/.github/workflows/sdk-client.yml @@ -32,4 +32,4 @@ jobs: target_file: 'packages/shared/sdk-client/dist/esm/index.mjs' package_name: '@launchdarkly/js-client-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 24000 + size_limit: 38000 diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index f105205f8a..1eaa579f92 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -872,4 +872,72 @@ describe('given a mock platform for a BrowserClient', () => { // Verify that no fetch calls were made expect(platform.requests.fetch.mock.calls.length).toBe(0); }); + + it('uses FDv1 endpoints when dataSystem is not set', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { streaming: false, logger, diagnosticOptOut: true, sendEvents: false, fetchGoals: false }, + platform, + ); + + await client.start(); + + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/evalx/'); + expect(fetchUrl).not.toContain('/sdk/poll/eval'); + }); + + it('uses FDv2 endpoints when dataSystem is set', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + // @ts-ignore dataSystem is @internal + dataSystem: {}, + }, + platform, + ); + + await client.start(); + + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/poll/eval/'); + }); + + it('validates dataSystem options and applies browser defaults', async () => { + const client = makeClient( + 'client-side-id', + { key: 'user-key', kind: 'user' }, + AutoEnvAttributes.Disabled, + { + streaming: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + fetchGoals: false, + // @ts-ignore dataSystem is @internal + dataSystem: { initialConnectionMode: 'invalid-mode' }, + }, + platform, + ); + + // Invalid mode should produce a warning + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('dataSystem.initialConnectionMode'), + ); + + await client.start(); + + // Should still use FDv2 — invalid sub-fields fall back to defaults, not disable FDv2 + const fetchUrl = platform.requests.fetch.mock.calls[0][0]; + expect(fetchUrl).toContain('/sdk/poll/eval/'); + }); }); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index a9de2937c1..678c3c1b26 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -2,11 +2,16 @@ import { AutoEnvAttributes, BasicLogger, BROWSER_DATA_SYSTEM_DEFAULTS, + BROWSER_TRANSITION_TABLE, browserFdv1Endpoints, Configuration, + createDefaultSourceFactoryProvider, + createFDv2DataManagerBase, + FDv2ConnectionMode, FlagManager, Hook, internal, + LDIdentifyOptions as LDBaseIdentifyOptions, LDClientImpl, LDContext, LDEmitter, @@ -17,6 +22,7 @@ import { LDPluginEnvironmentMetadata, LDWaitForInitializationOptions, LDWaitForInitializationResult, + MODE_TABLE, Platform, readFlagsFromBootstrap, safeRegisterDebugOverridePlugins, @@ -78,57 +84,83 @@ class BrowserClientImpl extends LDClientImpl { const { eventUrlTransformer } = validatedBrowserOptions; const endpoints = browserFdv1Endpoints(clientSideId); - super( - clientSideId, - autoEnvAttributes, - platform, - baseOptionsWithDefaults, - ( - flagManager: FlagManager, - configuration: Configuration, - baseHeaders: LDHeaders, - emitter: LDEmitter, - diagnosticsManager?: internal.DiagnosticsManager, - ) => - new BrowserDataManager( + const dataManagerFactory = ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => { + if (configuration.dataSystem) { + const initialForegroundMode: FDv2ConnectionMode = + (configuration.dataSystem.initialConnectionMode as FDv2ConnectionMode) ?? 'one-shot'; + + return createFDv2DataManagerBase({ platform, flagManager, - clientSideId, - configuration, - validatedBrowserOptions, - endpoints.polling, - endpoints.streaming, + credential: clientSideId, + config: configuration, baseHeaders, emitter, - diagnosticsManager, + transitionTable: BROWSER_TRANSITION_TABLE, + initialForegroundMode, + backgroundMode: undefined, + modeTable: MODE_TABLE, + sourceFactoryProvider: createDefaultSourceFactoryProvider(), + fdv1Endpoints: browserFdv1Endpoints(clientSideId), + buildQueryParams: (identifyOptions?: LDBaseIdentifyOptions) => { + const params: { key: string; value: string }[] = [{ key: 'auth', value: clientSideId }]; + const browserOpts = identifyOptions as LDIdentifyOptions | undefined; + if (browserOpts?.hash) { + params.push({ key: 'h', value: browserOpts.hash }); + } + return params; + }, + }); + } + + return new BrowserDataManager( + platform, + flagManager, + clientSideId, + configuration, + validatedBrowserOptions, + endpoints.polling, + endpoints.streaming, + baseHeaders, + emitter, + diagnosticsManager, + ); + }; + + super(clientSideId, autoEnvAttributes, platform, baseOptionsWithDefaults, dataManagerFactory, { + // This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js + getLegacyStorageKeys: () => + getAllStorageKeys().filter((key) => key.startsWith(`ld:${clientSideId}:`)), + analyticsEventPath: `/events/bulk/${clientSideId}`, + diagnosticEventPath: `/events/diagnostic/${clientSideId}`, + includeAuthorizationHeader: false, + highTimeoutThreshold: 5, + userAgentHeaderName: 'x-launchdarkly-user-agent', + dataSystemDefaults: BROWSER_DATA_SYSTEM_DEFAULTS, + trackEventModifier: (event: internal.InputCustomEvent) => + new internal.InputCustomEvent( + event.context, + event.key, + event.data, + event.metricValue, + event.samplingRatio, + eventUrlTransformer(getHref()), ), - { - // This logic is derived from https://github.com/launchdarkly/js-sdk-common/blob/main/src/PersistentFlagStore.js - getLegacyStorageKeys: () => - getAllStorageKeys().filter((key) => key.startsWith(`ld:${clientSideId}:`)), - analyticsEventPath: `/events/bulk/${clientSideId}`, - diagnosticEventPath: `/events/diagnostic/${clientSideId}`, - includeAuthorizationHeader: false, - highTimeoutThreshold: 5, - userAgentHeaderName: 'x-launchdarkly-user-agent', - dataSystemDefaults: BROWSER_DATA_SYSTEM_DEFAULTS, - trackEventModifier: (event: internal.InputCustomEvent) => - new internal.InputCustomEvent( - event.context, - event.key, - event.data, - event.metricValue, - event.samplingRatio, - eventUrlTransformer(getHref()), - ), - getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => - internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), - credentialType: 'clientSideId', - }, - ); + getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => + internal.safeGetHooks(logger, environmentMetadata, validatedBrowserOptions.plugins), + credentialType: 'clientSideId', + }); this.setEventSendingEnabled(true, false); + this.dataManager.setFlushCallback?.(() => this.flush()); + this._plugins = validatedBrowserOptions.plugins; if (validatedBrowserOptions.fetchGoals) { @@ -281,18 +313,14 @@ class BrowserClientImpl extends LDClientImpl { } setStreaming(streaming?: boolean): void { - // With FDv2 we may want to consider if we support connection mode directly. - // Maybe with an extension to connection mode for 'automatic'. - const browserDataManager = this.dataManager as BrowserDataManager; - browserDataManager.setForcedStreaming(streaming); + this.dataManager.setForcedStreaming?.(streaming); } private _updateAutomaticStreamingState() { - const browserDataManager = this.dataManager as BrowserDataManager; const hasListeners = this.emitter .eventNames() .some((name) => name.startsWith('change:') || name === 'change'); - browserDataManager.setAutomaticStreamingState(hasListeners); + this.dataManager.setAutomaticStreamingState?.(hasListeners); } override on(eventName: LDEmitterEventName, listener: Function): void { From 97568e45a937365d4096b4a4e1a177a902da2cdf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:47:34 -0700 Subject: [PATCH 06/28] fix: address PR review feedback on SourceFactoryProvider - Fix ping handler to use per-entry endpoint-overridden requestor and the factory's selector getter (sg) for fresh selectors on each ping - Extract duplicated streaming base config into shared buildStreamingBase helper - Restructure SourceFactoryContext to group polling/streaming config into nested objects (polling.paths, polling.intervalSeconds, streaming.paths, streaming.initialReconnectDelaySeconds) - Resolve merge conflict in FDv2DataSource (warn -> debug) --- .../datasource/SourceFactoryProvider.test.ts | 77 +++++++++++-- .../src/datasource/SourceFactoryProvider.ts | 106 +++++++++--------- .../src/datasource/fdv2/FDv2DataSource.ts | 4 - 3 files changed, 122 insertions(+), 65 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts index 2faeec0b8c..65ba95890b 100644 --- a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts @@ -11,6 +11,7 @@ import { InitializerEntry, SynchronizerEntry } from '../../src/api/datasource'; import { DataSourcePaths } from '../../src/datasource/DataSourceConfig'; import { createCacheInitializerFactory } from '../../src/datasource/fdv2/CacheInitializer'; import { FDv2Requestor, makeFDv2Requestor } from '../../src/datasource/fdv2/FDv2Requestor'; +import { poll as fdv2Poll } from '../../src/datasource/fdv2/PollingBase'; import { createPollingInitializer } from '../../src/datasource/fdv2/PollingInitializer'; import { createPollingSynchronizer } from '../../src/datasource/fdv2/PollingSynchronizer'; import { createSynchronizerSlot } from '../../src/datasource/fdv2/SourceManager'; @@ -39,6 +40,7 @@ const mockCreateStreamingSynchronizer = createStreamingSynchronizer as jest.Mock const mockCreateCacheInitializerFactory = createCacheInitializerFactory as jest.Mock; const mockMakeFDv2Requestor = makeFDv2Requestor as jest.Mock; const mockCreateSynchronizerSlot = createSynchronizerSlot as jest.Mock; +const mockFdv2Poll = fdv2Poll as jest.Mock; jest.mock('../../src/datasource/fdv2/SourceManager', () => ({ createSynchronizerSlot: jest.fn((factory: any) => ({ @@ -71,20 +73,23 @@ function makeSourceFactoryContext(overrides?: Partial): So 'https://poll.example.com', 'https://events.example.com', ), - pollingPaths: makePaths(), - streamingPaths: makePaths(), baseHeaders: { authorization: 'sdk-key' }, queryParams: [], plainContextString: '{"kind":"user","key":"test-user"}', - selectorGetter: () => undefined, - streamInitialReconnectDelay: 1, - pollInterval: 30, logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), } as unknown as LDLogger, + polling: { + paths: makePaths(), + intervalSeconds: 30, + }, + streaming: { + paths: makePaths(), + initialReconnectDelaySeconds: 1, + }, storage: undefined, crypto: {} as Crypto, environmentNamespace: 'test-env', @@ -141,7 +146,7 @@ it('creates a StreamingInitializer for a streaming initializer entry', () => { expect.objectContaining({ requests: ctx.requests, serviceEndpoints: ctx.serviceEndpoints, - initialRetryDelayMillis: ctx.streamInitialReconnectDelay * 1000, + initialRetryDelayMillis: ctx.streaming.initialReconnectDelaySeconds * 1000, }), ); expect(mockCreateStreamingInitializer).toHaveBeenCalledWith( @@ -196,7 +201,7 @@ it('creates a PollingSynchronizer slot for a polling synchronizer entry', () => ctx.requestor, ctx.logger, selectorGetter, - ctx.pollInterval * 1000, + ctx.polling.intervalSeconds * 1000, ); }); @@ -218,7 +223,7 @@ it('creates a StreamingSynchronizer slot for a streaming synchronizer entry', () expect.objectContaining({ requests: ctx.requests, serviceEndpoints: ctx.serviceEndpoints, - initialRetryDelayMillis: ctx.streamInitialReconnectDelay * 1000, + initialRetryDelayMillis: ctx.streaming.initialReconnectDelaySeconds * 1000, }), ); expect(mockCreateStreamingSynchronizer).toHaveBeenCalledWith( @@ -258,7 +263,7 @@ it('creates a new requestor when polling entry has endpoint overrides', () => { polling: 'https://custom-poll.example.com', streaming: 'https://stream.example.com', }), - ctx.pollingPaths, + ctx.polling.paths, ctx.requests, ctx.encoding, ctx.baseHeaders, @@ -276,7 +281,7 @@ it('creates a new requestor when polling entry has endpoint overrides', () => { it('uses per-entry pollInterval override for polling synchronizer', () => { const provider = createDefaultSourceFactoryProvider(); - const ctx = makeSourceFactoryContext({ pollInterval: 30 }); + const ctx = makeSourceFactoryContext({ polling: { paths: makePaths(), intervalSeconds: 30 } }); const entry: SynchronizerEntry = { type: 'polling', pollInterval: 60 }; provider.createSynchronizerSlot(entry, ctx); @@ -295,7 +300,9 @@ it('uses per-entry pollInterval override for polling synchronizer', () => { it('uses per-entry initialReconnectDelay override for streaming initializer', () => { const provider = createDefaultSourceFactoryProvider(); - const ctx = makeSourceFactoryContext({ streamInitialReconnectDelay: 1 }); + const ctx = makeSourceFactoryContext({ + streaming: { paths: makePaths(), initialReconnectDelaySeconds: 1 }, + }); const entry: InitializerEntry = { type: 'streaming', initialReconnectDelay: 5 }; const factory = provider.createInitializerFactory(entry, ctx); @@ -308,3 +315,51 @@ it('uses per-entry initialReconnectDelay override for streaming initializer', () }), ); }); + +// --- ping handler --- + +it('ping handler uses the factory selector getter, not a stale reference', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { type: 'streaming' }; + + const factory = provider.createInitializerFactory(entry, ctx); + expect(factory).toBeDefined(); + + let currentSelector: string | undefined = 'selector-v1'; + const selectorGetter = () => currentSelector; + factory!(selectorGetter); + + // Extract the pingHandler from the createStreamingBase call + const streamingBaseArgs = mockCreateStreamingBase.mock.calls[0][0]; + const { pingHandler } = streamingBaseArgs; + + // Update the selector after factory creation + currentSelector = 'selector-v2'; + pingHandler.handlePing(); + + // The ping poll should use the fresh selector, not 'selector-v1' + expect(mockFdv2Poll).toHaveBeenCalledWith(expect.anything(), 'selector-v2', false, ctx.logger); +}); + +it('ping handler uses per-entry endpoint-overridden requestor', () => { + const provider = createDefaultSourceFactoryProvider(); + const ctx = makeSourceFactoryContext(); + const entry: InitializerEntry = { + type: 'streaming', + endpoints: { pollingBaseUri: 'https://custom-poll.example.com' }, + }; + + const factory = provider.createInitializerFactory(entry, ctx); + expect(factory).toBeDefined(); + factory!(() => undefined); + + // Extract the pingHandler from the createStreamingBase call + const streamingBaseArgs = mockCreateStreamingBase.mock.calls[0][0]; + const { pingHandler } = streamingBaseArgs; + pingHandler.handlePing(); + + // The ping poll should use the overridden requestor, not ctx.requestor + const overriddenRequestor = mockMakeFDv2Requestor.mock.results[0].value; + expect(mockFdv2Poll).toHaveBeenCalledWith(overriddenRequestor, undefined, false, ctx.logger); +}); diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts index 5ca2a3a679..7a1b059617 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -35,25 +35,31 @@ export interface SourceFactoryContext { encoding: Encoding; /** Service endpoint configuration. */ serviceEndpoints: ServiceEndpoints; - /** The polling endpoint paths. */ - pollingPaths: DataSourcePaths; - /** The streaming endpoint paths. */ - streamingPaths: DataSourcePaths; /** Default HTTP headers. */ baseHeaders: LDHeaders; /** Query parameters for requests (e.g., auth, secure mode hash). */ queryParams: { key: string; value: string }[]; /** JSON-serialized evaluation context. */ plainContextString: string; - /** Getter for the current selector (basis) string. */ - selectorGetter: () => string | undefined; - /** Initial reconnect delay for streaming, in seconds. */ - streamInitialReconnectDelay: number; - /** Poll interval in seconds. */ - pollInterval: number; /** Logger. */ logger: LDLogger; + /** Polling-specific configuration. */ + polling: { + /** The polling endpoint paths. */ + paths: DataSourcePaths; + /** Default poll interval in seconds. */ + intervalSeconds: number; + }; + + /** Streaming-specific configuration. */ + streaming: { + /** The streaming endpoint paths. */ + paths: DataSourcePaths; + /** Default initial reconnect delay in seconds. */ + initialReconnectDelaySeconds: number; + }; + // Cache-related fields (needed for cache initializer). /** Platform storage for reading cached data. */ storage: Storage | undefined; @@ -91,9 +97,13 @@ export interface SourceFactoryProvider { ): SynchronizerSlot | undefined; } -function createPingHandler(ctx: SourceFactoryContext): PingHandler { +function createPingHandler( + requestor: FDv2Requestor, + selectorGetter: () => string | undefined, + logger: LDLogger, +): PingHandler { return { - handlePing: () => fdv2Poll(ctx.requestor, ctx.selectorGetter(), false, ctx.logger), + handlePing: () => fdv2Poll(requestor, selectorGetter(), false, logger), }; } @@ -132,7 +142,7 @@ function resolvePollingRequestor( return makeFDv2Requestor( ctx.plainContextString, overriddenEndpoints, - ctx.pollingPaths, + ctx.polling.paths, ctx.requests, ctx.encoding, ctx.baseHeaders, @@ -140,6 +150,33 @@ function resolvePollingRequestor( ); } +/** + * Build a streaming base instance using per-entry config with context defaults + * as fallbacks. The `sg` selector getter is the canonical source of truth for + * the current selector — both the stream and its ping handler use it. + */ +function buildStreamingBase( + entry: { endpoints?: EndpointConfig; initialReconnectDelay?: number }, + ctx: SourceFactoryContext, + sg: () => string | undefined, +) { + const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); + const requestor = resolvePollingRequestor(ctx, entry.endpoints); + const streamUriPath = ctx.streaming.paths.pathGet(ctx.encoding, ctx.plainContextString); + return createStreamingBase({ + requests: ctx.requests, + serviceEndpoints: entryEndpoints, + streamUriPath, + parameters: ctx.queryParams, + selectorGetter: sg, + headers: ctx.baseHeaders, + initialRetryDelayMillis: + (entry.initialReconnectDelay ?? ctx.streaming.initialReconnectDelaySeconds) * 1000, + logger: ctx.logger, + pingHandler: createPingHandler(requestor, sg, ctx.logger), + }); +} + /** * Creates a {@link SourceFactoryProvider} that handles `cache`, `polling`, * and `streaming` data source entries. @@ -157,25 +194,9 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { createPollingInitializer(requestor, ctx.logger, sg); } - case 'streaming': { - const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); - return (sg: () => string | undefined) => { - const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); - const base = createStreamingBase({ - requests: ctx.requests, - serviceEndpoints: entryEndpoints, - streamUriPath, - parameters: ctx.queryParams, - selectorGetter: sg, - headers: ctx.baseHeaders, - initialRetryDelayMillis: - (entry.initialReconnectDelay ?? ctx.streamInitialReconnectDelay) * 1000, - logger: ctx.logger, - pingHandler: createPingHandler(ctx), - }); - return createStreamingInitializer(base); - }; - } + case 'streaming': + return (sg: () => string | undefined) => + createStreamingInitializer(buildStreamingBase(entry, ctx, sg)); case 'cache': return createCacheInitializerFactory({ @@ -197,7 +218,7 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { ): SynchronizerSlot | undefined { switch (entry.type) { case 'polling': { - const intervalMs = (entry.pollInterval ?? ctx.pollInterval) * 1000; + const intervalMs = (entry.pollInterval ?? ctx.polling.intervalSeconds) * 1000; const requestor = resolvePollingRequestor(ctx, entry.endpoints); const factory = (sg: () => string | undefined) => createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs); @@ -205,23 +226,8 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { } case 'streaming': { - const entryEndpoints = resolveEndpoints(ctx, entry.endpoints); - const factory = (sg: () => string | undefined) => { - const streamUriPath = ctx.streamingPaths.pathGet(ctx.encoding, ctx.plainContextString); - const base = createStreamingBase({ - requests: ctx.requests, - serviceEndpoints: entryEndpoints, - streamUriPath, - parameters: ctx.queryParams, - selectorGetter: sg, - headers: ctx.baseHeaders, - initialRetryDelayMillis: - (entry.initialReconnectDelay ?? ctx.streamInitialReconnectDelay) * 1000, - logger: ctx.logger, - pingHandler: createPingHandler(ctx), - }); - return createStreamingSynchronizer(base); - }; + const factory = (sg: () => string | undefined) => + createStreamingSynchronizer(buildStreamingBase(entry, ctx, sg)); return createSynchronizerSlot(factory); } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index 3652f481bc..60a3bf6508 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -209,11 +209,7 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour ); if (conditions.promise) { -<<<<<<< rlamb/fdv2-source-factory-provider - logger?.warn('Fallback condition active for current synchronizer.'); -======= logger?.debug('Fallback condition active for current synchronizer.'); ->>>>>>> main } // try/finally ensures conditions are closed on all code paths. From b1ca6c1cacd2e50fb5b86a3f30b52faf434ca368 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:54:28 -0700 Subject: [PATCH 07/28] fix: update FDv2DataManagerBase for restructured SourceFactoryContext Adapt factory context construction to use nested polling/streaming config objects and remove selectorGetter from the context (now passed directly via factory sg parameter). --- .../datasource/FDv2DataManagerBase.test.ts | 17 +++++++---------- .../src/datasource/FDv2DataManagerBase.ts | 13 ++++++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts index 6dde24cd1f..420d551db2 100644 --- a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -945,7 +945,7 @@ it('does not identify after close', async () => { ); }); -it('exposes the selectorGetter in the factory context that reads current selector', async () => { +it('populates polling and streaming config in the factory context', async () => { const sourceFactoryProvider = makeSourceFactoryProvider(); let capturedCtx: any; // @ts-ignore - mock captures ctx argument @@ -957,15 +957,12 @@ it('exposes the selectorGetter in the factory context that reads current selecto const manager = createFDv2DataManagerBase(makeBaseConfig({ sourceFactoryProvider })); await identifyManager(manager); - // Initially selector is undefined. - expect(capturedCtx.selectorGetter()).toBeUndefined(); - - // Deliver a payload with a selector via dataCallback. - const dsConfig = capturedDataSourceConfigs[0]; - dsConfig.dataCallback({ type: 'full', updates: [], state: 'new-selector' }); - - // Now selectorGetter should return the selector. - expect(capturedCtx.selectorGetter()).toBe('new-selector'); + 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/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index df9200c473..06b4e2a618 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -471,15 +471,18 @@ export function createFDv2DataManagerBase( requests: platform.requests, encoding: platform.encoding!, serviceEndpoints: config.serviceEndpoints, - pollingPaths: pollingEndpoints, - streamingPaths: streamingEndpoints, baseHeaders, queryParams, plainContextString, - selectorGetter: () => selector, - streamInitialReconnectDelay: config.streamInitialReconnectDelay, - pollInterval: config.pollInterval, logger, + polling: { + paths: pollingEndpoints, + intervalSeconds: config.pollInterval, + }, + streaming: { + paths: streamingEndpoints, + initialReconnectDelaySeconds: config.streamInitialReconnectDelay, + }, storage: platform.storage, crypto: platform.crypto, environmentNamespace, From de3921380f5518aafc3bae3568941328c83900e7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:55:28 -0700 Subject: [PATCH 08/28] fix: increase bundle size limits for common and sdk-client packages Account for new FDv2 SourceFactoryProvider and related code paths. --- .github/workflows/common.yml | 2 +- .github/workflows/sdk-client.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index f0161d845d..9034131d1d 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -35,4 +35,4 @@ jobs: target_file: 'packages/shared/common/dist/esm/index.mjs' package_name: '@launchdarkly/js-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 26000 + size_limit: 29000 diff --git a/.github/workflows/sdk-client.yml b/.github/workflows/sdk-client.yml index 8b3ba882b1..3f3d91f7cb 100644 --- a/.github/workflows/sdk-client.yml +++ b/.github/workflows/sdk-client.yml @@ -32,4 +32,4 @@ jobs: target_file: 'packages/shared/sdk-client/dist/esm/index.mjs' package_name: '@launchdarkly/js-client-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 24000 + size_limit: 38000 From a343b8b16b1105685c2cb142c16a126a678cfd9b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:04:42 -0700 Subject: [PATCH 09/28] fix: add applyChanges to FlagUpdater/FlagStore to bypass FDv1 version checks Push applyChanges down through the flag manager layers so the FDv2 path no longer routes through FDv1's upsert (which has version checks and inactive-context guards). FlagStore.applyPartial inserts without version comparison, and FlagUpdater.applyChanges sets the active context directly and emits change events. --- .../flag-manager/FlagManager.test.ts | 34 ++++++++++++++++ .../src/flag-manager/FlagPersistence.ts | 8 +--- .../sdk-client/src/flag-manager/FlagStore.ts | 12 ++++++ .../src/flag-manager/FlagUpdater.ts | 40 ++++++++++++++++++- 4 files changed, 86 insertions(+), 8 deletions(-) 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 314cd31279..4bad1d99a7 100644 --- a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts @@ -241,4 +241,38 @@ describe('given a flag manager with storage', () => { // Flag should still be present (no changes). expect(flagManager.get('flag1')?.flag.value).toBe('value'); }); + + it('partial applyChanges bypasses version checks (accepts older version)', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + flag1: makeMockItemDescriptor(10, 'v10'), + }); + + // FDv1 upsert would reject version 5 because 5 < 10. + // FDv2 applyChanges should accept it — protocol handles ordering. + await flagManager.applyChanges( + context, + { flag1: makeMockItemDescriptor(5, 'v5-override') }, + 'partial', + ); + + expect(flagManager.get('flag1')?.flag.value).toBe('v5-override'); + }); + + it('applyChanges emits change events for partial updates', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + flag1: makeMockItemDescriptor(1, 'old'), + }); + + const changes: string[][] = []; + flagManager.on((_ctx, keys) => { + changes.push(keys); + }); + + await flagManager.applyChanges(context, { flag1: makeMockItemDescriptor(2, 'new') }, 'partial'); + + expect(changes).toHaveLength(1); + expect(changes[0]).toContain('flag1'); + }); }); diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts index 54c69eba93..9827dd5af6 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts @@ -73,13 +73,7 @@ export default class FlagPersistence { updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType, ): Promise { - if (type === 'full') { - this._flagUpdater.init(context, updates); - } else if (type === 'partial') { - Object.entries(updates).forEach(([key, descriptor]) => { - this._flagUpdater.upsert(context, key, descriptor); - }); - } + this._flagUpdater.applyChanges(context, updates, type); await this._storeCache(context); } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts index d3051a963e..e861dcc0e3 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts @@ -20,6 +20,13 @@ export default interface FlagStore { * Gets all the flags in the flag store. */ getAll(): { [key: string]: ItemDescriptor }; + + /** + * Applies partial updates by inserting or replacing entries without + * version checks. Used by FDv2 where ordering is handled at the + * protocol layer. + */ + applyPartial(updates: { [key: string]: ItemDescriptor }): void; } /** @@ -49,5 +56,10 @@ export function createDefaultFlagStore(): FlagStore { getAll(): { [key: string]: ItemDescriptor } { return flags; }, + applyPartial(updates: { [key: string]: ItemDescriptor }) { + Object.entries(updates).forEach(([key, descriptor]) => { + flags[key] = descriptor; + }); + }, }; } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts index da6cd5b8e0..b1b95e7d1c 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts @@ -1,4 +1,4 @@ -import { Context, LDLogger } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDLogger } from '@launchdarkly/js-sdk-common'; import calculateChangedKeys from './calculateChangedKeys'; import FlagStore from './FlagStore'; @@ -60,6 +60,21 @@ export interface FlagUpdater { */ upsert(context: Context, key: string, item: ItemDescriptor): boolean; + /** + * Applies a changeset directly to the flag store, bypassing version checks + * and inactive-context guards. Used by the FDv2 data path where ordering + * is handled at the protocol layer. + * + * - `'full'`: replaces all flags and emits change events for differences. + * - `'partial'`: merges updates and emits change events for each key. + * - `'none'`: no flag changes (caller handles freshness separately). + */ + applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): void; + /** * Registers a callback to be called when the flags change. * @@ -115,6 +130,29 @@ export default function createFlagUpdater(_flagStore: FlagStore, _logger: LDLogg this.init(context, newFlags); }, + applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): void { + activeContext = context; + if (type === 'full') { + const oldFlags = flagStore.getAll(); + flagStore.init(updates); + const changed = calculateChangedKeys(oldFlags, updates); + if (changed.length > 0) { + this.handleFlagChanges(changed, 'init'); + } + } else if (type === 'partial') { + const keys = Object.keys(updates); + flagStore.applyPartial(updates); + if (keys.length > 0) { + this.handleFlagChanges(keys, 'patch'); + } + } + // 'none' — no flag changes, caller handles freshness. + }, + upsert(context: Context, key: string, item: ItemDescriptor): boolean { if (activeContext?.canonicalKey !== context.canonicalKey) { logger.warn('Received an update for an inactive context.'); From 80852ff0ef6aeb415ec3864077148aa7a6227517 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:21:27 -0700 Subject: [PATCH 10/28] fix: increase browser SDK bundle size limit Account for new FDv2 applyChanges code in FlagUpdater/FlagStore that flows into the browser SDK bundle. --- .github/workflows/browser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index be1be7e032..3cccf15cae 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -41,7 +41,7 @@ jobs: target_file: 'packages/sdk/browser/dist/index.js' package_name: '@launchdarkly/js-client-sdk' pr_number: ${{ github.event.number }} - size_limit: 25000 + size_limit: 34000 # Contract Tests - name: Install contract test dependencies From eeea559b86084df983bfbd25c4128163468d3979 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:45:12 -0700 Subject: [PATCH 11/28] fix: delegate full applyChanges to init to avoid duplication --- .../shared/sdk-client/src/flag-manager/FlagUpdater.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts index b1b95e7d1c..a0ca5519a6 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts @@ -135,15 +135,10 @@ export default function createFlagUpdater(_flagStore: FlagStore, _logger: LDLogg updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType, ): void { - activeContext = context; if (type === 'full') { - const oldFlags = flagStore.getAll(); - flagStore.init(updates); - const changed = calculateChangedKeys(oldFlags, updates); - if (changed.length > 0) { - this.handleFlagChanges(changed, 'init'); - } + this.init(context, updates); } else if (type === 'partial') { + activeContext = context; const keys = Object.keys(updates); flagStore.applyPartial(updates); if (keys.length > 0) { From b5e5dc3e3c97833e6e1fba2ef9dcd8d5cc1d15b9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:50:12 -0700 Subject: [PATCH 12/28] fix: unify applyChanges at FlagStore layer for future FDv1 removal Replace FlagStore.applyPartial with FlagStore.applyChanges that handles full/partial/none, delegating to init for full. FlagUpdater.applyChanges now delegates storage to FlagStore.applyChanges and only handles change event computation. --- .../sdk-client/src/flag-manager/FlagStore.ts | 23 ++++++++++++------- .../src/flag-manager/FlagUpdater.ts | 11 ++++++--- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts index e861dcc0e3..8b2f5605a9 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { ItemDescriptor } from './ItemDescriptor'; /** @@ -22,11 +24,12 @@ export default interface FlagStore { getAll(): { [key: string]: ItemDescriptor }; /** - * Applies partial updates by inserting or replacing entries without - * version checks. Used by FDv2 where ordering is handled at the - * protocol layer. + * Applies a changeset to the store without version checks. + * - `'full'`: replaces all flags. + * - `'partial'`: merges updates into existing flags. + * - `'none'`: no-op. */ - applyPartial(updates: { [key: string]: ItemDescriptor }): void; + applyChanges(updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType): void; } /** @@ -56,10 +59,14 @@ export function createDefaultFlagStore(): FlagStore { getAll(): { [key: string]: ItemDescriptor } { return flags; }, - applyPartial(updates: { [key: string]: ItemDescriptor }) { - Object.entries(updates).forEach(([key, descriptor]) => { - flags[key] = descriptor; - }); + applyChanges(updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType) { + if (type === 'full') { + this.init(updates); + } else if (type === 'partial') { + Object.entries(updates).forEach(([key, descriptor]) => { + flags[key] = descriptor; + }); + } }, }; } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts index a0ca5519a6..79f26ea88b 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts @@ -135,12 +135,17 @@ export default function createFlagUpdater(_flagStore: FlagStore, _logger: LDLogg updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType, ): void { + activeContext = context; + const oldFlags = flagStore.getAll(); + flagStore.applyChanges(updates, type); + if (type === 'full') { - this.init(context, updates); + const changed = calculateChangedKeys(oldFlags, updates); + if (changed.length > 0) { + this.handleFlagChanges(changed, 'init'); + } } else if (type === 'partial') { - activeContext = context; const keys = Object.keys(updates); - flagStore.applyPartial(updates); if (keys.length > 0) { this.handleFlagChanges(keys, 'patch'); } From ecc301d09984a8ecf3dbd56fe3c966f0e9edb0ff Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:32:48 -0700 Subject: [PATCH 13/28] feat: refactor mode switching to discriminated union with foregroundConnectionMode - Remove initialConnectionMode from LDClientDataSystemOptions - Add ManualModeSwitching interface (type: 'manual' + initialConnectionMode) - Add type: 'automatic' discriminant to AutomaticModeSwitchingConfig - Rename PlatformDataSystemDefaults.initialConnectionMode to foregroundConnectionMode - Update validators to accept boolean | automatic config | manual config - Update all tests for new shape --- .../configuration/Configuration.test.ts | 38 +++--- .../LDClientDataSystemOptions.test.ts | 113 +++++++----------- .../datasource/LDClientDataSystemOptions.ts | 40 ++++--- .../sdk-client/src/api/datasource/index.ts | 1 + .../datasource/LDClientDataSystemOptions.ts | 9 +- packages/shared/sdk-client/src/index.ts | 1 + 6 files changed, 97 insertions(+), 105 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index 6c747a0e76..ffd199c1fe 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, }, }, @@ -293,7 +301,7 @@ describe('dataSystem validation', () => { getImplementationHooks: () => [], credentialType: 'mobileKey', dataSystemDefaults: { - initialConnectionMode: 'streaming', + foregroundConnectionMode: 'streaming', automaticModeSwitching: true, }, }, @@ -312,7 +320,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/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..d94f077dd7 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. * @@ -76,6 +64,11 @@ export interface LDClientDataSystemOptions { * Granular control over which platform events trigger automatic mode switches. */ export interface AutomaticModeSwitchingConfig { + /** + * Specifies that mode switching is automatic. + */ + readonly type: 'automatic'; + /** * Whether to automatically switch modes in response to application lifecycle * events (foreground/background on mobile, visibility changes on browser). @@ -93,14 +86,31 @@ export interface AutomaticModeSwitchingConfig { readonly network?: boolean; } +/** + * Disable automatic switching and specify the initial mode for connections. + * + * When automatic mode switching is disabled mode switches must be done with `setConnectionMode`. + */ +export interface ManualModeSwitching { + /** + * Specifies that mode switching is manual. + */ + readonly type: 'manual'; + + /** + * The initial connection mode to use. Subsequently mode transitions will only happen with `setConnectionMode`. + */ + 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/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts index 639b875176..7a6ba06bc6 100644 --- a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts @@ -5,12 +5,13 @@ import { anyOf, validatorOf } from '../configuration/validateOptions'; import { connectionModesValidator, connectionModeValidator } from './ConnectionModeConfig'; const modeSwitchingValidators = { + type: TypeValidators.oneOf('automatic', 'manual'), lifecycle: TypeValidators.Boolean, network: TypeValidators.Boolean, + initialConnectionMode: connectionModeValidator, }; const dataSystemValidators = { - initialConnectionMode: connectionModeValidator, backgroundConnectionMode: connectionModeValidator, automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(modeSwitchingValidators)), connectionModes: connectionModesValidator, @@ -20,7 +21,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 +30,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 +39,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 4b7e252b47..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, From 8ac023483570c2a0665a8521ef85797e577c0d3c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:35:14 -0700 Subject: [PATCH 14/28] fix: derive initialForegroundMode from ManualModeSwitching in BrowserClient Read initial foreground mode from automaticModeSwitching.initialConnectionMode when type is 'manual', otherwise use browser platform default. --- packages/sdk/browser/__tests__/BrowserClient.test.ts | 4 ++-- packages/sdk/browser/src/BrowserClient.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 1eaa579f92..391f8829c4 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -924,14 +924,14 @@ describe('given a mock platform for a BrowserClient', () => { sendEvents: false, fetchGoals: false, // @ts-ignore dataSystem is @internal - dataSystem: { initialConnectionMode: 'invalid-mode' }, + dataSystem: { backgroundConnectionMode: 'invalid-mode' }, }, platform, ); // Invalid mode should produce a warning expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('dataSystem.initialConnectionMode'), + expect.stringContaining('dataSystem.backgroundConnectionMode'), ); await client.start(); diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 678c3c1b26..d2c9d5eb78 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -92,8 +92,11 @@ class BrowserClientImpl extends LDClientImpl { diagnosticsManager?: internal.DiagnosticsManager, ) => { if (configuration.dataSystem) { + const modeSwitching = configuration.dataSystem.automaticModeSwitching as any; const initialForegroundMode: FDv2ConnectionMode = - (configuration.dataSystem.initialConnectionMode as FDv2ConnectionMode) ?? 'one-shot'; + modeSwitching?.type === 'manual' && modeSwitching?.initialConnectionMode + ? modeSwitching.initialConnectionMode + : BROWSER_DATA_SYSTEM_DEFAULTS.foregroundConnectionMode; return createFDv2DataManagerBase({ platform, From 76f795cc4b41585cffc5ced8b41f5c5a54f5660d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:43:01 -0700 Subject: [PATCH 15/28] fix: add ManualModeSwitching to LDClientDataSystemOptions union type --- .../sdk-client/src/api/datasource/LDClientDataSystemOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts index d94f077dd7..86c2dc2611 100644 --- a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts @@ -35,7 +35,7 @@ export interface LDClientDataSystemOptions { * * Default is true for mobile SDKs, false/ignored for browser. */ - automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig; + automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig | ManualModeSwitching; /** * Override the data source pipeline for specific connection modes. From a39bdb481588f110f9d855c6c07093a0ec4cbf77 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:46:37 -0700 Subject: [PATCH 16/28] fix: rename initialForegroundMode to foregroundMode The config field represents the configured foreground mode, not just the initial one. Internally aliased to configuredForegroundMode to avoid collision with the mutable foregroundMode state variable. --- .../datasource/FDv2DataManagerBase.test.ts | 40 +++++++++---------- .../src/datasource/FDv2DataManagerBase.ts | 16 ++++---- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts index 420d551db2..f8c2afe086 100644 --- a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -120,7 +120,7 @@ function makeBaseConfig( baseHeaders: { authorization: 'test-credential' }, emitter: { emit: jest.fn(), on: jest.fn(), off: jest.fn() } as any, transitionTable: BROWSER_TRANSITION_TABLE, - initialForegroundMode: 'one-shot', + foregroundMode: 'one-shot', backgroundMode: undefined, modeTable: MODE_TABLE, sourceFactoryProvider: makeSourceFactoryProvider(), @@ -219,7 +219,7 @@ it('resolves identify immediately when bootstrap is provided', async () => { }); it('does not create a data source when bootstrap is used with one-shot mode', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + 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. @@ -229,7 +229,7 @@ it('does not create a data source when bootstrap is used with one-shot mode', as }); it('starts synchronizers when bootstrap is used with streaming mode', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); await identifyManager(manager, { bootstrap: {} }); // streaming has synchronizers, so a data source should be created. @@ -243,7 +243,7 @@ it('starts synchronizers when bootstrap is used with streaming mode', async () = }); it('includes initializers on mode switch when no selector has been obtained', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); await identifyManager(manager); // Reset mock to capture second data source creation. @@ -274,7 +274,7 @@ it('includes initializers on mode switch when no selector has been obtained', as }); it('closes data source on mode switch from streaming to one-shot and updates current mode', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); await identifyManager(manager); const firstDataSource = mockDataSource; @@ -305,7 +305,7 @@ it('closes data source on mode switch from streaming to one-shot and updates cur }); it('does nothing on mode switch when mode is unchanged', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); await identifyManager(manager); mockCreateFDv2DataSource.mockClear(); @@ -323,7 +323,7 @@ it('does nothing on mode switch when mode is unchanged', async () => { }); it('uses only synchronizers on mode switch after selector has been obtained', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); await identifyManager(manager); // Simulate that a selector was obtained via dataCallback. @@ -361,7 +361,7 @@ describe('given a manager with streaming as the initial foreground mode', () => let manager: FDv2DataManagerControl; beforeEach(() => { - manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); }); afterEach(() => { @@ -402,7 +402,7 @@ describe('given a manager with one-shot as the initial foreground mode', () => { let manager: FDv2DataManagerControl; beforeEach(() => { - manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); }); afterEach(() => { @@ -447,7 +447,7 @@ describe('given a manager with one-shot as the initial foreground mode', () => { }); it('falls back to one-shot when setForcedStreaming is false and configured mode is streaming', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); await identifyManager(manager); mockDebounceManager.setRequestedMode.mockClear(); @@ -527,7 +527,7 @@ it('skips cache initializer on mode switch when bootstrapped', async () => { const sourceFactoryProvider = makeSourceFactoryProvider(); const manager = createFDv2DataManagerBase( makeBaseConfig({ - initialForegroundMode: 'streaming', + foregroundMode: 'streaming', sourceFactoryProvider, }), ); @@ -640,7 +640,7 @@ it('rejects identify when data source start fails', async () => { }); it('exposes configuredForegroundMode from the initial config', () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'polling' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'polling' })); expect(manager.configuredForegroundMode).toBe('polling'); @@ -648,7 +648,7 @@ it('exposes configuredForegroundMode from the initial config', () => { }); it('reports the initial resolved mode via getCurrentMode', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); await identifyManager(manager); expect(manager.getCurrentMode()).toBe('one-shot'); @@ -674,7 +674,7 @@ it('does not reconcile after close', async () => { }); it('resolves to offline when network is unavailable via reconcile', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); await identifyManager(manager); const firstDataSource = mockDataSource; @@ -695,7 +695,7 @@ it('resolves to offline when network is unavailable via reconcile', async () => }); it('sets up debounce manager with correct initial state after identify', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'streaming' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'streaming' })); await identifyManager(manager); expect(mockCreateStateDebounceManager).toHaveBeenCalledTimes(1); @@ -775,7 +775,7 @@ it('calls flagManager.applyChanges with type none on none payload to update fres }); it('stores selector from payload state for subsequent data source creations', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig({ initialForegroundMode: 'one-shot' })); + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'one-shot' })); await identifyManager(manager); // Deliver a payload with a selector. @@ -820,7 +820,7 @@ it('warns and skips unsupported initializer entry types', async () => { config: cfg, sourceFactoryProvider, // Use streaming mode which has cache + polling initializers. - initialForegroundMode: 'streaming', + foregroundMode: 'streaming', }), ); await identifyManager(manager); @@ -844,7 +844,7 @@ it('warns and skips unsupported synchronizer entry types', async () => { config: cfg, sourceFactoryProvider, // streaming mode has streaming + polling synchronizers. - initialForegroundMode: 'streaming', + foregroundMode: 'streaming', }), ); await identifyManager(manager); @@ -881,7 +881,7 @@ it('appends a blocked FDv1 fallback synchronizer when fdv1Endpoints are configur sourceFactoryProvider, fdv1Endpoints, // streaming mode has synchronizers, so FDv1 fallback will be appended. - initialForegroundMode: 'streaming', + foregroundMode: 'streaming', }), ); await identifyManager(manager); @@ -908,7 +908,7 @@ it('resolves identify immediately when initial mode has no sources', async () => sourceFactoryProvider, // offline mode: [cache] initializer, [] synchronizers. // With provider returning undefined for cache, both arrays are empty. - initialForegroundMode: 'offline', + foregroundMode: 'offline', }), ); diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 06b4e2a618..860754af1f 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -46,8 +46,8 @@ export interface FDv2DataManagerBaseConfig { /** Mode resolution table for this platform. */ transitionTable: ModeResolutionTable; - /** The initial foreground connection mode. */ - initialForegroundMode: FDv2ConnectionMode; + /** The configured foreground connection mode. */ + foregroundMode: FDv2ConnectionMode; /** The background connection mode, if any. */ backgroundMode: FDv2ConnectionMode | undefined; /** The mode table mapping modes to data source definitions. */ @@ -121,7 +121,7 @@ export function createFDv2DataManagerBase( baseHeaders, emitter, transitionTable, - initialForegroundMode, + foregroundMode: configuredForegroundMode, backgroundMode, modeTable, sourceFactoryProvider, @@ -142,8 +142,8 @@ export function createFDv2DataManagerBase( // --- Mutable state --- let selector: string | undefined; - let currentResolvedMode: FDv2ConnectionMode = initialForegroundMode; - let foregroundMode: FDv2ConnectionMode = initialForegroundMode; + let currentResolvedMode: FDv2ConnectionMode = configuredForegroundMode; + let foregroundMode: FDv2ConnectionMode = configuredForegroundMode; let dataSource: FDv2DataSource | undefined; let debounceManager: StateDebounceManager | undefined; let identifiedContext: Context | undefined; @@ -204,10 +204,10 @@ export function createFDv2DataManagerBase( } if (forcedStreaming === false) { // Explicitly forced off — use configured mode, but never streaming. - return initialForegroundMode === 'streaming' ? 'one-shot' : initialForegroundMode; + return configuredForegroundMode === 'streaming' ? 'one-shot' : configuredForegroundMode; } // forcedStreaming === undefined — automatic behavior. - return automaticStreamingState ? 'streaming' : initialForegroundMode; + return automaticStreamingState ? 'streaming' : configuredForegroundMode; } /** @@ -417,7 +417,7 @@ export function createFDv2DataManagerBase( return { get configuredForegroundMode(): FDv2ConnectionMode { - return initialForegroundMode; + return configuredForegroundMode; }, async identify( From 2fecbc50930a4bf58a2cc53908a0ebe7e114c60c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:57:48 -0700 Subject: [PATCH 17/28] fix: rename initialForegroundMode to foregroundMode in BrowserClient --- packages/sdk/browser/src/BrowserClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index d2c9d5eb78..fe7afa055f 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -93,7 +93,7 @@ class BrowserClientImpl extends LDClientImpl { ) => { if (configuration.dataSystem) { const modeSwitching = configuration.dataSystem.automaticModeSwitching as any; - const initialForegroundMode: FDv2ConnectionMode = + const foregroundMode: FDv2ConnectionMode = modeSwitching?.type === 'manual' && modeSwitching?.initialConnectionMode ? modeSwitching.initialConnectionMode : BROWSER_DATA_SYSTEM_DEFAULTS.foregroundConnectionMode; @@ -106,7 +106,7 @@ class BrowserClientImpl extends LDClientImpl { baseHeaders, emitter, transitionTable: BROWSER_TRANSITION_TABLE, - initialForegroundMode, + foregroundMode, backgroundMode: undefined, modeTable: MODE_TABLE, sourceFactoryProvider: createDefaultSourceFactoryProvider(), From a9ac46684d34c2d086d2b0b712a6ff0e05536a28 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:00:13 -0700 Subject: [PATCH 18/28] fix: simplify dataCallback by using ?? [] for transfer-none updates --- .../sdk-client/src/datasource/FDv2DataManagerBase.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 860754af1f..61bc10425d 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -294,14 +294,7 @@ export function createFDv2DataManagerBase( return; } - if (payload.type === 'none') { - // Spec 5.2.2: transfer-none confirms data is still current. - // Persist cache to update freshness timestamp without changing flags. - flagManager.applyChanges(context, {}, 'none'); - return; - } - - const descriptors = flagEvalPayloadToItemDescriptors(payload.updates); + const descriptors = flagEvalPayloadToItemDescriptors(payload.updates ?? []); flagManager.applyChanges(context, descriptors, payload.type); } From 659ff40344c24c2379cb37d39330d8ebc7335d1c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:06:02 -0700 Subject: [PATCH 19/28] fix: unconditionally assign selector from payload state Clear the selector when an FDv1 synchronizer delivers a payload without state, preventing stale selectors from being sent on subsequent requests. --- .../shared/sdk-client/src/datasource/FDv2DataManagerBase.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 61bc10425d..d19ece48bc 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -284,9 +284,7 @@ export function createFDv2DataManagerBase( `${logTag} dataCallback: type=${payload.type}, updates=${payload.updates.length}, state=${payload.state}`, ); - if (payload.state) { - selector = payload.state; - } + selector = payload.state; const context = identifiedContext; if (!context) { From 9ce48585ac0b271c15a1d8b638bf5da40ed3b645 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:21:50 -0700 Subject: [PATCH 20/28] fix: revert combined-browser size limit decrease --- .github/workflows/combined-browser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/combined-browser.yml b/.github/workflows/combined-browser.yml index e83eb4b87b..159a6f9e7a 100644 --- a/.github/workflows/combined-browser.yml +++ b/.github/workflows/combined-browser.yml @@ -41,4 +41,4 @@ jobs: target_file: 'packages/sdk/combined-browser/dist/index.js' package_name: '@launchdarkly/browser' pr_number: ${{ github.event.number }} - size_limit: 194000 + size_limit: 200000 From 5f4f9f9c58a45175aef1c4a4f02464cb1ba500a1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:27:27 -0700 Subject: [PATCH 21/28] fix: extract resolveForegroundMode helper with type guard Move foreground mode resolution out of BrowserClient into a type-safe helper using a proper ManualModeSwitching type guard. Any platform SDK can now call resolveForegroundMode(dataSystem, defaults) instead of hand-parsing the discriminated union. --- packages/sdk/browser/src/BrowserClient.ts | 13 ++++----- .../datasource/LDClientDataSystemOptions.ts | 29 ++++++++++++++++++- packages/shared/sdk-client/src/index.ts | 1 + 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index fe7afa055f..4ec75c8e48 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -7,7 +7,6 @@ import { Configuration, createDefaultSourceFactoryProvider, createFDv2DataManagerBase, - FDv2ConnectionMode, FlagManager, Hook, internal, @@ -25,6 +24,7 @@ import { MODE_TABLE, Platform, readFlagsFromBootstrap, + resolveForegroundMode, safeRegisterDebugOverridePlugins, } from '@launchdarkly/js-client-sdk-common'; @@ -92,12 +92,6 @@ class BrowserClientImpl extends LDClientImpl { diagnosticsManager?: internal.DiagnosticsManager, ) => { if (configuration.dataSystem) { - const modeSwitching = configuration.dataSystem.automaticModeSwitching as any; - const foregroundMode: FDv2ConnectionMode = - modeSwitching?.type === 'manual' && modeSwitching?.initialConnectionMode - ? modeSwitching.initialConnectionMode - : BROWSER_DATA_SYSTEM_DEFAULTS.foregroundConnectionMode; - return createFDv2DataManagerBase({ platform, flagManager, @@ -106,7 +100,10 @@ class BrowserClientImpl extends LDClientImpl { baseHeaders, emitter, transitionTable: BROWSER_TRANSITION_TABLE, - foregroundMode, + foregroundMode: resolveForegroundMode( + configuration.dataSystem, + BROWSER_DATA_SYSTEM_DEFAULTS, + ), backgroundMode: undefined, modeTable: MODE_TABLE, sourceFactoryProvider: createDefaultSourceFactoryProvider(), diff --git a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts index 7a6ba06bc6..bb4c96bd4e 100644 --- a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts @@ -1,6 +1,11 @@ import { TypeValidators } from '@launchdarkly/js-sdk-common'; -import type { PlatformDataSystemDefaults } from '../api/datasource'; +import type FDv2ConnectionMode from '../api/datasource/FDv2ConnectionMode'; +import type { + LDClientDataSystemOptions, + ManualModeSwitching, + PlatformDataSystemDefaults, +} from '../api/datasource/LDClientDataSystemOptions'; import { anyOf, validatorOf } from '../configuration/validateOptions'; import { connectionModesValidator, connectionModeValidator } from './ConnectionModeConfig'; @@ -44,8 +49,30 @@ const DESKTOP_DATA_SYSTEM_DEFAULTS: PlatformDataSystemDefaults = { automaticModeSwitching: false, }; +function isManualModeSwitching( + value: LDClientDataSystemOptions['automaticModeSwitching'], +): value is ManualModeSwitching { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'manual'; +} + +/** + * Resolve the foreground connection mode from a validated data system config + * and platform defaults. Uses the mode from `ManualModeSwitching` when present, + * otherwise falls back to the platform default. + */ +function resolveForegroundMode( + dataSystem: LDClientDataSystemOptions, + defaults: PlatformDataSystemDefaults, +): FDv2ConnectionMode { + if (isManualModeSwitching(dataSystem.automaticModeSwitching)) { + return dataSystem.automaticModeSwitching.initialConnectionMode; + } + return defaults.foregroundConnectionMode; +} + export { dataSystemValidators, + resolveForegroundMode, BROWSER_DATA_SYSTEM_DEFAULTS, MOBILE_DATA_SYSTEM_DEFAULTS, DESKTOP_DATA_SYSTEM_DEFAULTS, diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index ce5289524a..3b53da181d 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -104,6 +104,7 @@ export type { DataSourceStatusManager } from './datasource/DataSourceStatusManag // FDv2 data system validators and platform defaults. export { dataSystemValidators, + resolveForegroundMode, BROWSER_DATA_SYSTEM_DEFAULTS, MOBILE_DATA_SYSTEM_DEFAULTS, DESKTOP_DATA_SYSTEM_DEFAULTS, From 63a0ac95354cbe2c3d9f7ee9eae18f4804a90e5c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:21:10 -0700 Subject: [PATCH 22/28] fix: remove unnecessary as any cast for bootstrap check --- .../shared/sdk-client/src/datasource/FDv2DataManagerBase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index d19ece48bc..3d816b6226 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -484,7 +484,7 @@ export function createFDv2DataManagerBase( const mode = resolveMode(); logger.debug(`${logTag} Identify: initial mode resolved to '${mode}'.`); - bootstrapped = !!(identifyOptions as any)?.bootstrap; + bootstrapped = identifyOptions?.bootstrap !== undefined; if (bootstrapped) { // Bootstrap data was already applied to the flag store by the From ad9a304c24011b8464a0e84f49e3edd31d2db539 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:26:56 -0700 Subject: [PATCH 23/28] fix: re-check closed state after await in identify to prevent resource leak If close() is called while namespaceForEnvironment is pending, identify would resume and create a data source and debounce manager that would never be cleaned up. Add a guard after the await to bail out early. --- .../sdk-client/src/datasource/FDv2DataManagerBase.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 3d816b6226..5e9e699a97 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -457,6 +457,13 @@ export function createFDv2DataManagerBase( 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, From 68b4baff6fc5fcdaf6f75f72198cb3d4eec46e3b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:37:49 -0700 Subject: [PATCH 24/28] fix: update doc comments to match discriminated union API Update automaticModeSwitching docs to reference AutomaticModeSwitchingConfig and ManualModeSwitching types instead of re-declaring the shape inline. Add discriminant docs and cross-references to related types. --- .../datasource/LDClientDataSystemOptions.ts | 33 +++++++++---------- .../src/datasource/FDv2DataManagerBase.ts | 2 +- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts index 86c2dc2611..c9a785a65d 100644 --- a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts @@ -22,18 +22,17 @@ 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 + * - `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 * - * `lifecycle` controls foreground/background transitions (mobile) and - * visibility changes (browser). `network` controls pause/resume of data - * sources when network availability changes. - * - * Default is true for mobile SDKs, false/ignored for browser. + * Default is `true` for mobile SDKs, `false` for browser. */ automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig | ManualModeSwitching; @@ -64,9 +63,7 @@ export interface LDClientDataSystemOptions { * Granular control over which platform events trigger automatic mode switches. */ export interface AutomaticModeSwitchingConfig { - /** - * Specifies that mode switching is automatic. - */ + /** Discriminant — selects automatic mode switching. */ readonly type: 'automatic'; /** @@ -87,18 +84,18 @@ export interface AutomaticModeSwitchingConfig { } /** - * Disable automatic switching and specify the initial mode for connections. + * Disable automatic switching and specify the initial connection mode. * - * When automatic mode switching is disabled mode switches must be done with `setConnectionMode`. + * Subsequent mode transitions must be triggered explicitly via + * {@link FDv2DataManagerControl.setRequestedMode}. */ export interface ManualModeSwitching { - /** - * Specifies that mode switching is manual. - */ + /** Discriminant — selects manual mode switching. */ readonly type: 'manual'; /** - * The initial connection mode to use. Subsequently mode transitions will only happen with `setConnectionMode`. + * The connection mode to use when the SDK starts. Overrides the + * platform default from {@link PlatformDataSystemDefaults.foregroundConnectionMode}. */ initialConnectionMode: FDv2ConnectionMode; } diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 5e9e699a97..788b76b3de 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -46,7 +46,7 @@ export interface FDv2DataManagerBaseConfig { /** Mode resolution table for this platform. */ transitionTable: ModeResolutionTable; - /** The configured foreground connection mode. */ + /** The configured foreground connection mode. Use {@link resolveForegroundMode} to derive. */ foregroundMode: FDv2ConnectionMode; /** The background connection mode, if any. */ backgroundMode: FDv2ConnectionMode | undefined; From 52339839d959dea61503b8ed2c0bdd6632aec834 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:57:16 -0700 Subject: [PATCH 25/28] feat: add setConnectionMode as top-level override bypassing transition table Replace setRequestedMode and setForegroundMode with setConnectionMode that sets an explicit connection mode override. When set, it bypasses the entire transition table (network, lifecycle, streaming logic). Pass undefined to clear and return to automatic behavior. Priority hierarchy: 1. connectionModeOverride (setConnectionMode) 2. Transition table with foreground mode from: a. forcedStreaming b. automaticStreaming c. configuredForegroundMode --- .../datasource/FDv2DataManagerBase.test.ts | 62 +++++++++++++++- .../datasource/LDClientDataSystemOptions.ts | 2 +- .../src/datasource/FDv2DataManagerBase.ts | 71 +++++++++++-------- 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts index f8c2afe086..710e737286 100644 --- a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -512,17 +512,73 @@ it('delegates setLifecycleState to debounce manager', async () => { manager.close(); }); -it('delegates setRequestedMode to debounce manager', async () => { - const manager = createFDv2DataManagerBase(makeBaseConfig()); +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.setRequestedMode('streaming'); + 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( diff --git a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts index c9a785a65d..35ffd121ca 100644 --- a/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts @@ -87,7 +87,7 @@ export interface AutomaticModeSwitchingConfig { * Disable automatic switching and specify the initial connection mode. * * Subsequent mode transitions must be triggered explicitly via - * {@link FDv2DataManagerControl.setRequestedMode}. + * {@link FDv2DataManagerControl.setConnectionMode}. */ export interface ManualModeSwitching { /** Discriminant — selects manual mode switching. */ diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 788b76b3de..e698bd9ede 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -87,14 +87,12 @@ export interface FDv2DataManagerControl extends DataManager { setNetworkState(state: NetworkState): void; /** Update the pending lifecycle state. Goes through debounce. */ setLifecycleState(state: LifecycleState): void; - /** Update the requested connection mode. Goes through debounce. */ - setRequestedMode(mode: FDv2ConnectionMode): void; /** - * Set the effective foreground mode directly. Used by browser - * listener-driven streaming to promote/demote the foreground mode. - * Goes through debounce. + * 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. */ - setForegroundMode(mode: FDv2ConnectionMode): void; + setConnectionMode(mode?: FDv2ConnectionMode): void; /** Get the currently resolved connection mode. */ getCurrentMode(): FDv2ConnectionMode; /** The configured default foreground mode (from config, not auto-promoted). */ @@ -153,6 +151,9 @@ export function createFDv2DataManagerBase( 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; @@ -182,34 +183,46 @@ export function createFDv2DataManagerBase( }; } + /** + * 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()); } /** - * Determine the foreground mode based on forced/automatic streaming state. + * Resolve the foreground mode input for the transition table based on + * forced/automatic streaming state. * - * +-----------+-----------+---------------------------+ - * | forced | automatic | result | - * +-----------+-----------+---------------------------+ - * | true | any | 'streaming' | - * | false | any | configured, never streaming| - * | undefined | true | 'streaming' | - * | undefined | false | configured mode | - * +-----------+-----------+---------------------------+ + * Priority: forcedStreaming > automaticStreaming > configuredForegroundMode */ - function resolveStreamingMode(): FDv2ConnectionMode { + function resolveStreamingForeground(): FDv2ConnectionMode { if (forcedStreaming === true) { return 'streaming'; } if (forcedStreaming === false) { - // Explicitly forced off — use configured mode, but never streaming. return configuredForegroundMode === 'streaming' ? 'one-shot' : configuredForegroundMode; } - // forcedStreaming === undefined — automatic behavior. 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. @@ -487,6 +500,9 @@ export function createFDv2DataManagerBase( 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}'.`); @@ -555,14 +571,13 @@ export function createFDv2DataManagerBase( debounceManager?.setLifecycleState(state); }, - setRequestedMode(mode: FDv2ConnectionMode): void { - foregroundMode = mode; - debounceManager?.setRequestedMode(mode); - }, - - setForegroundMode(mode: FDv2ConnectionMode): void { - foregroundMode = mode; - debounceManager?.setRequestedMode(mode); + setConnectionMode(mode?: FDv2ConnectionMode): void { + connectionModeOverride = mode; + if (mode !== undefined) { + debounceManager?.setRequestedMode(mode); + } else { + pushForegroundMode(); + } }, getCurrentMode(): FDv2ConnectionMode { @@ -575,12 +590,12 @@ export function createFDv2DataManagerBase( setForcedStreaming(streaming?: boolean): void { forcedStreaming = streaming; - this.setForegroundMode(resolveStreamingMode()); + pushForegroundMode(); }, setAutomaticStreamingState(streaming: boolean): void { automaticStreamingState = streaming; - this.setForegroundMode(resolveStreamingMode()); + pushForegroundMode(); }, }; } From f91a28f0826c784d62dc67de5d1253c918a99a88 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:58:11 -0700 Subject: [PATCH 26/28] fix: add .catch to flagManager.applyChanges to prevent unhandled rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synchronous flag updates and change events are the critical path. The returned promise is only for async cache persistence — intentionally not awaited so the data source pipeline isn't blocked by storage I/O. --- .../__tests__/datasource/FDv2DataManagerBase.test.ts | 2 +- .../sdk-client/src/datasource/FDv2DataManagerBase.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts index 710e737286..d2610ca5a4 100644 --- a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -83,7 +83,7 @@ function makeFlagManager() { return { init: jest.fn(), upsert: jest.fn(), - applyChanges: jest.fn(), + applyChanges: jest.fn().mockResolvedValue(undefined), get: jest.fn(), getAll: jest.fn(), on: jest.fn(), diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index e698bd9ede..3bf22e01e3 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -306,7 +306,12 @@ export function createFDv2DataManagerBase( } const descriptors = flagEvalPayloadToItemDescriptors(payload.updates ?? []); - flagManager.applyChanges(context, descriptors, payload.type); + // 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}`); + }); } /** From 0786b64964412d37c6df7beda9eceb7d5cc2a770 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:12:58 -0700 Subject: [PATCH 27/28] feat: add custom is predicate to validatorOf for discriminated unions Add optional is parameter to validatorOf so anyOf can discriminate between object shapes by checking a type field. Split mode switching validators into separate automatic and manual variants with type-based is predicates. --- .../configuration/Configuration.test.ts | 7 ++++++- .../src/configuration/validateOptions.ts | 12 ++++++++++-- .../sdk-client/src/configuration/validators.ts | 7 +++---- .../datasource/LDClientDataSystemOptions.ts | 18 +++++++++++++++--- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index ffd199c1fe..df936c5427 100644 --- a/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -296,7 +296,11 @@ 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', @@ -308,6 +312,7 @@ describe('dataSystem validation', () => { ); expect(config.dataSystem).toBeDefined(); expect(config.dataSystem!.automaticModeSwitching).toEqual({ + type: 'automatic', lifecycle: true, network: false, }); 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/LDClientDataSystemOptions.ts b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts index 7a6ba06bc6..430b057db7 100644 --- a/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts +++ b/packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts @@ -4,16 +4,28 @@ import type { PlatformDataSystemDefaults } from '../api/datasource'; import { anyOf, validatorOf } from '../configuration/validateOptions'; import { connectionModesValidator, connectionModeValidator } from './ConnectionModeConfig'; -const modeSwitchingValidators = { - type: TypeValidators.oneOf('automatic', 'manual'), +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 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, }; From 0ac3c0d71ba74f173c88bb2afacadda701c6dbca Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:46:55 -0700 Subject: [PATCH 28/28] fix: forward browser streaming option to FDv2 data manager When streaming is explicitly set in browser options, pass it to the FDv2 data manager as forcedStreaming so that streaming: false prevents auto-promotion via change listeners, matching FDv1 behavior. --- packages/sdk/browser/src/BrowserClient.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 4ec75c8e48..5c4b3c55fc 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -159,6 +159,12 @@ class BrowserClientImpl extends LDClientImpl { this.setEventSendingEnabled(true, false); + // Forward the browser streaming option to the FDv2 data manager so that + // an explicit streaming: false prevents auto-promotion to streaming. + if (validatedBrowserOptions.streaming !== undefined) { + this.dataManager.setForcedStreaming?.(validatedBrowserOptions.streaming); + } + this.dataManager.setFlushCallback?.(() => this.flush()); this._plugins = validatedBrowserOptions.plugins;