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 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..896d93596b 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,169 @@ 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('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 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 + // 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(); + 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 +351,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 +375,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 +402,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 +432,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 +455,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 +476,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 +503,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 +537,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 +585,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 +657,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 +739,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 +768,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 +833,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 +870,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 +899,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 +918,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 +969,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 +998,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 +1026,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 +1062,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 +1088,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..ac415f337f 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, @@ -134,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) { @@ -170,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; @@ -183,8 +200,30 @@ 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 (dataReceived) { + if (cacheOnlyDataSystem) { + // Cache-only data system with no synchronizer to produce a VALID + // 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(); + } 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(); } } 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; }