Skip to content
38 changes: 27 additions & 11 deletions packages/shared/common/src/internal/fdv2/protocolHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LDLogger } from '../../api';
import { isNullish } from '../../validators';
import {
DeleteObject,
FDv2Event,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof makeMemoryStorage>;

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');
});
});
27 changes: 27 additions & 0 deletions packages/shared/sdk-client/src/DataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -48,8 +49,27 @@ export interface LDClientDataSystemOptions {
*/
automaticModeSwitching?: boolean | AutomaticModeSwitchingConfig;

// Req 5.3.5 TBD — custom named modes reserved for future use.
// customModes?: Record<string, { initializers: ..., synchronizers: ... }>;
/**
* 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<Record<FDv2ConnectionMode, ModeDefinition>>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DataSourceEntry } from './DataSourceEntry';
import { InitializerEntry, SynchronizerEntry } from './DataSourceEntry';

/**
* Defines the data pipeline for a connection mode: which data sources
Expand All @@ -10,13 +10,13 @@ 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<DataSourceEntry>;
readonly initializers: ReadonlyArray<InitializerEntry>;

/**
* Ordered list of data sources for ongoing synchronization after
* initialization completes. Sources are in priority order with automatic
* failover to the next source if the primary fails.
* An empty array means no synchronization occurs (e.g., offline, one-shot).
*/
readonly synchronizers: ReadonlyArray<DataSourceEntry>;
readonly synchronizers: ReadonlyArray<SynchronizerEntry>;
}
2 changes: 2 additions & 0 deletions packages/shared/sdk-client/src/api/datasource/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export type {
CacheDataSourceEntry,
PollingDataSourceEntry,
StreamingDataSourceEntry,
InitializerEntry,
SynchronizerEntry,
DataSourceEntry,
} from './DataSourceEntry';
export type { ModeDefinition } from './ModeDefinition';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,6 +13,7 @@ const dataSystemValidators = {
initialConnectionMode: connectionModeValidator,
backgroundConnectionMode: connectionModeValidator,
automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(modeSwitchingValidators)),
connectionModes: connectionModesValidator,
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading