Skip to content

Commit c7130cc

Browse files
authored
feat: Add experimental FDv2 configuration (unused) (#1169)
Add `dataSystem` option to LDOptions for FDv2 data system configuration. The field is marked @internal and stripped from public type declarations via stripInternal. When present, ConfigurationImpl deep-validates it using dataSystemValidators with platform-specific defaults passed via LDClientInternalOptions.dataSystemDefaults. - Browser SDK passes BROWSER_DATA_SYSTEM_DEFAULTS - React Native SDK passes MOBILE_DATA_SYSTEM_DEFAULTS - No behavioral changes — the configuration is accepted and validated but not yet wired to any data manager selection logic SDK-1935 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches shared configuration validation logic (new nested-default handling and validator generation), which could subtly affect option parsing across SDKs, though the feature is gated behind an internal/experimental option and is not yet wired to runtime behavior. > > **Overview** > Introduces an *experimental, `@internal`* `dataSystem` option on `LDOptions` to carry FDv2 data-system configuration, and adds deep validation for it when platform `dataSystemDefaults` are supplied. > > Browser and React Native clients now pass platform-specific defaults (`BROWSER_DATA_SYSTEM_DEFAULTS`, `MOBILE_DATA_SYSTEM_DEFAULTS`) via `LDClientInternalOptions`, and the shared config layer switches from a static validator map to `createValidators()` plus an enhanced `validatorOf()` that can apply built-in defaults for nested objects. Tests were added to ensure `dataSystem` is not stripped from base options and is validated/merged (including warning/fallback behavior for invalid shapes/values). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fa111d5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2b9c49c commit c7130cc

9 files changed

Lines changed: 259 additions & 42 deletions

File tree

packages/sdk/browser/__tests__/options.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,19 @@ it('does not override common config flushInterval if it is set', () => {
9090
const result = filterToBaseOptionsWithDefaults(opts);
9191
expect(result.flushInterval).toEqual(15);
9292
});
93+
94+
it('passes dataSystem through to base options without stripping', () => {
95+
const opts = {
96+
dataSystem: { initialConnectionMode: 'polling' },
97+
} as any;
98+
const result = filterToBaseOptionsWithDefaults(opts);
99+
expect((result as any).dataSystem).toEqual({ initialConnectionMode: 'polling' });
100+
});
101+
102+
it('passes an empty dataSystem through to base options', () => {
103+
const opts = {
104+
dataSystem: {},
105+
} as any;
106+
const result = filterToBaseOptionsWithDefaults(opts);
107+
expect((result as any).dataSystem).toEqual({});
108+
});

packages/sdk/browser/src/BrowserClient.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AutoEnvAttributes,
33
BasicLogger,
4+
BROWSER_DATA_SYSTEM_DEFAULTS,
45
browserFdv1Endpoints,
56
Configuration,
67
FlagManager,
@@ -110,6 +111,7 @@ class BrowserClientImpl extends LDClientImpl {
110111
includeAuthorizationHeader: false,
111112
highTimeoutThreshold: 5,
112113
userAgentHeaderName: 'x-launchdarkly-user-agent',
114+
dataSystemDefaults: BROWSER_DATA_SYSTEM_DEFAULTS,
113115
trackEventModifier: (event: internal.InputCustomEvent) =>
114116
new internal.InputCustomEvent(
115117
event.context,

packages/sdk/react-native/src/ReactNativeLDClient.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
LDEmitter,
1212
LDHeaders,
1313
LDPluginEnvironmentMetadata,
14+
MOBILE_DATA_SYSTEM_DEFAULTS,
1415
mobileFdv1Endpoints,
1516
} from '@launchdarkly/js-client-sdk-common';
1617

@@ -65,6 +66,7 @@ export default class ReactNativeLDClient extends LDClientImpl {
6566
getImplementationHooks: (_environmentMetadata: LDPluginEnvironmentMetadata) =>
6667
internal.safeGetHooks(logger, _environmentMetadata, validatedRnOptions.plugins),
6768
credentialType: 'mobileKey',
69+
dataSystemDefaults: MOBILE_DATA_SYSTEM_DEFAULTS,
6870
};
6971

7072
const platform = createPlatform(logger, options, validatedRnOptions.storage);

packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,149 @@ it('does not wrap already safe loggers', () => {
178178
const config = new ConfigurationImpl({ logger });
179179
expect(config.logger).toBe(logger);
180180
});
181+
182+
describe('dataSystem validation', () => {
183+
it('does not set dataSystem when not provided', () => {
184+
const config = new ConfigurationImpl(
185+
{},
186+
{
187+
getImplementationHooks: () => [],
188+
credentialType: 'clientSideId',
189+
dataSystemDefaults: {
190+
initialConnectionMode: 'one-shot',
191+
automaticModeSwitching: false,
192+
},
193+
},
194+
);
195+
expect(config.dataSystem).toBeUndefined();
196+
});
197+
198+
it('validates dataSystem with platform defaults when provided as empty object', () => {
199+
const config = new ConfigurationImpl(
200+
// @ts-ignore dataSystem is @internal
201+
{ dataSystem: {} },
202+
{
203+
getImplementationHooks: () => [],
204+
credentialType: 'clientSideId',
205+
dataSystemDefaults: {
206+
initialConnectionMode: 'one-shot',
207+
automaticModeSwitching: false,
208+
},
209+
},
210+
);
211+
expect(config.dataSystem).toBeDefined();
212+
expect(config.dataSystem!.initialConnectionMode).toBe('one-shot');
213+
expect(config.dataSystem!.automaticModeSwitching).toBe(false);
214+
});
215+
216+
it('validates dataSystem with user overrides applied over platform defaults', () => {
217+
const config = new ConfigurationImpl(
218+
// @ts-ignore dataSystem is @internal
219+
{ dataSystem: { initialConnectionMode: 'polling' } },
220+
{
221+
getImplementationHooks: () => [],
222+
credentialType: 'mobileKey',
223+
dataSystemDefaults: {
224+
initialConnectionMode: 'streaming',
225+
backgroundConnectionMode: 'background',
226+
automaticModeSwitching: true,
227+
},
228+
},
229+
);
230+
expect(config.dataSystem).toBeDefined();
231+
expect(config.dataSystem!.initialConnectionMode).toBe('polling');
232+
expect(config.dataSystem!.backgroundConnectionMode).toBe('background');
233+
expect(config.dataSystem!.automaticModeSwitching).toBe(true);
234+
});
235+
236+
it('warns and falls back to default for invalid dataSystem sub-fields', () => {
237+
console.error = jest.fn();
238+
const config = new ConfigurationImpl(
239+
// @ts-ignore dataSystem is @internal
240+
{ dataSystem: { initialConnectionMode: 'turbo' } },
241+
{
242+
getImplementationHooks: () => [],
243+
credentialType: 'clientSideId',
244+
dataSystemDefaults: {
245+
initialConnectionMode: 'one-shot',
246+
automaticModeSwitching: false,
247+
},
248+
},
249+
);
250+
expect(config.dataSystem).toBeDefined();
251+
expect(config.dataSystem!.initialConnectionMode).toBe('one-shot');
252+
expect(console.error).toHaveBeenCalledWith(
253+
expect.stringContaining('dataSystem.initialConnectionMode'),
254+
);
255+
});
256+
257+
it('does not deep-validate dataSystem when dataSystemDefaults is not provided', () => {
258+
const config = new ConfigurationImpl(
259+
// @ts-ignore dataSystem is @internal
260+
{ dataSystem: { initialConnectionMode: 'polling' } },
261+
{
262+
getImplementationHooks: () => [],
263+
credentialType: 'clientSideId',
264+
},
265+
);
266+
// Without defaults, deep validation is skipped — raw object from basic validator
267+
expect(config.dataSystem).toBeDefined();
268+
});
269+
270+
it('warns and ignores dataSystem when set to a non-object value', () => {
271+
console.error = jest.fn();
272+
const config = new ConfigurationImpl(
273+
// @ts-ignore dataSystem is @internal
274+
{ dataSystem: 'streaming' },
275+
{
276+
getImplementationHooks: () => [],
277+
credentialType: 'clientSideId',
278+
dataSystemDefaults: {
279+
initialConnectionMode: 'one-shot',
280+
automaticModeSwitching: false,
281+
},
282+
},
283+
);
284+
expect(config.dataSystem).toBeUndefined();
285+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('dataSystem'));
286+
});
287+
288+
it('validates automaticModeSwitching as a granular config object', () => {
289+
const config = new ConfigurationImpl(
290+
// @ts-ignore dataSystem is @internal
291+
{ dataSystem: { automaticModeSwitching: { lifecycle: true, network: false } } },
292+
{
293+
getImplementationHooks: () => [],
294+
credentialType: 'mobileKey',
295+
dataSystemDefaults: {
296+
initialConnectionMode: 'streaming',
297+
automaticModeSwitching: true,
298+
},
299+
},
300+
);
301+
expect(config.dataSystem).toBeDefined();
302+
expect(config.dataSystem!.automaticModeSwitching).toEqual({
303+
lifecycle: true,
304+
network: false,
305+
});
306+
});
307+
308+
it('does not set other config fields when dataSystem defaults are provided', () => {
309+
const config = new ConfigurationImpl(
310+
{},
311+
{
312+
getImplementationHooks: () => [],
313+
credentialType: 'clientSideId',
314+
dataSystemDefaults: {
315+
initialConnectionMode: 'one-shot',
316+
automaticModeSwitching: false,
317+
},
318+
},
319+
);
320+
// dataSystem defaults should not leak into the config when dataSystem is not provided
321+
expect(config.dataSystem).toBeUndefined();
322+
// Other fields should retain their normal defaults
323+
expect(config.sendEvents).toBe(true);
324+
expect(config.capacity).toBe(100);
325+
});
326+
});

packages/shared/sdk-client/src/api/LDOptions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { LDLogger } from '@launchdarkly/js-sdk-common';
22

3+
import { LDClientDataSystemOptions } from './datasource/LDClientDataSystemOptions';
34
import { Hook } from './integrations/Hooks';
45
import { LDInspection } from './LDInspection';
56

@@ -279,4 +280,19 @@ export interface LDOptions {
279280
* @defaultValue false.
280281
*/
281282
cleanOldPersistentData?: boolean;
283+
284+
/**
285+
* @internal
286+
*
287+
* WARNING: This option is EXPERIMENTAL and UNSUPPORTED. It is subject to
288+
* change or removal without notice. Do not use in production applications.
289+
* Using this option may result in unexpected behavior, data loss, or
290+
* breaking changes in future SDK versions. LaunchDarkly does not provide
291+
* support for configurations using this option.
292+
*
293+
* Configuration for the FDv2 data system. When present, the SDK uses
294+
* the FDv2 protocol for flag delivery instead of the default FDv1
295+
* protocol.
296+
*/
297+
dataSystem?: LDClientDataSystemOptions;
282298
}

packages/shared/sdk-client/src/configuration/Configuration.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ import {
1010
} from '@launchdarkly/js-sdk-common';
1111

1212
import { Hook, type LDOptions } from '../api';
13+
import type {
14+
LDClientDataSystemOptions,
15+
PlatformDataSystemDefaults,
16+
} from '../api/datasource/LDClientDataSystemOptions';
1317
import { LDInspection } from '../api/LDInspection';
1418
import validateOptions from './validateOptions';
15-
import validators from './validators';
19+
import createValidators from './validators';
1620

1721
const DEFAULT_POLLING_INTERVAL: number = 60 * 5;
1822

@@ -21,6 +25,7 @@ export interface LDClientInternalOptions extends internal.LDInternalOptions {
2125
getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => Hook[];
2226
credentialType: 'clientSideId' | 'mobileKey';
2327
getLegacyStorageKeys?: () => string[];
28+
dataSystemDefaults?: PlatformDataSystemDefaults;
2429
}
2530

2631
export interface Configuration {
@@ -59,6 +64,7 @@ export interface Configuration {
5964
readonly inspectors: LDInspection[];
6065
readonly credentialType: 'clientSideId' | 'mobileKey';
6166
readonly getImplementationHooks: (environmentMetadata: LDPluginEnvironmentMetadata) => Hook[];
67+
readonly dataSystem?: LDClientDataSystemOptions;
6268
}
6369

6470
const DEFAULT_POLLING: string = 'https://clientsdk.launchdarkly.com';
@@ -138,6 +144,7 @@ export default class ConfigurationImpl implements Configuration {
138144
public readonly getImplementationHooks: (
139145
environmentMetadata: LDPluginEnvironmentMetadata,
140146
) => Hook[];
147+
public readonly dataSystem?: LDClientDataSystemOptions;
141148

142149
// Allow indexing Configuration by a string
143150
[index: string]: any;
@@ -150,6 +157,9 @@ export default class ConfigurationImpl implements Configuration {
150157
},
151158
) {
152159
this.logger = ensureSafeLogger(pristineOptions.logger);
160+
const validators = createValidators({
161+
dataSystemDefaults: internalOptions.dataSystemDefaults,
162+
});
153163
const validated = validateOptions(
154164
pristineOptions as Record<string, unknown>,
155165
validators,

packages/shared/sdk-client/src/configuration/validateOptions.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ export default function validateOptions(
111111
* `validateOptions` will recursively validate the nested object's properties.
112112
* Defaults for nested fields are passed through from the parent.
113113
*/
114-
export function validatorOf(validators: Record<string, TypeValidator>): CompoundValidator {
114+
export function validatorOf(
115+
validators: Record<string, TypeValidator>,
116+
builtInDefaults?: Record<string, unknown>,
117+
): CompoundValidator {
115118
return {
116119
is: (u: unknown) => TypeValidators.Object.is(u),
117120
getType: () => 'object',
@@ -120,9 +123,9 @@ export function validatorOf(validators: Record<string, TypeValidator>): Compound
120123
logger?.warn(OptionMessages.wrongOptionType(name, 'object', typeof value));
121124
return undefined;
122125
}
123-
const nestedDefaults = TypeValidators.Object.is(defaults)
124-
? (defaults as Record<string, unknown>)
125-
: {};
126+
const nestedDefaults =
127+
builtInDefaults ??
128+
(TypeValidators.Object.is(defaults) ? (defaults as Record<string, unknown>) : {});
126129
const nested = validateOptions(
127130
value as Record<string, unknown>,
128131
validators,
Lines changed: 51 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,54 @@
1-
// eslint-disable-next-line max-classes-per-file
21
import { TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common';
32

43
import { type LDOptions } from '../api';
5-
6-
const validators: Record<keyof LDOptions, TypeValidator> = {
7-
logger: TypeValidators.Object,
8-
maxCachedContexts: TypeValidators.numberWithMin(0),
9-
10-
baseUri: TypeValidators.String,
11-
streamUri: TypeValidators.String,
12-
eventsUri: TypeValidators.String,
13-
14-
capacity: TypeValidators.numberWithMin(1),
15-
diagnosticRecordingInterval: TypeValidators.numberWithMin(2),
16-
flushInterval: TypeValidators.numberWithMin(2),
17-
streamInitialReconnectDelay: TypeValidators.numberWithMin(0),
18-
19-
allAttributesPrivate: TypeValidators.Boolean,
20-
debug: TypeValidators.Boolean,
21-
diagnosticOptOut: TypeValidators.Boolean,
22-
withReasons: TypeValidators.Boolean,
23-
sendEvents: TypeValidators.Boolean,
24-
25-
pollInterval: TypeValidators.numberWithMin(30),
26-
27-
useReport: TypeValidators.Boolean,
28-
29-
privateAttributes: TypeValidators.StringArray,
30-
31-
applicationInfo: TypeValidators.Object,
32-
wrapperName: TypeValidators.String,
33-
wrapperVersion: TypeValidators.String,
34-
payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/),
35-
hooks: TypeValidators.createTypeArray('Hook[]', {}),
36-
inspectors: TypeValidators.createTypeArray('LDInspection', {}),
37-
cleanOldPersistentData: TypeValidators.Boolean,
38-
};
39-
40-
export default validators;
4+
import type { PlatformDataSystemDefaults } from '../api/datasource/LDClientDataSystemOptions';
5+
import { dataSystemValidators } from '../datasource/LDClientDataSystemOptions';
6+
import { validatorOf } from './validateOptions';
7+
8+
export interface ValidatorOptions {
9+
dataSystemDefaults?: PlatformDataSystemDefaults;
10+
}
11+
12+
export default function createValidators(
13+
options?: ValidatorOptions,
14+
): Record<keyof LDOptions, TypeValidator> {
15+
return {
16+
logger: TypeValidators.Object,
17+
maxCachedContexts: TypeValidators.numberWithMin(0),
18+
19+
baseUri: TypeValidators.String,
20+
streamUri: TypeValidators.String,
21+
eventsUri: TypeValidators.String,
22+
23+
capacity: TypeValidators.numberWithMin(1),
24+
diagnosticRecordingInterval: TypeValidators.numberWithMin(2),
25+
flushInterval: TypeValidators.numberWithMin(2),
26+
streamInitialReconnectDelay: TypeValidators.numberWithMin(0),
27+
28+
allAttributesPrivate: TypeValidators.Boolean,
29+
debug: TypeValidators.Boolean,
30+
diagnosticOptOut: TypeValidators.Boolean,
31+
withReasons: TypeValidators.Boolean,
32+
sendEvents: TypeValidators.Boolean,
33+
34+
pollInterval: TypeValidators.numberWithMin(30),
35+
36+
useReport: TypeValidators.Boolean,
37+
38+
privateAttributes: TypeValidators.StringArray,
39+
40+
applicationInfo: TypeValidators.Object,
41+
wrapperName: TypeValidators.String,
42+
wrapperVersion: TypeValidators.String,
43+
payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/),
44+
hooks: TypeValidators.createTypeArray('Hook[]', {}),
45+
inspectors: TypeValidators.createTypeArray('LDInspection', {}),
46+
cleanOldPersistentData: TypeValidators.Boolean,
47+
dataSystem: options?.dataSystemDefaults
48+
? validatorOf(
49+
dataSystemValidators,
50+
options.dataSystemDefaults as unknown as Record<string, unknown>,
51+
)
52+
: TypeValidators.Object,
53+
};
54+
}

0 commit comments

Comments
 (0)