From 4d6b1c2b778ddb648ae41e061dd6d990fa023fe8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:13:04 -0700 Subject: [PATCH 1/5] fix: FDv2 Only -- Adjust the behavior of initialization when only cache initializers are available. --- .../datasource/FDv2DataManagerBase.test.ts | 27 +++- .../datasource/SourceFactoryProvider.test.ts | 21 +-- .../datasource/fdv2/CacheInitializer.test.ts | 14 +- .../datasource/fdv2/FDv2DataSource.test.ts | 120 +++++++++++++----- .../datasource/fdv2/SourceManager.test.ts | 42 +++--- .../fdv2/orchestrationTestHelpers.ts | 8 +- .../src/datasource/FDv2DataManagerBase.ts | 6 +- .../src/datasource/SourceFactoryProvider.ts | 25 ++-- .../src/datasource/fdv2/CacheInitializer.ts | 33 ++--- .../src/datasource/fdv2/FDv2DataSource.ts | 14 +- .../src/datasource/fdv2/SourceManager.ts | 43 +++++-- 11 files changed, 242 insertions(+), 111 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts index 09a9feced8..a668ae882d 100644 --- a/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/FDv2DataManagerBase.test.ts @@ -93,8 +93,10 @@ function makeFlagManager() { function makeSourceFactoryProvider() { return { - createInitializerFactory: jest.fn((_entry: any) => jest.fn()), - createSynchronizerSlot: jest.fn((_entry: any) => createSynchronizerSlot(jest.fn())), + createInitializerFactory: jest.fn((entry: any) => + entry?.type === 'cache' ? { create: jest.fn(), isCache: true } : { create: jest.fn() }, + ), + createSynchronizerSlot: jest.fn((_entry: any) => createSynchronizerSlot({ create: jest.fn() })), }; } @@ -983,7 +985,7 @@ it('uses per-mode fdv1Fallback pollInterval from MODE_TABLE for background mode' const dsConfig = capturedDataSourceConfigs[0]; const fdv1Slot = dsConfig.synchronizerSlots[dsConfig.synchronizerSlots.length - 1]; // Invoke the factory to trigger createFDv1PollingSynchronizer. - fdv1Slot.factory(() => undefined); + fdv1Slot.factory.create(() => undefined); // The FDv1 fallback synchronizer should use background's default (3600s = 3600000ms). expect(createFDv1PollingSynchronizer).toHaveBeenCalledWith( @@ -1022,6 +1024,25 @@ it('resolves identify immediately when initial mode has no sources', async () => manager.close(); }); +it('builds offline mode with a cache-marked initializer factory and no synchronizers', async () => { + // Verifies that the offline mode passes an isCache-marked factory to the + // data source. FDv2DataSource is responsible for recognizing this + // cache-only configuration and completing initialization on a cache miss. + const manager = createFDv2DataManagerBase(makeBaseConfig({ foregroundMode: 'offline' })); + + const { resolve, reject } = await identifyManager(manager); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(reject).not.toHaveBeenCalled(); + + const dsConfig = capturedDataSourceConfigs[0]; + expect(dsConfig.synchronizerSlots).toEqual([]); + expect(dsConfig.initializerFactories).toHaveLength(1); + expect(dsConfig.initializerFactories[0].isCache).toBe(true); + + manager.close(); +}); + it('does not identify after close', async () => { const manager = createFDv2DataManagerBase(makeBaseConfig()); manager.close(); diff --git a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts index c68987bd0e..5069fed7f6 100644 --- a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts @@ -109,7 +109,7 @@ beforeEach(() => { }); mockCreateStreamingInitializer.mockReturnValue({ close: jest.fn() }); mockCreateStreamingSynchronizer.mockReturnValue({ close: jest.fn() }); - mockCreateCacheInitializerFactory.mockReturnValue(jest.fn()); + mockCreateCacheInitializerFactory.mockReturnValue({ create: jest.fn(), isCache: true }); mockMakeFDv2Requestor.mockReturnValue({ poll: jest.fn() }); }); @@ -124,7 +124,7 @@ it('creates a PollingInitializer for a polling initializer entry', () => { expect(factory).toBeDefined(); const selectorGetter = () => 'some-selector'; - factory!(selectorGetter); + factory!.create(selectorGetter); expect(mockCreatePollingInitializer).toHaveBeenCalledWith( ctx.requestor, ctx.logger, @@ -141,7 +141,7 @@ it('creates a StreamingInitializer for a streaming initializer entry', () => { expect(factory).toBeDefined(); const selectorGetter = () => 'some-selector'; - factory!(selectorGetter); + factory!.create(selectorGetter); expect(mockCreateStreamingBase).toHaveBeenCalledWith( expect.objectContaining({ requests: ctx.requests, @@ -169,6 +169,7 @@ it('creates a CacheInitializer for a cache initializer entry', () => { logger: ctx.logger, }); expect(factory).toBe(mockCreateCacheInitializerFactory.mock.results[0].value); + expect(factory!.isCache).toBe(true); }); it('returns undefined for an unknown initializer entry type', () => { @@ -196,7 +197,7 @@ it('creates a PollingSynchronizer slot for a polling synchronizer entry', () => // Invoke the factory that was passed to createSynchronizerSlot const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; const selectorGetter = () => 'sel'; - factoryArg(selectorGetter); + factoryArg.create(selectorGetter); expect(mockCreatePollingSynchronizer).toHaveBeenCalledWith( ctx.requestor, ctx.logger, @@ -218,7 +219,7 @@ it('creates a StreamingSynchronizer slot for a streaming synchronizer entry', () // Invoke the factory that was passed to createSynchronizerSlot const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; const selectorGetter = () => 'sel'; - factoryArg(selectorGetter); + factoryArg.create(selectorGetter); expect(mockCreateStreamingBase).toHaveBeenCalledWith( expect.objectContaining({ requests: ctx.requests, @@ -255,7 +256,7 @@ it('creates a new requestor when polling entry has endpoint overrides', () => { expect(factory).toBeDefined(); const selectorGetter = () => undefined; - factory!(selectorGetter); + factory!.create(selectorGetter); expect(mockMakeFDv2Requestor).toHaveBeenCalledWith( ctx.plainContextString, @@ -288,7 +289,7 @@ it('uses per-entry pollInterval override for polling synchronizer', () => { const factoryArg = mockCreateSynchronizerSlot.mock.calls[0][0]; const selectorGetter = () => undefined; - factoryArg(selectorGetter); + factoryArg.create(selectorGetter); expect(mockCreatePollingSynchronizer).toHaveBeenCalledWith( ctx.requestor, @@ -307,7 +308,7 @@ it('uses per-entry initialReconnectDelay override for streaming initializer', () const factory = provider.createInitializerFactory(entry, ctx); expect(factory).toBeDefined(); - factory!(() => undefined); + factory!.create(() => undefined); expect(mockCreateStreamingBase).toHaveBeenCalledWith( expect.objectContaining({ @@ -328,7 +329,7 @@ it('ping handler uses the factory selector getter, not a stale reference', () => let currentSelector: string | undefined = 'selector-v1'; const selectorGetter = () => currentSelector; - factory!(selectorGetter); + factory!.create(selectorGetter); // Extract the pingHandler from the createStreamingBase call const streamingBaseArgs = mockCreateStreamingBase.mock.calls[0][0]; @@ -352,7 +353,7 @@ it('ping handler uses per-entry endpoint-overridden requestor', () => { const factory = provider.createInitializerFactory(entry, ctx); expect(factory).toBeDefined(); - factory!(() => undefined); + factory!.create(() => undefined); // Extract the pingHandler from the createStreamingBase call const streamingBaseArgs = mockCreateStreamingBase.mock.calls[0][0]; diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/CacheInitializer.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/CacheInitializer.test.ts index ce921fb833..316b171d4f 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/CacheInitializer.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/CacheInitializer.test.ts @@ -39,7 +39,7 @@ function createInitializer( context, logger, }); - return factory(noSelector); + return factory.create(noSelector); } describe('CacheInitializer', () => { @@ -51,6 +51,16 @@ describe('CacheInitializer', () => { context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); }); + it('returns a factory marked as a cache initializer', () => { + const factory = createCacheInitializerFactory({ + storage: makeMemoryStorage(), + crypto, + environmentNamespace: TEST_NAMESPACE, + context, + }); + expect(factory.isCache).toBe(true); + }); + it('returns a changeSet with cached flags when cache is present', async () => { const storage = makeMemoryStorage(); const flags: Flags = { @@ -234,7 +244,7 @@ describe('CacheInitializer', () => { context, }); - const initializer = factory(selectorGetter); + const initializer = factory.create(selectorGetter); const result = await initializer.run(); expect(result.type).toBe('changeSet'); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts index b7083c7db0..bec4a3c838 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts @@ -14,6 +14,7 @@ import { } from '../../../src/datasource/fdv2/SourceManager'; import { Synchronizer } from '../../../src/datasource/fdv2/Synchronizer'; import { + makeCacheInitFactory, makeErrorInfo, makeInitFactory, makeLogger, @@ -134,6 +135,52 @@ it('does not mark data received for transfer-none initializer results', async () ds.close(); }); +it('resolves start() when only initializer is a cache initializer that returns transfer-none', async () => { + const dataCallback = jest.fn(); + const statusManager = makeStatusManager(); + const nonePayload = makePayload({ type: 'none' }); + + const ds = createFDv2DataSource({ + initializerFactories: [ + makeCacheInitFactory(makeMockInitializer(changeSet(nonePayload, false))), + ], + synchronizerSlots: [], + dataCallback, + statusManager, + selectorGetter: noSelector, + }); + + await ds.start(); + + expect(statusManager.requestStateUpdate).toHaveBeenCalledWith('VALID'); + expect(dataCallback).not.toHaveBeenCalled(); + ds.close(); +}); + +it('rejects when a cache initializer returns transfer-none but synchronizers exist', async () => { + const dataCallback = jest.fn(); + const statusManager = makeStatusManager(); + const nonePayload = makePayload({ type: 'none' }); + + // A synchronizer that produces a terminal error immediately -- no data will + // be delivered, so the orchestrator exhausts all sources and rejects. + const sync = makeMockSynchronizer([terminalError(makeErrorInfo(), false)]); + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; + + const ds = createFDv2DataSource({ + initializerFactories: [ + makeCacheInitFactory(makeMockInitializer(changeSet(nonePayload, false))), + ], + synchronizerSlots: slots, + dataCallback, + statusManager, + selectorGetter: noSelector, + }); + + await expect(ds.start()).rejects.toThrow('All data sources exhausted'); + ds.close(); +}); + it('continues past initializer errors', async () => { const dataCallback = jest.fn(); const statusManager = makeStatusManager(); @@ -187,7 +234,7 @@ it('skips to synchronizers when no initializers are configured', async () => { const payload = makePayload({ state: 'selector' }); const sync = makeMockSynchronizer([changeSet(payload, false)]); - const slots: SynchronizerSlot[] = [createSynchronizerSlot(() => sync)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -211,7 +258,7 @@ it('delivers changeSet from synchronizer to callback', async () => { const payload = makePayload({ state: 'sync-selector' }); const sync = makeMockSynchronizer([changeSet(payload, false)]); - const slots: SynchronizerSlot[] = [createSynchronizerSlot(() => sync)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -238,8 +285,8 @@ it('blocks synchronizer on terminal error and moves to next', async () => { const sync2 = makeMockSynchronizer([changeSet(payload, false)]); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => sync1), - createSynchronizerSlot(() => sync2), + createSynchronizerSlot({ create: () => sync1 }), + createSynchronizerSlot({ create: () => sync2 }), ]; const ds = createFDv2DataSource({ @@ -268,7 +315,7 @@ it('continues on interrupted results from synchronizer', async () => { interrupted(makeErrorInfo(), false), changeSet(payload, false), ]); - const slots: SynchronizerSlot[] = [createSynchronizerSlot(() => sync)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -291,7 +338,7 @@ it('continues on goodbye results from synchronizer', async () => { const payload = makePayload({ state: 'selector' }); const sync = makeMockSynchronizer([goodbye('reconnect', false), changeSet(payload, false)]); - const slots: SynchronizerSlot[] = [createSynchronizerSlot(() => sync)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -312,7 +359,7 @@ it('rejects start() when all synchronizers are exhausted without data', async () const statusManager = makeStatusManager(); const sync = makeMockSynchronizer([terminalError(makeErrorInfo(), false)]); - const slots: SynchronizerSlot[] = [createSynchronizerSlot(() => sync)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -339,8 +386,8 @@ it('triggers fdv1 fallback when synchronizer changeSet has fdv1Fallback flag', a const fdv1Sync = makeMockSynchronizer([changeSet(fdv1Payload, false)]); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => fdv2Sync), - createSynchronizerSlot(() => fdv1Sync, { isFDv1Fallback: true }), + createSynchronizerSlot({ create: () => fdv2Sync }), + createSynchronizerSlot({ create: () => fdv1Sync }, { isFDv1Fallback: true }), ]; const ds = createFDv2DataSource({ @@ -373,8 +420,8 @@ it('triggers fdv1 fallback on terminal error with fdv1Fallback flag', async () = const fdv1Sync = makeMockSynchronizer([changeSet(fdv1Payload, false)]); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => fdv2Sync), - createSynchronizerSlot(() => fdv1Sync, { isFDv1Fallback: true }), + createSynchronizerSlot({ create: () => fdv2Sync }), + createSynchronizerSlot({ create: () => fdv1Sync }, { isFDv1Fallback: true }), ]; const ds = createFDv2DataSource({ @@ -421,8 +468,8 @@ it('falls back to next synchronizer when fallback condition fires', async () => const sync2 = makeMockSynchronizer([changeSet(payload, false)]); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => sync1), - createSynchronizerSlot(() => sync2), + createSynchronizerSlot({ create: () => sync1 }), + createSynchronizerSlot({ create: () => sync2 }), ]; const ds = createFDv2DataSource({ @@ -493,8 +540,8 @@ it('recovers to primary synchronizer when recovery condition fires', async () => }); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(sync1Factory), - createSynchronizerSlot(sync2Factory), + createSynchronizerSlot({ create: sync1Factory }), + createSynchronizerSlot({ create: sync2Factory }), ]; const ds = createFDv2DataSource({ @@ -575,7 +622,7 @@ it('close during synchronization causes exit', async () => { }, }; - const slots: SynchronizerSlot[] = [createSynchronizerSlot(() => sync)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -604,7 +651,7 @@ it('passes selectorGetter from config through to source factories', async () => const payload = makePayload({ state: 'selector' }); const syncFactory = jest.fn(() => makeMockSynchronizer([changeSet(payload, false)])); - const slots: SynchronizerSlot[] = [createSynchronizerSlot(syncFactory)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: syncFactory })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -669,8 +716,10 @@ it('shutdown result from synchronizer exits without moving to next', async () => const secondSyncFactory = jest.fn(() => makeMockSynchronizer([changeSet(payload, false)])); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => makeMockSynchronizer([changeSet(payload, false), shutdown()])), - createSynchronizerSlot(secondSyncFactory), + createSynchronizerSlot({ + create: () => makeMockSynchronizer([changeSet(payload, false), shutdown()]), + }), + createSynchronizerSlot({ create: secondSyncFactory }), ]; const ds = createFDv2DataSource({ @@ -704,7 +753,7 @@ it('delivers multiple changeSets from synchronizer in order', async () => { changeSet(payload2, false), changeSet(payload3, false), ]); - const slots: SynchronizerSlot[] = [createSynchronizerSlot(() => sync)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -733,14 +782,14 @@ it('first initializer with selector prevents second initializer from running', a const statusManager = makeStatusManager(); const payload = makePayload({ state: 'good-selector' }); - const secondInitFactory = jest.fn(() => + const secondInitCreate = jest.fn(() => makeMockInitializer(changeSet(makePayload({ state: 'second' }), false)), ); const ds = createFDv2DataSource({ initializerFactories: [ makeInitFactory(makeMockInitializer(changeSet(payload, false))), - secondInitFactory, + { create: secondInitCreate }, ], synchronizerSlots: [], dataCallback, @@ -752,7 +801,7 @@ it('first initializer with selector prevents second initializer from running', a expect(dataCallback).toHaveBeenCalledTimes(1); expect(dataCallback).toHaveBeenCalledWith(payload); - expect(secondInitFactory).not.toHaveBeenCalled(); + expect(secondInitCreate).not.toHaveBeenCalled(); ds.close(); }); @@ -803,8 +852,8 @@ it('close during condition waiting exits cleanly', async () => { }; const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => sync), - createSynchronizerSlot(() => makeMockSynchronizer([])), + createSynchronizerSlot({ create: () => sync }), + createSynchronizerSlot({ create: () => makeMockSynchronizer([]) }), ]; const ds = createFDv2DataSource({ @@ -832,8 +881,8 @@ it('fdv1 fallback not triggered when fdv1Fallback flag is absent', async () => { const fdv1Factory = jest.fn(() => makeMockSynchronizer([])); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => makeMockSynchronizer([changeSet(payload, false)])), - createSynchronizerSlot(fdv1Factory, { isFDv1Fallback: true }), + createSynchronizerSlot({ create: () => makeMockSynchronizer([changeSet(payload, false)]) }), + createSynchronizerSlot({ create: fdv1Factory }, { isFDv1Fallback: true }), ]; const ds = createFDv2DataSource({ @@ -860,11 +909,14 @@ it('fdv1 fallback blocks other synchronizers', async () => { const secondSyncFactory = jest.fn(() => makeMockSynchronizer([])); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => makeMockSynchronizer([changeSet(fdv2Payload, true)])), - createSynchronizerSlot(secondSyncFactory), - createSynchronizerSlot(() => makeMockSynchronizer([changeSet(fdv1Payload, false)]), { - isFDv1Fallback: true, + createSynchronizerSlot({ + create: () => makeMockSynchronizer([changeSet(fdv2Payload, true)]), }), + createSynchronizerSlot({ create: secondSyncFactory }), + createSynchronizerSlot( + { create: () => makeMockSynchronizer([changeSet(fdv1Payload, false)]) }, + { isFDv1Fallback: true }, + ), ]; const ds = createFDv2DataSource({ @@ -893,7 +945,7 @@ it('fdv1 fallback ignored when no FDv1 synchronizer is configured', async () => // Synchronizer sends changeSet with fdv1Fallback flag but no FDv1 slot exists const sync = makeMockSynchronizer([changeSet(payload, true)]); - const slots: SynchronizerSlot[] = [createSynchronizerSlot(() => sync)]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: () => sync })]; const ds = createFDv2DataSource({ initializerFactories: [], @@ -919,8 +971,8 @@ it('fdv1 fallback triggered on interrupted result with fdv1Fallback flag', async const fdv1Sync = makeMockSynchronizer([changeSet(fdv1Payload, false)]); const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(() => fdv2Sync), - createSynchronizerSlot(() => fdv1Sync, { isFDv1Fallback: true }), + createSynchronizerSlot({ create: () => fdv2Sync }), + createSynchronizerSlot({ create: () => fdv1Sync }, { isFDv1Fallback: true }), ]; const ds = createFDv2DataSource({ diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/SourceManager.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/SourceManager.test.ts index 7f6c1117cf..dc68443467 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/SourceManager.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/SourceManager.test.ts @@ -15,7 +15,7 @@ import { // -- createSynchronizerSlot -- it('creates an available synchronizer slot by default', () => { - const factory: SynchronizerFactory = jest.fn(); + const factory: SynchronizerFactory = { create: jest.fn() }; const slot = createSynchronizerSlot(factory); expect(slot.state).toBe('available'); @@ -23,7 +23,7 @@ it('creates an available synchronizer slot by default', () => { }); it('creates a blocked synchronizer slot for FDv1 fallback', () => { - const factory: SynchronizerFactory = jest.fn(); + const factory: SynchronizerFactory = { create: jest.fn() }; const slot = createSynchronizerSlot(factory, { isFDv1Fallback: true }); expect(slot.state).toBe('blocked'); @@ -31,7 +31,7 @@ it('creates a blocked synchronizer slot for FDv1 fallback', () => { }); it('allows overriding synchronizer slot initial state', () => { - const factory: SynchronizerFactory = jest.fn(); + const factory: SynchronizerFactory = { create: jest.fn() }; const slot = createSynchronizerSlot(factory, { isFDv1Fallback: true, initialState: 'available', @@ -52,10 +52,10 @@ it('iterates initializers in order', () => { const manager = createSourceManager([factory1, factory2], [], () => undefined); expect(manager.getNextInitializerAndSetActive()).toBe(init1); - expect(factory1).toHaveBeenCalledWith(expect.any(Function)); + expect(factory1.create).toHaveBeenCalledWith(expect.any(Function)); expect(manager.getNextInitializerAndSetActive()).toBe(init2); - expect(factory2).toHaveBeenCalledWith(expect.any(Function)); + expect(factory2.create).toHaveBeenCalledWith(expect.any(Function)); }); it('returns undefined when all initializers are exhausted', () => { @@ -90,11 +90,12 @@ it('closes the previous active source when getting next initializer', () => { it('passes selectorGetter to initializer factories', () => { const selectorGetter = () => 'test-selector'; - const factory = jest.fn(() => makeMockInitializer()); + const factoryCreate = jest.fn(() => makeMockInitializer()); + const factory = { create: factoryCreate }; const manager = createSourceManager([factory], [], selectorGetter); manager.getNextInitializerAndSetActive(); - expect(factory).toHaveBeenCalledWith(selectorGetter); + expect(factoryCreate).toHaveBeenCalledWith(selectorGetter); }); // -- synchronizers -- @@ -140,8 +141,9 @@ it('returns undefined when all synchronizers are blocked', () => { it('wraps around to find available synchronizers', () => { const sync1a = makeMockSynchronizer(); const sync1b = makeMockSynchronizer(); - const factory1 = jest.fn string | undefined]>(); - factory1.mockReturnValueOnce(sync1a).mockReturnValueOnce(sync1b); + const factory1Create = jest.fn string | undefined]>(); + factory1Create.mockReturnValueOnce(sync1a).mockReturnValueOnce(sync1b); + const factory1: SynchronizerFactory = { create: factory1Create }; const sync2 = makeMockSynchronizer(); const slots: SynchronizerSlot[] = [ @@ -163,8 +165,9 @@ it('wraps around and skips blocked synchronizers', () => { const sync1 = makeMockSynchronizer(); const sync2a = makeMockSynchronizer(); const sync2b = makeMockSynchronizer(); - const factory2 = jest.fn string | undefined]>(); - factory2.mockReturnValueOnce(sync2a).mockReturnValueOnce(sync2b); + const factory2Create = jest.fn string | undefined]>(); + factory2Create.mockReturnValueOnce(sync2a).mockReturnValueOnce(sync2b); + const factory2: SynchronizerFactory = { create: factory2Create }; const slots: SynchronizerSlot[] = [ createSynchronizerSlot(makeSyncFactory(sync1)), @@ -241,8 +244,9 @@ it('blocks the current synchronizer', () => { it('resetSourceIndex allows re-iteration from the beginning', () => { const sync1a = makeMockSynchronizer(); const sync1b = makeMockSynchronizer(); - const factory1 = jest.fn string | undefined]>(); - factory1.mockReturnValueOnce(sync1a).mockReturnValueOnce(sync1b); + const factory1Create = jest.fn string | undefined]>(); + factory1Create.mockReturnValueOnce(sync1a).mockReturnValueOnce(sync1b); + const factory1: SynchronizerFactory = { create: factory1Create }; const slots: SynchronizerSlot[] = [ createSynchronizerSlot(factory1), @@ -300,14 +304,16 @@ it('fdv1Fallback resets synchronizer index so FDv1 slot is found', () => { }); it('hasFDv1Fallback returns true when FDv1 slot exists', () => { - const slots: SynchronizerSlot[] = [createSynchronizerSlot(jest.fn(), { isFDv1Fallback: true })]; + const slots: SynchronizerSlot[] = [ + createSynchronizerSlot({ create: jest.fn() }, { isFDv1Fallback: true }), + ]; const manager = createSourceManager([], slots, () => undefined); expect(manager.hasFDv1Fallback()).toBe(true); }); it('hasFDv1Fallback returns false when no FDv1 slot exists', () => { - const slots: SynchronizerSlot[] = [createSynchronizerSlot(jest.fn())]; + const slots: SynchronizerSlot[] = [createSynchronizerSlot({ create: jest.fn() })]; const manager = createSourceManager([], slots, () => undefined); expect(manager.hasFDv1Fallback()).toBe(false); @@ -367,9 +373,9 @@ it('isPrimeSynchronizer returns true when first slot is blocked and second is fi it('counts available synchronizers excluding blocked ones', () => { const slots: SynchronizerSlot[] = [ - createSynchronizerSlot(jest.fn()), - createSynchronizerSlot(jest.fn()), - createSynchronizerSlot(jest.fn(), { isFDv1Fallback: true }), + createSynchronizerSlot({ create: jest.fn() }), + createSynchronizerSlot({ create: jest.fn() }), + createSynchronizerSlot({ create: jest.fn() }, { isFDv1Fallback: true }), ]; const manager = createSourceManager([], slots, () => undefined); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/orchestrationTestHelpers.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/orchestrationTestHelpers.ts index 1c3ce0bdb1..2fe743e5d5 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/orchestrationTestHelpers.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/orchestrationTestHelpers.ts @@ -150,9 +150,13 @@ export function makeMockSynchronizer( } export function makeInitFactory(init: Initializer): InitializerFactory { - return jest.fn(() => init); + return { create: jest.fn(() => init) }; +} + +export function makeCacheInitFactory(init: Initializer): InitializerFactory { + return { create: jest.fn(() => init), isCache: true }; } export function makeSyncFactory(sync: Synchronizer): SynchronizerFactory { - return jest.fn(() => sync); + return { create: jest.fn(() => sync) }; } diff --git a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts index b7b8bfdf54..79373d25e9 100644 --- a/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts +++ b/packages/shared/sdk-client/src/datasource/FDv2DataManagerBase.ts @@ -301,8 +301,10 @@ export function createFDv2DataManagerBase( config.useReport, ); - const fdv1SyncFactory = () => - createFDv1PollingSynchronizer(fdv1RequestorFactory(), fallbackPollIntervalMs, logger); + const fdv1SyncFactory = { + create: () => + createFDv1PollingSynchronizer(fdv1RequestorFactory(), fallbackPollIntervalMs, logger), + }; synchronizerSlots.push(createSynchronizerSlot(fdv1SyncFactory, { isFDv1Fallback: true })); } diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts index 94725e6aac..61ca8e7567 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -190,13 +190,15 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { switch (entry.type) { case 'polling': { const requestor = resolvePollingRequestor(ctx, entry.endpoints); - return (sg: () => string | undefined) => - createPollingInitializer(requestor, ctx.logger, sg); + return { + create: (sg) => createPollingInitializer(requestor, ctx.logger, sg), + }; } case 'streaming': - return (sg: () => string | undefined) => - createStreamingInitializer(buildStreamingBase(entry, ctx, sg)); + return { + create: (sg) => createStreamingInitializer(buildStreamingBase(entry, ctx, sg)), + }; case 'cache': return createCacheInitializerFactory({ @@ -220,16 +222,15 @@ export function createDefaultSourceFactoryProvider(): SourceFactoryProvider { case 'polling': { const intervalMs = (entry.pollInterval ?? ctx.polling.intervalSeconds) * 1000; const requestor = resolvePollingRequestor(ctx, entry.endpoints); - const factory = (sg: () => string | undefined) => - createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs); - return createSynchronizerSlot(factory); + return createSynchronizerSlot({ + create: (sg) => createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs), + }); } - case 'streaming': { - const factory = (sg: () => string | undefined) => - createStreamingSynchronizer(buildStreamingBase(entry, ctx, sg)); - return createSynchronizerSlot(factory); - } + case 'streaming': + return createSynchronizerSlot({ + create: (sg) => createStreamingSynchronizer(buildStreamingBase(entry, ctx, sg)), + }); default: return undefined; diff --git a/packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts b/packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts index e817650ed9..44a2358233 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts @@ -90,22 +90,25 @@ async function loadFromCache(config: CacheInitializerConfig): Promise string | undefined): Initializer => { - let shutdownResolve: ((result: FDv2SourceResult) => void) | undefined; - const shutdownPromise = new Promise((resolve) => { - shutdownResolve = resolve; - }); + return { + isCache: true, + // The selectorGetter is ignored -- cache data has no selector. + create(_selectorGetter: () => string | undefined): Initializer { + let shutdownResolve: ((result: FDv2SourceResult) => void) | undefined; + const shutdownPromise = new Promise((resolve) => { + shutdownResolve = resolve; + }); - return { - async run(): Promise { - return Promise.race([shutdownPromise, loadFromCache(config)]); - }, + return { + async run(): Promise { + return Promise.race([shutdownPromise, loadFromCache(config)]); + }, - close(): void { - shutdownResolve?.(shutdown()); - shutdownResolve = undefined; - }, - }; + close(): void { + shutdownResolve?.(shutdown()); + shutdownResolve = undefined; + }, + }; + }, }; } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index 9d69f50863..77a2433493 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -91,6 +91,17 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour let initResolve: (() => void) | undefined; let initReject: ((err: Error) => void) | undefined; + // When every initializer is a cache initializer and there are no + // synchronizers, the cache is the only possible data source. A cache miss + // in that configuration must not fail initialization -- there is nowhere + // else for data to come from, and reporting an error would be meaningless. + // Mirrors the Android SDK's InitializerFromCache / hasAvailableSources + // behavior. + const cacheOnlyDataSystem = + initializerFactories.length > 0 && + initializerFactories.every((f) => f.isCache === true) && + synchronizerSlots.length === 0; + const sourceManager = createSourceManager( initializerFactories, synchronizerSlots, @@ -184,7 +195,8 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour } // All initializers exhausted. - if (dataReceived) { + if (dataReceived || cacheOnlyDataSystem) { + statusManager.requestStateUpdate('VALID'); markInitialized(); } } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/SourceManager.ts b/packages/shared/sdk-client/src/datasource/fdv2/SourceManager.ts index c306a44402..43aa2bffec 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/SourceManager.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/SourceManager.ts @@ -2,20 +2,39 @@ import { Initializer } from './Initializer'; import { Synchronizer } from './Synchronizer'; /** - * Factory function that creates an {@link Initializer} instance. - * - * @param selectorGetter Returns the current selector (basis) string, or - * `undefined` if no selector is available. + * Factory that creates an {@link Initializer} instance and carries optional + * metadata about the kind of initializer it produces. */ -export type InitializerFactory = (selectorGetter: () => string | undefined) => Initializer; +export interface InitializerFactory { + /** + * Create an {@link Initializer} instance. + * + * @param selectorGetter Returns the current selector (basis) string, or + * `undefined` if no selector is available. + */ + create(selectorGetter: () => string | undefined): Initializer; + + /** + * True if this factory produces a cache initializer. Used by the data + * source orchestrator to distinguish cache-only data systems (where a + * cache miss should still complete initialization) from general + * initializer chains. + */ + readonly isCache?: boolean; +} /** - * Factory function that creates a {@link Synchronizer} instance. - * - * @param selectorGetter Returns the current selector (basis) string, or - * `undefined` if no selector is available. + * Factory that creates a {@link Synchronizer} instance. */ -export type SynchronizerFactory = (selectorGetter: () => string | undefined) => Synchronizer; +export interface SynchronizerFactory { + /** + * Create a {@link Synchronizer} instance. + * + * @param selectorGetter Returns the current selector (basis) string, or + * `undefined` if no selector is available. + */ + create(selectorGetter: () => string | undefined): Synchronizer; +} /** * State of a synchronizer slot. @@ -144,7 +163,7 @@ export function createSourceManager( } closeActiveSource(); - const initializer = initializerFactories[initializerIndex](selectorGetter); + const initializer = initializerFactories[initializerIndex].create(selectorGetter); activeSource = initializer; return initializer; }, @@ -167,7 +186,7 @@ export function createSourceManager( const candidate = synchronizerSlots[synchronizerIndex]; if (candidate.state === 'available') { closeActiveSource(); - const synchronizer = candidate.factory(selectorGetter); + const synchronizer = candidate.factory.create(selectorGetter); activeSource = synchronizer; return synchronizer; } From 3e9be109033963db0b4a7d5a1e85d14881223896 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:41:55 -0700 Subject: [PATCH 2/5] fix: preserve error status on initializer exhaustion Split the end-of-chain branch in the FDv2 orchestrator so VALID is only requested for the cache-only-data-system case. When data was received from an earlier initializer and a later one errored, the combined branch was overwriting the reported error status back to VALID. Adds a regression test that fails under the old conflated branch. --- .../datasource/fdv2/FDv2DataSource.test.ts | 36 +++++++++++++++++++ .../src/datasource/fdv2/FDv2DataSource.ts | 10 +++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts index bec4a3c838..79b5d84f27 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts @@ -157,6 +157,42 @@ it('resolves start() when only initializer is a cache initializer that returns t ds.close(); }); +it('does not overwrite an error status when a later initializer fails after data was received', async () => { + // Scenario: initializer 1 delivers a payload without a selector (data + // received, status VALID). Initializer 2 errors, which reports an error + // status. When the chain exhausts with dataReceived=true, the orchestrator + // must NOT re-assert VALID, because doing so would silently overwrite the + // error status from the failed initializer. + const dataCallback = jest.fn(); + const statusManager = makeStatusManager(); + const logger = makeLogger(); + const payloadNoSelector = makePayload({ state: '' }); + + const ds = createFDv2DataSource({ + initializerFactories: [ + makeInitFactory(makeMockInitializer(changeSet(payloadNoSelector, false))), + makeInitFactory(makeMockInitializer(interrupted(makeErrorInfo(), false))), + ], + synchronizerSlots: [], + dataCallback, + statusManager, + selectorGetter: noSelector, + logger, + }); + + await ds.start(); + + expect(dataCallback).toHaveBeenCalledWith(payloadNoSelector); + expect(statusManager.reportError).toHaveBeenCalled(); + // Exactly one VALID request: from applyChangeSet on the first initializer. + // No second VALID from the exhaustion branch. + const validCalls = statusManager.requestStateUpdate.mock.calls.filter( + (args) => args[0] === 'VALID', + ); + expect(validCalls).toHaveLength(1); + ds.close(); +}); + it('rejects when a cache initializer returns transfer-none but synchronizers exist', async () => { const dataCallback = jest.fn(); const statusManager = makeStatusManager(); diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index 77a2433493..c530ff520d 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -195,9 +195,17 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour } // All initializers exhausted. - if (dataReceived || cacheOnlyDataSystem) { + if (cacheOnlyDataSystem) { + // Cache-only data system with no synchronizer to produce a VALID + // status on its own. Set VALID here so the data source reports ready + // even on a cache miss. statusManager.requestStateUpdate('VALID'); markInitialized(); + } else if (dataReceived) { + // At least one initializer delivered data. Do not overwrite any + // error status that a subsequent failed initializer may have + // reported -- the status will be driven by the synchronizers. + markInitialized(); } } From c7959e84c67dc9de0b178f57bcf3a86849dfd3f6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:41:15 -0700 Subject: [PATCH 3/5] fix: skip redundant VALID update on cache hit; test mixed init chain Add a !dataReceived guard inside the cacheOnlyDataSystem exhaustion branch so a cache hit does not request VALID twice (applyChangeSet already drove it). Adds a boundary test that locks the mixed-chain behavior: a cache initializer followed by a non-cache initializer where neither delivers data must NOT complete initialization, because cacheOnlyDataSystem is false. --- .../datasource/fdv2/FDv2DataSource.test.ts | 25 +++++++++++++++++++ .../src/datasource/fdv2/FDv2DataSource.ts | 9 ++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts index 79b5d84f27..e27b5f1892 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts @@ -193,6 +193,31 @@ it('does not overwrite an error status when a later initializer fails after data ds.close(); }); +it('rejects when a cache initializer is followed by a non-cache initializer and neither delivers data', async () => { + // Cache initializer misses (transfer-none) and a non-cache initializer + // also returns transfer-none. Because the chain includes a non-cache + // initializer, cacheOnlyDataSystem is false and the exhaustion branch + // must NOT complete initialization successfully. + const dataCallback = jest.fn(); + const statusManager = makeStatusManager(); + const nonePayload = makePayload({ type: 'none' }); + + const ds = createFDv2DataSource({ + initializerFactories: [ + makeCacheInitFactory(makeMockInitializer(changeSet(nonePayload, false))), + makeInitFactory(makeMockInitializer(changeSet(nonePayload, false))), + ], + synchronizerSlots: [], + dataCallback, + statusManager, + selectorGetter: noSelector, + }); + + await expect(ds.start()).rejects.toThrow('All data sources exhausted'); + expect(dataCallback).not.toHaveBeenCalled(); + ds.close(); +}); + it('rejects when a cache initializer returns transfer-none but synchronizers exist', async () => { const dataCallback = jest.fn(); const statusManager = makeStatusManager(); diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index c530ff520d..218d836898 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -197,9 +197,12 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour // All initializers exhausted. if (cacheOnlyDataSystem) { // Cache-only data system with no synchronizer to produce a VALID - // status on its own. Set VALID here so the data source reports ready - // even on a cache miss. - statusManager.requestStateUpdate('VALID'); + // status on its own. On a cache miss, nothing else has asserted + // VALID yet, so do it here. On a cache hit, applyChangeSet already + // asserted VALID -- skip the redundant call for idempotence. + if (!dataReceived) { + statusManager.requestStateUpdate('VALID'); + } markInitialized(); } else if (dataReceived) { // At least one initializer delivered data. Do not overwrite any From a90165a1a4f6690207f23dd5c870d3d025a6c2dc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:35:46 -0700 Subject: [PATCH 4/5] fix: guard close race and error overwrite in cache-only init Two subtle bugs in the cache-only exhaustion branch: 1. Close race: if close() runs before runInitializers starts, the loop was skipped entirely and the exhaustion branch fired VALID and marked the start() promise resolved, where it should reject with "closed before initialization completed." Add an early return when closed is set before the exhaustion branch. 2. VALID-over-error: the branch unconditionally requested VALID on cache miss. Today's default CacheInitializer never emits error statuses, but a custom cache-marked factory could. Track errorReportedDuringInit from the interrupted/terminal_error cases and skip the VALID request when an error was reported. Adds regression tests for both paths: close-before-init rejects and emits no VALID, and a cache-only factory emitting interrupted leaves the error status intact. --- .../datasource/fdv2/FDv2DataSource.test.ts | 56 +++++++++++++++++++ .../src/datasource/fdv2/FDv2DataSource.ts | 24 ++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts index e27b5f1892..896d93596b 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts @@ -193,6 +193,62 @@ it('does not overwrite an error status when a later initializer fails after data ds.close(); }); +it('rejects start() when close() is called before cache-only initialization runs', async () => { + // Race: close() happens before the runInitializers microtask starts. + // The cache-only success path must not fire a spurious VALID or resolve + // the start() promise; it must reject with "closed before initialization". + const dataCallback = jest.fn(); + const statusManager = makeStatusManager(); + const nonePayload = makePayload({ type: 'none' }); + + const ds = createFDv2DataSource({ + initializerFactories: [ + makeCacheInitFactory(makeMockInitializer(changeSet(nonePayload, false))), + ], + synchronizerSlots: [], + dataCallback, + statusManager, + selectorGetter: noSelector, + }); + + const startPromise = ds.start().catch((e) => e); + ds.close(); + const error = await startPromise; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('closed before initialization'); + expect(statusManager.requestStateUpdate).not.toHaveBeenCalledWith('VALID'); +}); + +it('does not overwrite error status when a cache-only initializer reports interrupted', async () => { + // Latent guard: even though the default CacheInitializer never emits + // interrupted/terminal_error, a custom cache-marked factory could. + // The cache-only exhaustion branch must not overwrite the reported + // error status with VALID. + const dataCallback = jest.fn(); + const statusManager = makeStatusManager(); + const logger = makeLogger(); + + const ds = createFDv2DataSource({ + initializerFactories: [ + makeCacheInitFactory(makeMockInitializer(interrupted(makeErrorInfo(), false))), + ], + synchronizerSlots: [], + dataCallback, + statusManager, + selectorGetter: noSelector, + logger, + }); + + // Initialization still completes (cache-only mode is always ready) but + // without overriding the reported error status. + await ds.start(); + + expect(statusManager.reportError).toHaveBeenCalled(); + expect(statusManager.requestStateUpdate).not.toHaveBeenCalledWith('VALID'); + ds.close(); +}); + it('rejects when a cache initializer is followed by a non-cache initializer and neither delivers data', async () => { // Cache initializer misses (transfer-none) and a non-cache initializer // also returns transfer-none. Because the chain includes a non-cache diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index 218d836898..ac415f337f 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -145,6 +145,11 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour // The orchestration loops intentionally use await-in-loop for sequential // state machine processing — one result at a time. async function runInitializers(): Promise { + // Tracks whether any initializer reported interrupted/terminal_error. + // Used below so the cache-only exhaustion branch does not overwrite + // that error status with VALID. + let errorReportedDuringInit = false; + while (!closed) { const initializer = sourceManager.getNextInitializerAndSetActive(); if (initializer === undefined) { @@ -181,6 +186,7 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour case 'terminal_error': logger?.warn(`Initializer failed: ${result.errorInfo?.message ?? 'unknown error'}`); reportStatusError(result); + errorReportedDuringInit = true; break; case 'shutdown': return; @@ -194,13 +200,23 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour } } + // close() between the last loop iteration and the exhaustion branch. + // Exit without marking initialized or emitting a spurious VALID; the + // start() promise will be rejected by the post-orchestration handler + // with "closed before initialization completed." + if (closed) { + return; + } + // All initializers exhausted. if (cacheOnlyDataSystem) { // Cache-only data system with no synchronizer to produce a VALID - // status on its own. On a cache miss, nothing else has asserted - // VALID yet, so do it here. On a cache hit, applyChangeSet already - // asserted VALID -- skip the redundant call for idempotence. - if (!dataReceived) { + // status on its own. On a cache miss with no errors, nothing else + // has asserted VALID yet, so do it here. Skip the update if: + // - dataReceived (cache hit): applyChangeSet already asserted VALID. + // - errorReportedDuringInit: reportError set an error status that + // must not be silently overwritten. + if (!dataReceived && !errorReportedDuringInit) { statusManager.requestStateUpdate('VALID'); } markInitialized(); From 1104dc5d6c432ad90721877dedec2cddc4b26052 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:12:22 -0700 Subject: [PATCH 5/5] chore: bump sdk-client size limit to 39000 --- .github/workflows/sdk-client.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sdk-client.yml b/.github/workflows/sdk-client.yml index b7fbade75e..9ab3d911e8 100644 --- a/.github/workflows/sdk-client.yml +++ b/.github/workflows/sdk-client.yml @@ -32,4 +32,4 @@ jobs: target_file: 'packages/shared/sdk-client/dist/esm/index.mjs' package_name: '@launchdarkly/js-client-sdk-common' pr_number: ${{ github.event.number }} - size_limit: 38000 + size_limit: 39000