diff --git a/packages/shared/sdk-client/__tests__/configuration/validateOptions.test.ts b/packages/shared/sdk-client/__tests__/configuration/validateOptions.test.ts new file mode 100644 index 0000000000..794b58cdbb --- /dev/null +++ b/packages/shared/sdk-client/__tests__/configuration/validateOptions.test.ts @@ -0,0 +1,108 @@ +import { LDLogger, TypeValidators } from '@launchdarkly/js-sdk-common'; + +import { recordOf, validatorOf } from '../../src/configuration/validateOptions'; + +let logger: LDLogger; + +beforeEach(() => { + logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +}); + +describe('recordOf with built-in defaults', () => { + const valueValidator = validatorOf({ + name: TypeValidators.String, + count: TypeValidators.numberWithMin(1), + }); + + const keyValidator = TypeValidators.oneOf('a', 'b', 'c'); + + const builtInDefaults = { + a: { name: 'alpha', count: 10 }, + b: { name: 'beta', count: 20 }, + c: { name: 'gamma', count: 30 }, + }; + + const validator = recordOf(keyValidator, valueValidator, { defaults: builtInDefaults }); + + it('uses built-in defaults for keys not provided by the caller', () => { + const result = validator.validate({ a: { name: 'custom' } }, 'test', logger); + + expect(result?.value).toEqual({ + a: { name: 'custom', count: 10 }, + b: { name: 'beta', count: 20 }, + c: { name: 'gamma', count: 30 }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('fills in missing fields from the per-key default', () => { + const result = validator.validate({ b: { count: 99 } }, 'test', logger); + + // name comes from built-in default for key 'b' + expect((result?.value as any).b).toEqual({ name: 'beta', count: 99 }); + }); + + it('returns built-in defaults when input is empty object', () => { + const result = validator.validate({}, 'test', logger); + + expect(result?.value).toEqual(builtInDefaults); + }); + + it('returns undefined when input is undefined', () => { + const result = validator.validate(undefined, 'test', logger); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when input is null', () => { + const result = validator.validate(null, 'test', logger); + + expect(result).toBeUndefined(); + }); + + it('prefers built-in defaults over caller-provided defaults', () => { + const callerDefaults = { + a: { name: 'caller-alpha', count: 1 }, + }; + + const result = validator.validate({}, 'test', logger, callerDefaults); + + // Built-in defaults take priority over caller defaults. + expect(result?.value).toEqual(builtInDefaults); + }); +}); + +describe('recordOf without built-in defaults', () => { + const valueValidator = validatorOf({ + name: TypeValidators.String, + count: TypeValidators.numberWithMin(1), + }); + + const keyValidator = TypeValidators.oneOf('a', 'b'); + + const validator = recordOf(keyValidator, valueValidator); + + it('uses caller-provided defaults when no built-in defaults', () => { + const callerDefaults = { + a: { name: 'alpha', count: 10 }, + b: { name: 'beta', count: 20 }, + }; + + const result = validator.validate({ a: { name: 'custom' } }, 'test', logger, callerDefaults); + + expect((result?.value as any).a).toEqual({ name: 'custom', count: 10 }); + expect((result?.value as any).b).toEqual({ name: 'beta', count: 20 }); + }); + + it('uses empty defaults when neither built-in nor caller defaults are provided', () => { + const result = validator.validate({ a: { name: 'custom' } }, 'test', logger); + + // No defaults to fill in count, so only name is present. + expect((result?.value as any).a).toEqual({ name: 'custom' }); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts b/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts index 25ae203897..55c1d9576a 100644 --- a/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts @@ -2,7 +2,9 @@ import { LDLogger } from '@launchdarkly/js-sdk-common'; import validateOptions from '../../src/configuration/validateOptions'; import { + BACKGROUND_POLL_INTERVAL_SECONDS, connectionModesValidator, + DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, MODE_DEFINITION_DEFAULTS, MODE_TABLE, modeDefinitionValidators, @@ -497,6 +499,7 @@ describe('given a partial override', () => { expect(result.streaming).toEqual({ initializers: [{ type: 'polling' }], synchronizers: [{ type: 'streaming' }], + fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS }, }); // Other modes retain their defaults. expect(result.polling).toEqual(MODE_TABLE.polling); @@ -555,6 +558,7 @@ describe('given unknown mode names', () => { expect(result.polling).toEqual({ initializers: [], synchronizers: [{ type: 'polling', pollInterval: 60 }], + fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS }, }); expect(result.streaming).toEqual(MODE_TABLE.streaming); expect(logger.warn).toHaveBeenCalledTimes(1); @@ -610,6 +614,7 @@ describe('given a custom defaults table', () => { expect(result.polling).toEqual({ initializers: [], synchronizers: [{ type: 'polling', pollInterval: 120 }], + fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS }, }); // Custom default retained for streaming (not the built-in MODE_TABLE default). expect(result.streaming).toEqual(customDefaults.streaming); @@ -640,3 +645,186 @@ describe('given no logger for validateModeTable', () => { expect(result.polling).toEqual(MODE_TABLE.polling); }); }); + +// ----------------------------- fdv1Fallback validation -------------------------------- + +describe('given MODE_TABLE fdv1Fallback defaults', () => { + it('has the default poll interval for streaming mode', () => { + expect(MODE_TABLE.streaming.fdv1Fallback).toEqual({ + pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, + }); + }); + + it('has the default poll interval for polling mode', () => { + expect(MODE_TABLE.polling.fdv1Fallback).toEqual({ + pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, + }); + }); + + it('has the background poll interval for background mode', () => { + expect(MODE_TABLE.background.fdv1Fallback).toEqual({ + pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS, + }); + }); + + it('does not have fdv1Fallback for offline mode', () => { + expect(MODE_TABLE.offline.fdv1Fallback).toBeUndefined(); + }); + + it('does not have fdv1Fallback for one-shot mode', () => { + expect(MODE_TABLE['one-shot'].fdv1Fallback).toBeUndefined(); + }); +}); + +describe('given a valid fdv1Fallback in a mode definition', () => { + it('passes through a valid fdv1Fallback with pollInterval', () => { + const input = { + initializers: [{ type: 'cache' }], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: { pollInterval: 600 }, + }; + + const result = validateModeDefinition(input, 'testMode', logger); + + expect(result.fdv1Fallback).toEqual({ pollInterval: 600 }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('passes through fdv1Fallback with endpoint overrides', () => { + const input = { + initializers: [], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: { + pollInterval: 120, + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }, + }; + + const result = validateModeDefinition(input, 'testMode', logger); + + expect(result.fdv1Fallback).toEqual({ + pollInterval: 120, + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('passes through fdv1Fallback with only endpoints', () => { + const input = { + initializers: [], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: { + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }, + }; + + const result = validateModeDefinition(input, 'testMode', logger); + + expect(result.fdv1Fallback).toEqual({ + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('given invalid fdv1Fallback in a mode definition', () => { + it('clamps pollInterval to minimum when below 30', () => { + const input = { + initializers: [], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: { pollInterval: 5 }, + }; + + const result = validateModeDefinition(input, 'testMode', logger); + + expect(result.fdv1Fallback).toEqual({ pollInterval: 30 }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollInterval')); + }); + + it('drops pollInterval when it is a string and warns', () => { + const input = { + initializers: [], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: { pollInterval: 'fast' }, + }; + + const result = validateModeDefinition(input, 'testMode', logger); + + // validatorOf returns undefined when all nested fields are invalid/dropped. + expect(result.fdv1Fallback).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollInterval')); + }); + + it('drops fdv1Fallback when it is not an object and warns', () => { + const input = { + initializers: [], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: 'invalid', + }; + + const result = validateModeDefinition(input, 'testMode', logger); + + expect(result.fdv1Fallback).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('fdv1Fallback')); + }); + + it('drops invalid endpoint fields within fdv1Fallback', () => { + const input = { + initializers: [], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: { + pollInterval: 60, + endpoints: { pollingBaseUri: 123 }, + }, + }; + + const result = validateModeDefinition(input, 'testMode', logger); + + expect(result.fdv1Fallback).toEqual({ pollInterval: 60 }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollingBaseUri')); + }); +}); + +describe('given mode table overrides with fdv1Fallback', () => { + it('preserves default fdv1Fallback when user override does not specify it', () => { + const result = validateModeTable( + { + streaming: { + initializers: [{ type: 'polling' }], + synchronizers: [{ type: 'streaming' }], + }, + }, + MODE_TABLE, + logger, + ); + + // The validatorOf merges defaults from MODE_TABLE, so fdv1Fallback + // is carried through even when the user doesn't specify it. + expect((result.streaming as any).fdv1Fallback).toEqual({ + pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, + }); + expect((result.streaming as any).initializers).toEqual([{ type: 'polling' }]); + expect((result.streaming as any).synchronizers).toEqual([{ type: 'streaming' }]); + // Non-overridden modes retain their defaults including fdv1Fallback. + expect((result.background as any).fdv1Fallback).toEqual({ + pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS, + }); + }); + + it('uses user-specified fdv1Fallback when provided', () => { + const result = validateModeTable( + { + background: { + initializers: [{ type: 'cache' }], + synchronizers: [{ type: 'polling', pollInterval: 7200 }], + fdv1Fallback: { pollInterval: 7200 }, + }, + }, + MODE_TABLE, + logger, + ); + + expect((result.background as any).fdv1Fallback).toEqual({ pollInterval: 7200 }); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts index d2610ca5a4..09a9feced8 100644 --- a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -951,6 +951,50 @@ it('appends a blocked FDv1 fallback synchronizer when fdv1Endpoints are configur manager.close(); }); +it('uses per-mode fdv1Fallback pollInterval from MODE_TABLE for background mode', 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, + foregroundMode: 'background', + }), + ); + await identifyManager(manager); + + const dsConfig = capturedDataSourceConfigs[0]; + const fdv1Slot = dsConfig.synchronizerSlots[dsConfig.synchronizerSlots.length - 1]; + // Invoke the factory to trigger createFDv1PollingSynchronizer. + fdv1Slot.factory(() => undefined); + + // The FDv1 fallback synchronizer should use background's default (3600s = 3600000ms). + expect(createFDv1PollingSynchronizer).toHaveBeenCalledWith( + expect.anything(), + 3600 * 1000, + expect.anything(), + ); + + 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(); diff --git a/packages/shared/sdk-client/__tests__/datasource/LDClientDataSystemOptions.test.ts b/packages/shared/sdk-client/__tests__/datasource/LDClientDataSystemOptions.test.ts index 8f0d04a219..32861ee303 100644 --- a/packages/shared/sdk-client/__tests__/datasource/LDClientDataSystemOptions.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/LDClientDataSystemOptions.test.ts @@ -1,6 +1,11 @@ import { LDLogger } from '@launchdarkly/js-sdk-common'; import validateOptions from '../../src/configuration/validateOptions'; +import { + BACKGROUND_POLL_INTERVAL_SECONDS, + DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, + MODE_TABLE, +} from '../../src/datasource/ConnectionModeConfig'; import { BROWSER_DATA_SYSTEM_DEFAULTS, dataSystemValidators, @@ -27,7 +32,7 @@ function validateDataSystemOptions( return validateOptions( input, dataSystemValidators, - defaults as unknown as Record, + { ...defaults, connectionModes: MODE_TABLE } as unknown as Record, testLogger, 'dataSystem', ); @@ -225,3 +230,263 @@ describe('given no logger', () => { expect(result.automaticModeSwitching).toBe(false); }); }); + +describe('given connectionModes with fdv1Fallback omitted', () => { + it('fills in fdv1Fallback defaults from MODE_TABLE when user omits it', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + streaming: { + initializers: [{ type: 'polling' }], + synchronizers: [{ type: 'streaming' }], + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.streaming.fdv1Fallback).toEqual({ + pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('preserves MODE_TABLE defaults for modes the user does not override', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + polling: { + initializers: [{ type: 'cache' }], + synchronizers: [{ type: 'polling', pollInterval: 60 }], + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.background.fdv1Fallback).toEqual({ + pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS, + }); + expect(modes.streaming.fdv1Fallback).toEqual({ + pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, + }); + }); +}); + +describe('given connectionModes with valid fdv1Fallback', () => { + it('accepts pollInterval without endpoints', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + streaming: { + initializers: [{ type: 'cache' }], + synchronizers: [{ type: 'streaming' }], + fdv1Fallback: { pollInterval: 120 }, + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.streaming.fdv1Fallback).toEqual({ pollInterval: 120 }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('accepts endpoints without pollInterval and fills in default pollInterval', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + background: { + initializers: [{ type: 'cache' }], + synchronizers: [{ type: 'polling', pollInterval: 3600 }], + fdv1Fallback: { + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }, + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.background.fdv1Fallback).toEqual({ + pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS, + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('accepts both pollInterval and endpoints', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + streaming: { + initializers: [{ type: 'cache' }], + synchronizers: [{ type: 'streaming' }], + fdv1Fallback: { + pollInterval: 60, + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }, + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.streaming.fdv1Fallback).toEqual({ + pollInterval: 60, + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('fills in default pollInterval when fdv1Fallback is an empty object', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + background: { + initializers: [{ type: 'cache' }], + synchronizers: [{ type: 'polling', pollInterval: 3600 }], + fdv1Fallback: {}, + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.background.fdv1Fallback).toEqual({ + pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('given connectionModes with invalid fdv1Fallback fields', () => { + it('drops pollInterval and fills in default when it is a string', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + streaming: { + initializers: [], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: { pollInterval: 'fast' }, + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.streaming.fdv1Fallback).toEqual({ + pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, + }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollInterval')); + }); + + it('clamps pollInterval to minimum 30 and preserves endpoints', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + streaming: { + initializers: [], + synchronizers: [{ type: 'polling' }], + fdv1Fallback: { + pollInterval: 5, + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }, + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.streaming.fdv1Fallback).toEqual({ + pollInterval: 30, + endpoints: { pollingBaseUri: 'https://relay.example.com' }, + }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollInterval')); + }); + + it('drops pollingBaseUri and fills in default pollInterval when it is not a string', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + background: { + initializers: [{ type: 'cache' }], + synchronizers: [{ type: 'polling', pollInterval: 3600 }], + fdv1Fallback: { + endpoints: { pollingBaseUri: 123 }, + }, + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.background.fdv1Fallback).toEqual({ + pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS, + }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('pollingBaseUri')); + }); + + it('drops streamingBaseUri when it is not a string', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + streaming: { + initializers: [], + synchronizers: [{ type: 'streaming' }], + fdv1Fallback: { + pollInterval: 120, + endpoints: { streamingBaseUri: true }, + }, + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.streaming.fdv1Fallback).toEqual({ pollInterval: 120 }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('streamingBaseUri')); + }); + + it('drops fdv1Fallback entirely when it is not an object and fills in default', () => { + const result = validateDataSystemOptions( + { + connectionModes: { + streaming: { + initializers: [], + synchronizers: [{ type: 'streaming' }], + fdv1Fallback: 'invalid', + }, + }, + }, + MOBILE_DATA_SYSTEM_DEFAULTS, + logger, + ); + + const modes = result.connectionModes as any; + expect(modes.streaming.fdv1Fallback).toEqual({ + pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, + }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('fdv1Fallback')); + }); +}); diff --git a/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts b/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts index 0049393be6..6cd817b2f9 100644 --- a/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts +++ b/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts @@ -66,6 +66,24 @@ export interface StreamingDataSourceEntry { readonly endpoints?: EndpointConfig; } +/** + * Configuration for the FDv1 polling fallback within a mode definition. + * When fdv1Endpoints is provided at the platform level, this controls + * how the FDv1 fallback synchronizer behaves for a specific mode. + * + * This interface is not stable, and not subject to any backwards compatibility + * guarantees or semantic versioning. It is in early access. If you want access + * to this feature please join the EAP. + * https://launchdarkly.com/docs/sdk/features/data-saving-mode + */ +export interface FDv1FallbackConfig { + /** Poll interval for the FDv1 fallback in seconds. Minimum 30. */ + readonly pollInterval?: number; + + /** Endpoint overrides for the FDv1 fallback. */ + readonly endpoints?: EndpointConfig; +} + /** * An entry in the initializers list of a mode definition. Initializers * can be cache, polling, or streaming sources. diff --git a/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts b/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts index fd87a7fed1..f12bb5f1f1 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 { InitializerEntry, SynchronizerEntry } from './DataSourceEntry'; +import { FDv1FallbackConfig, InitializerEntry, SynchronizerEntry } from './DataSourceEntry'; /** * Defines the data pipeline for a connection mode: which data sources @@ -24,4 +24,16 @@ export interface ModeDefinition { * An empty array means no synchronization occurs (e.g., offline, one-shot). */ readonly synchronizers: ReadonlyArray; + + /** + * Configuration for the FDv1 polling fallback synchronizer for this mode. + * When the platform provides fdv1Endpoints, a fallback synchronizer is + * automatically appended to modes with synchronizers. This field controls + * the poll interval and endpoint overrides for that fallback. + * + * When omitted (or when a user overrides a mode without specifying this), + * the built-in default for the mode is used. The fallback cannot be removed + * through configuration. + */ + readonly fdv1Fallback?: FDv1FallbackConfig; } diff --git a/packages/shared/sdk-client/src/api/datasource/index.ts b/packages/shared/sdk-client/src/api/datasource/index.ts index b4dd293bd0..0795f0f33c 100644 --- a/packages/shared/sdk-client/src/api/datasource/index.ts +++ b/packages/shared/sdk-client/src/api/datasource/index.ts @@ -1,6 +1,7 @@ export type { default as FDv2ConnectionMode } from './FDv2ConnectionMode'; export type { EndpointConfig, + FDv1FallbackConfig, CacheDataSourceEntry, PollingDataSourceEntry, StreamingDataSourceEntry, diff --git a/packages/shared/sdk-client/src/configuration/validateOptions.ts b/packages/shared/sdk-client/src/configuration/validateOptions.ts index eb80835325..98fde7f557 100644 --- a/packages/shared/sdk-client/src/configuration/validateOptions.ts +++ b/packages/shared/sdk-client/src/configuration/validateOptions.ts @@ -254,6 +254,10 @@ export function anyOf(...validators: TypeValidator[]): CompoundValidator { * * @param keyValidator - Validates that each key is an allowed value. * @param valueValidator - Validates each value in the record. + * @param options - Optional configuration. + * @param options.defaults - Built-in defaults for the record entries. When + * provided, takes priority over defaults passed from the parent at + * validation time (same precedence as {@link validatorOf}). * * @example * ```ts @@ -268,7 +272,9 @@ export function anyOf(...validators: TypeValidator[]): CompoundValidator { export function recordOf( keyValidator: TypeValidator, valueValidator: TypeValidator, + options?: { defaults?: Record }, ): CompoundValidator { + const builtInDefaults = options?.defaults; return { is: (u: unknown) => TypeValidators.Object.is(u), getType: () => 'object', @@ -296,9 +302,9 @@ export function recordOf( } }); - const recordDefaults = TypeValidators.Object.is(defaults) - ? (defaults as Record) - : {}; + const recordDefaults = + builtInDefaults ?? + (TypeValidators.Object.is(defaults) ? (defaults as Record) : {}); return { value: validateOptions(filtered, validatorMap, recordDefaults, logger, name) }; }, }; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index ec0efa265a..64f1629c36 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -1,6 +1,7 @@ import { TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; import { type LDOptions } from '../api'; +import { MODE_TABLE } from '../datasource/ConnectionModeConfig'; import { dataSystemValidators, type PlatformDataSystemDefaults, @@ -49,7 +50,10 @@ export default function createValidators( cleanOldPersistentData: TypeValidators.Boolean, dataSystem: options?.dataSystemDefaults ? validatorOf(dataSystemValidators, { - defaults: options.dataSystemDefaults as unknown as Record, + defaults: { + ...options.dataSystemDefaults, + connectionModes: MODE_TABLE, + } as unknown as Record, }) : TypeValidators.Object, }; diff --git a/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts b/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts index 51573af7e3..6e2070430f 100644 --- a/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts +++ b/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts @@ -10,6 +10,7 @@ type ModeTable = { readonly [K in FDv2ConnectionMode]: ModeDefinition; }; +const DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS = 300; const BACKGROUND_POLL_INTERVAL_SECONDS = 3600; const dataSourceTypeValidator = TypeValidators.oneOf('cache', 'polling', 'streaming'); @@ -53,9 +54,15 @@ const synchronizerEntryArrayValidator = arrayOf('type', { streaming: streamingEntryValidators, }); +const fdv1FallbackValidators = { + pollInterval: TypeValidators.numberWithMin(30), + endpoints: validatorOf(endpointValidators), +}; + const modeDefinitionValidators = { initializers: initializerEntryArrayValidator, synchronizers: synchronizerEntryArrayValidator, + fdv1Fallback: validatorOf(fdv1FallbackValidators), }; const MODE_DEFINITION_DEFAULTS: Record = { @@ -63,19 +70,16 @@ const MODE_DEFINITION_DEFAULTS: Record = { synchronizers: [], }; -const connectionModesValidator = recordOf( - connectionModeValidator, - validatorOf(modeDefinitionValidators), -); - const MODE_TABLE: ModeTable = { streaming: { initializers: [{ type: 'cache' }, { type: 'polling' }], synchronizers: [{ type: 'streaming' }, { type: 'polling' }], + fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS }, }, polling: { initializers: [{ type: 'cache' }], synchronizers: [{ type: 'polling' }], + fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS }, }, offline: { initializers: [{ type: 'cache' }], @@ -88,13 +92,20 @@ const MODE_TABLE: ModeTable = { background: { initializers: [{ type: 'cache' }], synchronizers: [{ type: 'polling', pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS }], + fdv1Fallback: { pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS }, }, }; +const connectionModesValidator = recordOf( + connectionModeValidator, + validatorOf(modeDefinitionValidators), +); + export type { ModeTable }; export { MODE_TABLE, MODE_DEFINITION_DEFAULTS, + DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS, BACKGROUND_POLL_INTERVAL_SECONDS, connectionModeValidator, modeDefinitionValidators, diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index 3bf22e01e3..b7b8bfdf54 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -1,4 +1,10 @@ -import { Context, internal, LDHeaders, Platform } from '@launchdarkly/js-sdk-common'; +import { + Context, + internal, + LDHeaders, + Platform, + ServiceEndpoints, +} from '@launchdarkly/js-sdk-common'; import { FDv2ConnectionMode, @@ -266,10 +272,26 @@ export function createFDv2DataManagerBase( // Append a blocked FDv1 fallback synchronizer when configured and // when there are FDv2 synchronizers to fall back from. if (fdv1Endpoints && synchronizerSlots.length > 0) { + const fallbackConfig = modeDef.fdv1Fallback; + const fallbackPollIntervalMs = (fallbackConfig?.pollInterval ?? config.pollInterval) * 1000; + + const fallbackServiceEndpoints = + fallbackConfig?.endpoints?.pollingBaseUri || fallbackConfig?.endpoints?.streamingBaseUri + ? new ServiceEndpoints( + fallbackConfig.endpoints.streamingBaseUri ?? ctx.serviceEndpoints.streaming, + fallbackConfig.endpoints.pollingBaseUri ?? ctx.serviceEndpoints.polling, + ctx.serviceEndpoints.events, + ctx.serviceEndpoints.analyticsEventPath, + ctx.serviceEndpoints.diagnosticEventPath, + ctx.serviceEndpoints.includeAuthorizationHeader, + ctx.serviceEndpoints.payloadFilterKey, + ) + : ctx.serviceEndpoints; + const fdv1RequestorFactory = () => makeRequestor( ctx.plainContextString, - ctx.serviceEndpoints, + fallbackServiceEndpoints, fdv1Endpoints.polling(), ctx.requests, ctx.encoding, @@ -280,7 +302,7 @@ export function createFDv2DataManagerBase( ); const fdv1SyncFactory = () => - createFDv1PollingSynchronizer(fdv1RequestorFactory(), config.pollInterval * 1000, logger); + createFDv1PollingSynchronizer(fdv1RequestorFactory(), fallbackPollIntervalMs, logger); synchronizerSlots.push(createSynchronizerSlot(fdv1SyncFactory, { isFDv1Fallback: true })); }