Skip to content

Commit d7ccfc1

Browse files
authored
feat: FDv2 types, refined validators, and DataManager interface (#1207)
This is the first of several PRs to integrate the changes from the testing FDv2 branch. ## Summary - Add `InitializerEntry`/`SynchronizerEntry` type aliases for type-safe mode definitions - Refine `protocolHandler` validation to use `isNullish` for version/target fields (allows `0`) - Add `connectionModes` config option to `LDClientDataSystemOptions` - Split validator arrays so synchronizers reject `cache` entries - Extend `DataManager` interface with optional `setForcedStreaming`, `setAutomaticStreamingState`, `setFlushCallback` methods - Export `MODE_TABLE` and `createDataSourceStatusManager` from sdk-client index <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk due to public API surface changes (new `connectionModes` option, new exported types/exports, and `DataManager` interface extensions) plus adjusted FDv2 protocol validation that could affect event processing in edge cases (e.g., version `0`). > > **Overview** > Adds a `connectionModes` override to `LDClientDataSystemOptions`, plus new `InitializerEntry`/`SynchronizerEntry` types so mode definitions can *type/validate* initializers vs synchronizers differently (notably disallowing `cache` in `synchronizers`), with accompanying validator updates and tests. > > Refines FDv2 `protocolHandler` validation to treat `0` as valid for `target`/`version`/`state` via `isNullish`, and adds warning logs when ignoring malformed or out-of-sequence events. > > Extends the `DataManager` interface with optional streaming/lifecycle hooks (`setForcedStreaming`, `setAutomaticStreamingState`, `setFlushCallback`) and updates public exports (including `MODE_TABLE` and `createDataSourceStatusManager`), plus a doc fix for `flag-eval` kind naming. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e315ed6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b84fe9d commit d7ccfc1

12 files changed

Lines changed: 158 additions & 22 deletions

File tree

packages/shared/common/src/internal/fdv2/protocolHandler.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LDLogger } from '../../api';
2+
import { isNullish } from '../../validators';
23
import {
34
DeleteObject,
45
FDv2Event,
@@ -111,7 +112,10 @@ export function createProtocolHandler(
111112
}
112113

113114
function processIntentNone(intent: PayloadIntent): ProtocolAction {
114-
if (!intent.id || !intent.target) {
115+
if (!intent.id || isNullish(intent.target)) {
116+
logger?.warn(
117+
`Ignoring 'none' intent with missing fields: id=${intent.id}, target=${intent.target}`,
118+
);
115119
return ACTION_NONE;
116120
}
117121

@@ -164,14 +168,15 @@ export function createProtocolHandler(
164168
}
165169

166170
function processPutObject(data: PutObject): ProtocolAction {
167-
if (
168-
protocolState === 'inactive' ||
169-
!tempId ||
170-
!data.kind ||
171-
!data.key ||
172-
!data.version ||
173-
!data.object
174-
) {
171+
if (protocolState === 'inactive' || !tempId) {
172+
logger?.warn('Received put-object before server-intent was established. Ignoring.');
173+
return ACTION_NONE;
174+
}
175+
176+
if (!data.kind || !data.key || isNullish(data.version) || !data.object) {
177+
logger?.warn(
178+
`Ignoring put-object with missing fields: kind=${data.kind}, key=${data.key}, version=${data.version}`,
179+
);
175180
return ACTION_NONE;
176181
}
177182

@@ -191,7 +196,15 @@ export function createProtocolHandler(
191196
}
192197

193198
function processDeleteObject(data: DeleteObject): ProtocolAction {
194-
if (protocolState === 'inactive' || !tempId || !data.kind || !data.key || !data.version) {
199+
if (protocolState === 'inactive' || !tempId) {
200+
logger?.warn('Received delete-object before server-intent was established. Ignoring.');
201+
return ACTION_NONE;
202+
}
203+
204+
if (!data.kind || !data.key || isNullish(data.version)) {
205+
logger?.warn(
206+
`Ignoring delete-object with missing fields: kind=${data.kind}, key=${data.key}, version=${data.version}`,
207+
);
195208
return ACTION_NONE;
196209
}
197210

@@ -214,7 +227,10 @@ export function createProtocolHandler(
214227
};
215228
}
216229

217-
if (!tempId || data.state === null || data.state === undefined || !data.version) {
230+
if (!tempId || isNullish(data.state) || isNullish(data.version)) {
231+
logger?.warn(
232+
`Ignoring payload-transferred with missing fields: state=${data.state}, version=${data.version}`,
233+
);
218234
resetAll();
219235
return ACTION_NONE;
220236
}

packages/shared/sdk-client/__tests__/datasource/ConnectionModeConfig.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,44 @@ describe('given entries with invalid type field', () => {
257257
});
258258
});
259259

260+
describe('given cache entries in synchronizers', () => {
261+
it('discards a cache entry from synchronizers and warns', () => {
262+
const result = validateModeDefinition(
263+
{ initializers: [], synchronizers: [{ type: 'cache' }] },
264+
'testMode',
265+
logger,
266+
);
267+
268+
expect(result.synchronizers).toEqual([]);
269+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('got cache'));
270+
});
271+
272+
it('keeps valid synchronizer entries and discards cache', () => {
273+
const result = validateModeDefinition(
274+
{
275+
initializers: [],
276+
synchronizers: [{ type: 'polling' }, { type: 'cache' }, { type: 'streaming' }],
277+
},
278+
'testMode',
279+
logger,
280+
);
281+
282+
expect(result.synchronizers).toEqual([{ type: 'polling' }, { type: 'streaming' }]);
283+
expect(logger.warn).toHaveBeenCalledTimes(1);
284+
});
285+
286+
it('allows cache as an initializer', () => {
287+
const result = validateModeDefinition(
288+
{ initializers: [{ type: 'cache' }], synchronizers: [] },
289+
'testMode',
290+
logger,
291+
);
292+
293+
expect(result.initializers).toEqual([{ type: 'cache' }]);
294+
expect(logger.warn).not.toHaveBeenCalled();
295+
});
296+
});
297+
260298
describe('given polling entries with invalid config', () => {
261299
it('drops pollInterval when it is a string and warns', () => {
262300
const result = validateModeDefinition(

packages/shared/sdk-client/src/DataManager.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,33 @@ export interface DataManager {
5353
* Closes the data manager. Any active connections are closed.
5454
*/
5555
close(): void;
56+
57+
/**
58+
* Force streaming on or off. When `true`, the data manager should
59+
* maintain a streaming connection. When `false`, streaming is disabled.
60+
* When `undefined`, the forced state is cleared and automatic behavior
61+
* takes over.
62+
*
63+
* Optional — only browser data managers implement this.
64+
*/
65+
setForcedStreaming?(streaming?: boolean): void;
66+
67+
/**
68+
* Update the automatic streaming state based on whether change listeners
69+
* are registered. When `true` and forced streaming is not set, the data
70+
* manager should activate streaming.
71+
*
72+
* Optional — only browser data managers implement this.
73+
*/
74+
setAutomaticStreamingState?(streaming: boolean): void;
75+
76+
/**
77+
* Set a callback to flush pending analytics events. Called immediately
78+
* (not debounced) when the lifecycle transitions to background.
79+
*
80+
* Optional — only FDv2 data managers implement this.
81+
*/
82+
setFlushCallback?(callback: () => void): void;
5683
}
5784

5885
/**

packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface EndpointConfig {
1414

1515
/**
1616
* Configuration for a cache data source entry.
17+
* Cache is only valid as an initializer (not a synchronizer).
1718
*/
1819
export interface CacheDataSourceEntry {
1920
readonly type: 'cache';
@@ -45,6 +46,21 @@ export interface StreamingDataSourceEntry {
4546
readonly endpoints?: EndpointConfig;
4647
}
4748

49+
/**
50+
* An entry in the initializers list of a mode definition. Initializers
51+
* can be cache, polling, or streaming sources.
52+
*/
53+
export type InitializerEntry =
54+
| CacheDataSourceEntry
55+
| PollingDataSourceEntry
56+
| StreamingDataSourceEntry;
57+
58+
/**
59+
* An entry in the synchronizers list of a mode definition. Synchronizers
60+
* can be polling or streaming sources (not cache).
61+
*/
62+
export type SynchronizerEntry = PollingDataSourceEntry | StreamingDataSourceEntry;
63+
4864
/**
4965
* A data source entry in a mode table. Each entry identifies a data source type
5066
* and carries type-specific configuration overrides.

packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import FDv2ConnectionMode from './FDv2ConnectionMode';
2+
import { ModeDefinition } from './ModeDefinition';
23

34
// When FDv2 becomes the default, this should be integrated into the
45
// main LDOptions interface (api/LDOptions.ts).
@@ -48,8 +49,27 @@ export interface LDClientDataSystemOptions {
4849
*/
4950
automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig;
5051

51-
// Req 5.3.5 TBD — custom named modes reserved for future use.
52-
// customModes?: Record<string, { initializers: ..., synchronizers: ... }>;
52+
/**
53+
* Override the data source pipeline for specific connection modes.
54+
*
55+
* Each key is a connection mode name (`'streaming'`, `'polling'`, `'offline'`,
56+
* `'one-shot'`, `'background'`). The value defines the initializers and
57+
* synchronizers for that mode, replacing the built-in defaults.
58+
*
59+
* Only the modes you specify are overridden — unspecified modes retain
60+
* their built-in definitions.
61+
*
62+
* @example
63+
* ```
64+
* connectionModes: {
65+
* streaming: {
66+
* initializers: [{ type: 'polling' }],
67+
* synchronizers: [{ type: 'streaming' }],
68+
* },
69+
* }
70+
* ```
71+
*/
72+
connectionModes?: Partial<Record<FDv2ConnectionMode, ModeDefinition>>;
5373
}
5474

5575
/**

packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DataSourceEntry } from './DataSourceEntry';
1+
import { InitializerEntry, SynchronizerEntry } from './DataSourceEntry';
22

33
/**
44
* Defines the data pipeline for a connection mode: which data sources
@@ -10,13 +10,13 @@ export interface ModeDefinition {
1010
* Sources are tried in order; the first that successfully provides a full
1111
* data set transitions the SDK out of the initialization phase.
1212
*/
13-
readonly initializers: ReadonlyArray<DataSourceEntry>;
13+
readonly initializers: ReadonlyArray<InitializerEntry>;
1414

1515
/**
1616
* Ordered list of data sources for ongoing synchronization after
1717
* initialization completes. Sources are in priority order with automatic
1818
* failover to the next source if the primary fails.
1919
* An empty array means no synchronization occurs (e.g., offline, one-shot).
2020
*/
21-
readonly synchronizers: ReadonlyArray<DataSourceEntry>;
21+
readonly synchronizers: ReadonlyArray<SynchronizerEntry>;
2222
}

packages/shared/sdk-client/src/api/datasource/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export type {
44
CacheDataSourceEntry,
55
PollingDataSourceEntry,
66
StreamingDataSourceEntry,
7+
InitializerEntry,
8+
SynchronizerEntry,
79
DataSourceEntry,
810
} from './DataSourceEntry';
911
export type { ModeDefinition } from './ModeDefinition';

packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,20 @@ const streamingEntryValidators = {
4242
endpoints: validatorOf(endpointValidators),
4343
};
4444

45-
const dataSourceEntryArrayValidator = arrayOf('type', {
45+
const initializerEntryArrayValidator = arrayOf('type', {
4646
cache: cacheEntryValidators,
4747
polling: pollingEntryValidators,
4848
streaming: streamingEntryValidators,
4949
});
5050

51+
const synchronizerEntryArrayValidator = arrayOf('type', {
52+
polling: pollingEntryValidators,
53+
streaming: streamingEntryValidators,
54+
});
55+
5156
const modeDefinitionValidators = {
52-
initializers: dataSourceEntryArrayValidator,
53-
synchronizers: dataSourceEntryArrayValidator,
57+
initializers: initializerEntryArrayValidator,
58+
synchronizers: synchronizerEntryArrayValidator,
5459
};
5560

5661
const MODE_DEFINITION_DEFAULTS: Record<string, unknown> = {

packages/shared/sdk-client/src/datasource/LDClientDataSystemOptions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { TypeValidators } from '@launchdarkly/js-sdk-common';
22

33
import type { PlatformDataSystemDefaults } from '../api/datasource';
44
import { anyOf, validatorOf } from '../configuration/validateOptions';
5-
import { connectionModeValidator } from './ConnectionModeConfig';
5+
import { connectionModesValidator, connectionModeValidator } from './ConnectionModeConfig';
66

77
const modeSwitchingValidators = {
88
lifecycle: TypeValidators.Boolean,
@@ -13,6 +13,7 @@ const dataSystemValidators = {
1313
initialConnectionMode: connectionModeValidator,
1414
backgroundConnectionMode: connectionModeValidator,
1515
automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(modeSwitchingValidators)),
16+
connectionModes: connectionModesValidator,
1617
};
1718

1819
/**

packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour
208208
recoveryTimeoutMs,
209209
);
210210

211+
if (conditions.promise) {
212+
logger?.debug('Fallback condition active for current synchronizer.');
213+
}
214+
211215
// try/finally ensures conditions are closed on all code paths.
212216
let synchronizerRunning = true;
213217
try {

0 commit comments

Comments
 (0)