Skip to content

Commit e254f77

Browse files
authored
feat: SourceFactoryProvider for declarative data source creation (#1209)
## Summary Converts: `{polling: {initializers: ['cache', 'polling'], synchronizers: ['streaming']}}` into real factories. - Add `SourceFactoryProvider` interface and `createDefaultSourceFactoryProvider` implementation - Converts declarative `InitializerEntry`/`SynchronizerEntry` config into concrete factories and synchronizer slots - Supports per-entry endpoint overrides (custom polling/streaming URIs) and interval overrides - Handles `cache`, `polling`, and `streaming` entry types Stacked on #1207. Independent of #1208 (FlagManager.applyChanges). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces a new factory layer that determines how polling/streaming/cache sources are instantiated (including endpoint/interval overrides), which can affect connection behavior if integrated incorrectly. Changes are well-covered by unit tests and are otherwise additive. > > **Overview** > Adds a new `SourceFactoryProvider` abstraction (with `createDefaultSourceFactoryProvider`) that converts declarative `InitializerEntry`/`SynchronizerEntry` configs into concrete FDv2 initializer factories and synchronizer slots, supporting `cache`, `polling`, and `streaming` entries. > > Implements per-entry overrides for endpoints and timing (poll interval and streaming reconnect delay), including ensuring the streaming ping handler always uses the latest selector and any endpoint-overridden polling requestor. > > Exports the new provider/types from `sdk-client` public entrypoint and updates CI package-size thresholds for `js-sdk-common` and `js-client-sdk-common` to accommodate the added code/tests. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit de39213. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/launchdarkly/js-core/pull/1209" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
1 parent 579f21f commit e254f77

5 files changed

Lines changed: 613 additions & 2 deletions

File tree

.github/workflows/common.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ jobs:
3535
target_file: 'packages/shared/common/dist/esm/index.mjs'
3636
package_name: '@launchdarkly/js-sdk-common'
3737
pr_number: ${{ github.event.number }}
38-
size_limit: 26000
38+
size_limit: 29000

.github/workflows/sdk-client.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ jobs:
3232
target_file: 'packages/shared/sdk-client/dist/esm/index.mjs'
3333
package_name: '@launchdarkly/js-client-sdk-common'
3434
pr_number: ${{ github.event.number }}
35-
size_limit: 24000
35+
size_limit: 38000
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
import {
2+
Context,
3+
Crypto,
4+
Encoding,
5+
LDLogger,
6+
Requests,
7+
ServiceEndpoints,
8+
} from '@launchdarkly/js-sdk-common';
9+
10+
import { InitializerEntry, SynchronizerEntry } from '../../src/api/datasource';
11+
import { DataSourcePaths } from '../../src/datasource/DataSourceConfig';
12+
import { createCacheInitializerFactory } from '../../src/datasource/fdv2/CacheInitializer';
13+
import { FDv2Requestor, makeFDv2Requestor } from '../../src/datasource/fdv2/FDv2Requestor';
14+
import { poll as fdv2Poll } from '../../src/datasource/fdv2/PollingBase';
15+
import { createPollingInitializer } from '../../src/datasource/fdv2/PollingInitializer';
16+
import { createPollingSynchronizer } from '../../src/datasource/fdv2/PollingSynchronizer';
17+
import { createSynchronizerSlot } from '../../src/datasource/fdv2/SourceManager';
18+
import { createStreamingBase } from '../../src/datasource/fdv2/StreamingFDv2Base';
19+
import { createStreamingInitializer } from '../../src/datasource/fdv2/StreamingInitializerFDv2';
20+
import { createStreamingSynchronizer } from '../../src/datasource/fdv2/StreamingSynchronizerFDv2';
21+
import {
22+
createDefaultSourceFactoryProvider,
23+
SourceFactoryContext,
24+
} from '../../src/datasource/SourceFactoryProvider';
25+
26+
jest.mock('../../src/datasource/fdv2/PollingInitializer');
27+
jest.mock('../../src/datasource/fdv2/PollingSynchronizer');
28+
jest.mock('../../src/datasource/fdv2/StreamingFDv2Base');
29+
jest.mock('../../src/datasource/fdv2/StreamingInitializerFDv2');
30+
jest.mock('../../src/datasource/fdv2/StreamingSynchronizerFDv2');
31+
jest.mock('../../src/datasource/fdv2/CacheInitializer');
32+
jest.mock('../../src/datasource/fdv2/FDv2Requestor');
33+
jest.mock('../../src/datasource/fdv2/PollingBase');
34+
35+
const mockCreatePollingInitializer = createPollingInitializer as jest.Mock;
36+
const mockCreatePollingSynchronizer = createPollingSynchronizer as jest.Mock;
37+
const mockCreateStreamingBase = createStreamingBase as jest.Mock;
38+
const mockCreateStreamingInitializer = createStreamingInitializer as jest.Mock;
39+
const mockCreateStreamingSynchronizer = createStreamingSynchronizer as jest.Mock;
40+
const mockCreateCacheInitializerFactory = createCacheInitializerFactory as jest.Mock;
41+
const mockMakeFDv2Requestor = makeFDv2Requestor as jest.Mock;
42+
const mockCreateSynchronizerSlot = createSynchronizerSlot as jest.Mock;
43+
const mockFdv2Poll = fdv2Poll as jest.Mock;
44+
45+
jest.mock('../../src/datasource/fdv2/SourceManager', () => ({
46+
createSynchronizerSlot: jest.fn((factory: any) => ({
47+
factory,
48+
isFDv1Fallback: false,
49+
state: 'available',
50+
})),
51+
}));
52+
53+
function makeContext(): Context {
54+
return Context.fromLDContext({ kind: 'user', key: 'test-user' });
55+
}
56+
57+
function makePaths(): DataSourcePaths {
58+
return {
59+
pathGet: jest.fn().mockReturnValue('/eval/test-path'),
60+
pathReport: jest.fn().mockReturnValue('/eval/report-path'),
61+
pathPost: jest.fn().mockReturnValue('/eval/post-path'),
62+
pathPing: jest.fn().mockReturnValue('/eval/ping-path'),
63+
};
64+
}
65+
66+
function makeSourceFactoryContext(overrides?: Partial<SourceFactoryContext>): SourceFactoryContext {
67+
return {
68+
requestor: { poll: jest.fn() } as unknown as FDv2Requestor,
69+
requests: {} as Requests,
70+
encoding: {} as Encoding,
71+
serviceEndpoints: new ServiceEndpoints(
72+
'https://stream.example.com',
73+
'https://poll.example.com',
74+
'https://events.example.com',
75+
),
76+
baseHeaders: { authorization: 'sdk-key' },
77+
queryParams: [],
78+
plainContextString: '{"kind":"user","key":"test-user"}',
79+
logger: {
80+
debug: jest.fn(),
81+
info: jest.fn(),
82+
warn: jest.fn(),
83+
error: jest.fn(),
84+
} as unknown as LDLogger,
85+
polling: {
86+
paths: makePaths(),
87+
intervalSeconds: 30,
88+
},
89+
streaming: {
90+
paths: makePaths(),
91+
initialReconnectDelaySeconds: 1,
92+
},
93+
storage: undefined,
94+
crypto: {} as Crypto,
95+
environmentNamespace: 'test-env',
96+
context: makeContext(),
97+
...overrides,
98+
};
99+
}
100+
101+
beforeEach(() => {
102+
jest.clearAllMocks();
103+
mockCreatePollingInitializer.mockReturnValue({ close: jest.fn() });
104+
mockCreatePollingSynchronizer.mockReturnValue({ close: jest.fn() });
105+
mockCreateStreamingBase.mockReturnValue({
106+
start: jest.fn(),
107+
close: jest.fn(),
108+
takeResult: jest.fn(),
109+
});
110+
mockCreateStreamingInitializer.mockReturnValue({ close: jest.fn() });
111+
mockCreateStreamingSynchronizer.mockReturnValue({ close: jest.fn() });
112+
mockCreateCacheInitializerFactory.mockReturnValue(jest.fn());
113+
mockMakeFDv2Requestor.mockReturnValue({ poll: jest.fn() });
114+
});
115+
116+
// --- createInitializerFactory ---
117+
118+
it('creates a PollingInitializer for a polling initializer entry', () => {
119+
const provider = createDefaultSourceFactoryProvider();
120+
const ctx = makeSourceFactoryContext();
121+
const entry: InitializerEntry = { type: 'polling' };
122+
123+
const factory = provider.createInitializerFactory(entry, ctx);
124+
125+
expect(factory).toBeDefined();
126+
const selectorGetter = () => 'some-selector';
127+
factory!(selectorGetter);
128+
expect(mockCreatePollingInitializer).toHaveBeenCalledWith(
129+
ctx.requestor,
130+
ctx.logger,
131+
selectorGetter,
132+
);
133+
});
134+
135+
it('creates a StreamingInitializer for a streaming initializer entry', () => {
136+
const provider = createDefaultSourceFactoryProvider();
137+
const ctx = makeSourceFactoryContext();
138+
const entry: InitializerEntry = { type: 'streaming' };
139+
140+
const factory = provider.createInitializerFactory(entry, ctx);
141+
142+
expect(factory).toBeDefined();
143+
const selectorGetter = () => 'some-selector';
144+
factory!(selectorGetter);
145+
expect(mockCreateStreamingBase).toHaveBeenCalledWith(
146+
expect.objectContaining({
147+
requests: ctx.requests,
148+
serviceEndpoints: ctx.serviceEndpoints,
149+
initialRetryDelayMillis: ctx.streaming.initialReconnectDelaySeconds * 1000,
150+
}),
151+
);
152+
expect(mockCreateStreamingInitializer).toHaveBeenCalledWith(
153+
mockCreateStreamingBase.mock.results[0].value,
154+
);
155+
});
156+
157+
it('creates a CacheInitializer for a cache initializer entry', () => {
158+
const provider = createDefaultSourceFactoryProvider();
159+
const ctx = makeSourceFactoryContext();
160+
const entry: InitializerEntry = { type: 'cache' };
161+
162+
const factory = provider.createInitializerFactory(entry, ctx);
163+
164+
expect(mockCreateCacheInitializerFactory).toHaveBeenCalledWith({
165+
storage: ctx.storage,
166+
crypto: ctx.crypto,
167+
environmentNamespace: ctx.environmentNamespace,
168+
context: ctx.context,
169+
logger: ctx.logger,
170+
});
171+
expect(factory).toBe(mockCreateCacheInitializerFactory.mock.results[0].value);
172+
});
173+
174+
it('returns undefined for an unknown initializer entry type', () => {
175+
const provider = createDefaultSourceFactoryProvider();
176+
const ctx = makeSourceFactoryContext();
177+
const entry = { type: 'unknown' } as unknown as InitializerEntry;
178+
179+
const factory = provider.createInitializerFactory(entry, ctx);
180+
181+
expect(factory).toBeUndefined();
182+
});
183+
184+
// --- createSynchronizerSlot ---
185+
186+
it('creates a PollingSynchronizer slot for a polling synchronizer entry', () => {
187+
const provider = createDefaultSourceFactoryProvider();
188+
const ctx = makeSourceFactoryContext();
189+
const entry: SynchronizerEntry = { type: 'polling' };
190+
191+
const slot = provider.createSynchronizerSlot(entry, ctx);
192+
193+
expect(slot).toBeDefined();
194+
expect(mockCreateSynchronizerSlot).toHaveBeenCalled();
195+
196+
// Invoke the factory that was passed to createSynchronizerSlot
197+
const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0];
198+
const selectorGetter = () => 'sel';
199+
factoryArg(selectorGetter);
200+
expect(mockCreatePollingSynchronizer).toHaveBeenCalledWith(
201+
ctx.requestor,
202+
ctx.logger,
203+
selectorGetter,
204+
ctx.polling.intervalSeconds * 1000,
205+
);
206+
});
207+
208+
it('creates a StreamingSynchronizer slot for a streaming synchronizer entry', () => {
209+
const provider = createDefaultSourceFactoryProvider();
210+
const ctx = makeSourceFactoryContext();
211+
const entry: SynchronizerEntry = { type: 'streaming' };
212+
213+
const slot = provider.createSynchronizerSlot(entry, ctx);
214+
215+
expect(slot).toBeDefined();
216+
expect(mockCreateSynchronizerSlot).toHaveBeenCalled();
217+
218+
// Invoke the factory that was passed to createSynchronizerSlot
219+
const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0];
220+
const selectorGetter = () => 'sel';
221+
factoryArg(selectorGetter);
222+
expect(mockCreateStreamingBase).toHaveBeenCalledWith(
223+
expect.objectContaining({
224+
requests: ctx.requests,
225+
serviceEndpoints: ctx.serviceEndpoints,
226+
initialRetryDelayMillis: ctx.streaming.initialReconnectDelaySeconds * 1000,
227+
}),
228+
);
229+
expect(mockCreateStreamingSynchronizer).toHaveBeenCalledWith(
230+
mockCreateStreamingBase.mock.results[0].value,
231+
);
232+
});
233+
234+
it('returns undefined for an unknown synchronizer entry type', () => {
235+
const provider = createDefaultSourceFactoryProvider();
236+
const ctx = makeSourceFactoryContext();
237+
const entry = { type: 'unknown' } as unknown as SynchronizerEntry;
238+
239+
const slot = provider.createSynchronizerSlot(entry, ctx);
240+
241+
expect(slot).toBeUndefined();
242+
});
243+
244+
// --- per-entry overrides ---
245+
246+
it('creates a new requestor when polling entry has endpoint overrides', () => {
247+
const provider = createDefaultSourceFactoryProvider();
248+
const ctx = makeSourceFactoryContext();
249+
const entry: InitializerEntry = {
250+
type: 'polling',
251+
endpoints: { pollingBaseUri: 'https://custom-poll.example.com' },
252+
};
253+
254+
const factory = provider.createInitializerFactory(entry, ctx);
255+
expect(factory).toBeDefined();
256+
257+
const selectorGetter = () => undefined;
258+
factory!(selectorGetter);
259+
260+
expect(mockMakeFDv2Requestor).toHaveBeenCalledWith(
261+
ctx.plainContextString,
262+
expect.objectContaining({
263+
polling: 'https://custom-poll.example.com',
264+
streaming: 'https://stream.example.com',
265+
}),
266+
ctx.polling.paths,
267+
ctx.requests,
268+
ctx.encoding,
269+
ctx.baseHeaders,
270+
ctx.queryParams,
271+
);
272+
273+
// Should use the new requestor, not the context one
274+
const newRequestor = mockMakeFDv2Requestor.mock.results[0].value;
275+
expect(mockCreatePollingInitializer).toHaveBeenCalledWith(
276+
newRequestor,
277+
ctx.logger,
278+
selectorGetter,
279+
);
280+
});
281+
282+
it('uses per-entry pollInterval override for polling synchronizer', () => {
283+
const provider = createDefaultSourceFactoryProvider();
284+
const ctx = makeSourceFactoryContext({ polling: { paths: makePaths(), intervalSeconds: 30 } });
285+
const entry: SynchronizerEntry = { type: 'polling', pollInterval: 60 };
286+
287+
provider.createSynchronizerSlot(entry, ctx);
288+
289+
const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0];
290+
const selectorGetter = () => undefined;
291+
factoryArg(selectorGetter);
292+
293+
expect(mockCreatePollingSynchronizer).toHaveBeenCalledWith(
294+
ctx.requestor,
295+
ctx.logger,
296+
selectorGetter,
297+
60000,
298+
);
299+
});
300+
301+
it('uses per-entry initialReconnectDelay override for streaming initializer', () => {
302+
const provider = createDefaultSourceFactoryProvider();
303+
const ctx = makeSourceFactoryContext({
304+
streaming: { paths: makePaths(), initialReconnectDelaySeconds: 1 },
305+
});
306+
const entry: InitializerEntry = { type: 'streaming', initialReconnectDelay: 5 };
307+
308+
const factory = provider.createInitializerFactory(entry, ctx);
309+
expect(factory).toBeDefined();
310+
factory!(() => undefined);
311+
312+
expect(mockCreateStreamingBase).toHaveBeenCalledWith(
313+
expect.objectContaining({
314+
initialRetryDelayMillis: 5000,
315+
}),
316+
);
317+
});
318+
319+
// --- ping handler ---
320+
321+
it('ping handler uses the factory selector getter, not a stale reference', () => {
322+
const provider = createDefaultSourceFactoryProvider();
323+
const ctx = makeSourceFactoryContext();
324+
const entry: InitializerEntry = { type: 'streaming' };
325+
326+
const factory = provider.createInitializerFactory(entry, ctx);
327+
expect(factory).toBeDefined();
328+
329+
let currentSelector: string | undefined = 'selector-v1';
330+
const selectorGetter = () => currentSelector;
331+
factory!(selectorGetter);
332+
333+
// Extract the pingHandler from the createStreamingBase call
334+
const streamingBaseArgs = mockCreateStreamingBase.mock.calls[0][0];
335+
const { pingHandler } = streamingBaseArgs;
336+
337+
// Update the selector after factory creation
338+
currentSelector = 'selector-v2';
339+
pingHandler.handlePing();
340+
341+
// The ping poll should use the fresh selector, not 'selector-v1'
342+
expect(mockFdv2Poll).toHaveBeenCalledWith(expect.anything(), 'selector-v2', false, ctx.logger);
343+
});
344+
345+
it('ping handler uses per-entry endpoint-overridden requestor', () => {
346+
const provider = createDefaultSourceFactoryProvider();
347+
const ctx = makeSourceFactoryContext();
348+
const entry: InitializerEntry = {
349+
type: 'streaming',
350+
endpoints: { pollingBaseUri: 'https://custom-poll.example.com' },
351+
};
352+
353+
const factory = provider.createInitializerFactory(entry, ctx);
354+
expect(factory).toBeDefined();
355+
factory!(() => undefined);
356+
357+
// Extract the pingHandler from the createStreamingBase call
358+
const streamingBaseArgs = mockCreateStreamingBase.mock.calls[0][0];
359+
const { pingHandler } = streamingBaseArgs;
360+
pingHandler.handlePing();
361+
362+
// The ping poll should use the overridden requestor, not ctx.requestor
363+
const overriddenRequestor = mockMakeFDv2Requestor.mock.results[0].value;
364+
expect(mockFdv2Poll).toHaveBeenCalledWith(overriddenRequestor, undefined, false, ctx.logger);
365+
});

0 commit comments

Comments
 (0)