@launchdarkly/js-client-sdk-common 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cjs/LDClientImpl.d.ts +19 -8
  3. package/dist/cjs/LDClientImpl.d.ts.map +1 -1
  4. package/dist/cjs/api/LDStartOptions.d.ts +19 -0
  5. package/dist/cjs/api/LDStartOptions.d.ts.map +1 -0
  6. package/dist/cjs/api/index.d.ts +1 -0
  7. package/dist/cjs/api/index.d.ts.map +1 -1
  8. package/dist/cjs/configuration/Configuration.d.ts +10 -0
  9. package/dist/cjs/configuration/Configuration.d.ts.map +1 -1
  10. package/dist/cjs/context/ensureKey.d.ts.map +1 -1
  11. package/dist/cjs/datasource/FDv2DataManagerBase.d.ts.map +1 -1
  12. package/dist/cjs/datasource/SourceFactoryProvider.d.ts.map +1 -1
  13. package/dist/cjs/datasource/fdv2/CacheInitializer.d.ts.map +1 -1
  14. package/dist/cjs/datasource/fdv2/FDv2DataSource.d.ts.map +1 -1
  15. package/dist/cjs/datasource/fdv2/SourceManager.d.ts +28 -10
  16. package/dist/cjs/datasource/fdv2/SourceManager.d.ts.map +1 -1
  17. package/dist/cjs/index.cjs +231 -95
  18. package/dist/cjs/index.cjs.map +1 -1
  19. package/dist/cjs/index.d.ts +1 -1
  20. package/dist/cjs/index.d.ts.map +1 -1
  21. package/dist/cjs/storage/getOrGenerateKey.d.ts +4 -1
  22. package/dist/cjs/storage/getOrGenerateKey.d.ts.map +1 -1
  23. package/dist/cjs/storage/namespaceUtils.d.ts +3 -6
  24. package/dist/cjs/storage/namespaceUtils.d.ts.map +1 -1
  25. package/dist/esm/LDClientImpl.d.ts +19 -8
  26. package/dist/esm/LDClientImpl.d.ts.map +1 -1
  27. package/dist/esm/api/LDStartOptions.d.ts +19 -0
  28. package/dist/esm/api/LDStartOptions.d.ts.map +1 -0
  29. package/dist/esm/api/index.d.ts +1 -0
  30. package/dist/esm/api/index.d.ts.map +1 -1
  31. package/dist/esm/configuration/Configuration.d.ts +10 -0
  32. package/dist/esm/configuration/Configuration.d.ts.map +1 -1
  33. package/dist/esm/context/ensureKey.d.ts.map +1 -1
  34. package/dist/esm/datasource/FDv2DataManagerBase.d.ts.map +1 -1
  35. package/dist/esm/datasource/SourceFactoryProvider.d.ts.map +1 -1
  36. package/dist/esm/datasource/fdv2/CacheInitializer.d.ts.map +1 -1
  37. package/dist/esm/datasource/fdv2/FDv2DataSource.d.ts.map +1 -1
  38. package/dist/esm/datasource/fdv2/SourceManager.d.ts +28 -10
  39. package/dist/esm/datasource/fdv2/SourceManager.d.ts.map +1 -1
  40. package/dist/esm/index.d.ts +1 -1
  41. package/dist/esm/index.d.ts.map +1 -1
  42. package/dist/esm/index.mjs +231 -95
  43. package/dist/esm/index.mjs.map +1 -1
  44. package/dist/esm/storage/getOrGenerateKey.d.ts +4 -1
  45. package/dist/esm/storage/getOrGenerateKey.d.ts.map +1 -1
  46. package/dist/esm/storage/namespaceUtils.d.ts +3 -6
  47. package/dist/esm/storage/namespaceUtils.d.ts.map +1 -1
  48. package/package.json +1 -3
@@ -680,13 +680,28 @@ async function digest(hasher, encoding) {
680
680
  * @param storageKey keyed storage location where the generated key should live. See {@link namespaceForGeneratedContextKey}
681
681
  * for related exmaples of generating a storage key and usage.
682
682
  * @param platform crypto and storage implementations for necessary operations
683
+ * @param legacyStorageKey optional legacy storage key to migrate from. If the key is not found
684
+ * under {@link storageKey} but exists under this legacy key, it will be migrated to the new
685
+ * location and the legacy key will be cleared.
683
686
  * @returns the generated key
684
687
  */
685
- const getOrGenerateKey = async (storageKey, { crypto, storage }) => {
688
+ const getOrGenerateKey = async (storageKey, { crypto, storage }, legacyStorageKey) => {
686
689
  let generatedKey = await storage?.get(storageKey);
687
- if (!generatedKey) {
688
- generatedKey = crypto.randomUUID();
689
- await storage?.set(storageKey, generatedKey);
690
+ if (generatedKey == null) {
691
+ if (legacyStorageKey) {
692
+ generatedKey = await storage?.get(legacyStorageKey);
693
+ if (generatedKey != null) {
694
+ await storage?.set(storageKey, generatedKey);
695
+ const verified = await storage?.get(storageKey);
696
+ if (verified != null) {
697
+ await storage?.clear(legacyStorageKey);
698
+ }
699
+ }
700
+ }
701
+ if (generatedKey == null) {
702
+ generatedKey = crypto.randomUUID();
703
+ await storage?.set(storageKey, generatedKey);
704
+ }
690
705
  }
691
706
  return generatedKey;
692
707
  };
@@ -709,12 +724,9 @@ async function namespaceForEnvironment(crypto, sdkKey) {
709
724
  ]);
710
725
  }
711
726
  /**
712
- * @deprecated prefer {@link namespaceForGeneratedContextKey}. At one time we only generated keys for
713
- * anonymous contexts and they were namespaced in LaunchDarkly_AnonymousKeys. Eventually we started
714
- * generating context keys for non-anonymous contexts such as for the Auto Environment Attributes
715
- * feature and those were namespaced in LaunchDarkly_ContextKeys. This function can be removed
716
- * when the data under the LaunchDarkly_AnonymousKeys namespace is merged with data under the
717
- * LaunchDarkly_ContextKeys namespace.
727
+ * @deprecated Used only for migration in ensureKey. Data stored under LaunchDarkly_AnonymousKeys
728
+ * is now migrated to LaunchDarkly_ContextKeys on first access. This function can be removed once
729
+ * all clients have had the chance to run the migration.
718
730
  */
719
731
  async function namespaceForAnonymousGeneratedContextKey(kind) {
720
732
  return concatNamespacesAndValues([
@@ -897,10 +909,11 @@ const { isLegacyUser, isMultiKind, isSingleKind } = internal;
897
909
  const ensureKeyCommon = async (kind, c, platform) => {
898
910
  const { anonymous, key } = c;
899
911
  if (anonymous && !key) {
900
- const storageKey = await namespaceForAnonymousGeneratedContextKey(kind);
912
+ const storageKey = await namespaceForGeneratedContextKey(kind);
913
+ const legacyStorageKey = await namespaceForAnonymousGeneratedContextKey(kind);
901
914
  // This mutates a cloned copy of the original context from ensureyKey so this is safe.
902
915
  // eslint-disable-next-line no-param-reassign
903
- c.key = await getOrGenerateKey(storageKey, platform);
916
+ c.key = await getOrGenerateKey(storageKey, platform, legacyStorageKey);
904
917
  }
905
918
  };
906
919
  const ensureKeySingle = async (c, platform) => {
@@ -999,6 +1012,46 @@ class EventFactory extends internal.EventFactoryBase {
999
1012
  }
1000
1013
  }
1001
1014
 
1015
+ function readFlagsFromBootstrap(logger, data) {
1016
+ // If the bootstrap data came from an older server-side SDK, we'll have just a map of keys to values.
1017
+ // Newer SDKs that have an allFlagsState method will provide an extra "$flagsState" key that contains
1018
+ // the rest of the metadata we want. We do it this way for backward compatibility with older JS SDKs.
1019
+ const keys = Object.keys(data);
1020
+ const metadataKey = '$flagsState';
1021
+ const validKey = '$valid';
1022
+ const metadata = data[metadataKey];
1023
+ if (!metadata && keys.length) {
1024
+ logger.warn('LaunchDarkly client was initialized with bootstrap data that did not include flag' +
1025
+ ' metadata. Events may not be sent correctly.');
1026
+ }
1027
+ if (data[validKey] === false) {
1028
+ logger.warn('LaunchDarkly bootstrap data is not available because the back end could not read the flags.');
1029
+ }
1030
+ const ret = {};
1031
+ keys.forEach((key) => {
1032
+ if (key !== metadataKey && key !== validKey) {
1033
+ let flag;
1034
+ if (metadata && metadata[key]) {
1035
+ flag = {
1036
+ value: data[key],
1037
+ ...metadata[key],
1038
+ };
1039
+ }
1040
+ else {
1041
+ flag = {
1042
+ value: data[key],
1043
+ version: 0,
1044
+ };
1045
+ }
1046
+ ret[key] = {
1047
+ version: flag.version,
1048
+ flag,
1049
+ };
1050
+ }
1051
+ });
1052
+ return ret;
1053
+ }
1054
+
1002
1055
  /**
1003
1056
  * Suffix appended to context storage keys to form the freshness storage key.
1004
1057
  */
@@ -1927,6 +1980,10 @@ class LDClientImpl {
1927
1980
  this._eventFactoryWithReasons = new EventFactory(true);
1928
1981
  this._eventSendingEnabled = false;
1929
1982
  this._identifyQueue = createAsyncTaskQueue();
1983
+ // NOTE: this is used to ease the transition to the new initialization pattern.
1984
+ // All client SDKs should set this to true with the exception of React Native which is
1985
+ // still using the deprecated construct + identify pattern.
1986
+ this._requiresStart = false;
1930
1987
  if (!sdkKey) {
1931
1988
  throw new Error('You must configure the client with a client-side SDK key');
1932
1989
  }
@@ -1935,6 +1992,8 @@ class LDClientImpl {
1935
1992
  }
1936
1993
  this._config = new ConfigurationImpl(options, internalOptions);
1937
1994
  this.logger = this._config.logger;
1995
+ this._requiresStart = internalOptions?.requiresStart ?? false;
1996
+ this.initialContext = internalOptions?.initialContext;
1938
1997
  this._baseHeaders = defaultHeaders(this.sdkKey, this.platform.info, this._config.tags, this._config.serviceEndpoints.includeAuthorizationHeader, this._config.userAgentHeaderName);
1939
1998
  this._flagManager = new DefaultFlagManager(this.platform, sdkKey, this._config.maxCachedContexts, this._config.disableCache ?? false, this._config.logger);
1940
1999
  this._diagnosticsManager = createDiagnosticsManager(sdkKey, this._config, platform);
@@ -1952,6 +2011,8 @@ class LDClientImpl {
1952
2011
  });
1953
2012
  });
1954
2013
  this.dataManager = dataManagerFactory(this._flagManager, this._config, this._baseHeaders, this.emitter, this._diagnosticsManager);
2014
+ this.isFDv2 = !!this._config.dataSystem;
2015
+ this.dataSystemConfig = this._config.dataSystem;
1955
2016
  const hooks = [...this._config.hooks];
1956
2017
  this.environmentMetadata = createPluginEnvironmentMetadata(this.sdkKey, this.platform, this._config);
1957
2018
  this._config.getImplementationHooks(this.environmentMetadata).forEach((hook) => {
@@ -2029,6 +2090,57 @@ class LDClientImpl {
2029
2090
  presetFlags(newFlags) {
2030
2091
  this._flagManager.presetFlags(newFlags);
2031
2092
  }
2093
+ /**
2094
+ * Starts the client and returns a promise that resolves to the initialization result.
2095
+ *
2096
+ * This method is idempotent - calling it multiple times returns the same promise.
2097
+ *
2098
+ * @param options Optional configuration. See {@link LDStartOptions}.
2099
+ * @returns A promise that resolves to the initialization result.
2100
+ */
2101
+ start(options) {
2102
+ if (this.initializeResult) {
2103
+ return Promise.resolve(this.initializeResult);
2104
+ }
2105
+ if (this.startPromise) {
2106
+ return this.startPromise;
2107
+ }
2108
+ if (!this.initialContext) {
2109
+ this.logger.error('Initial context not set');
2110
+ return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') });
2111
+ }
2112
+ const identifyOptions = {
2113
+ ...(options?.identifyOptions ?? {}),
2114
+ // Initial identify operations are not sheddable.
2115
+ sheddable: false,
2116
+ };
2117
+ // If the bootstrap data is provided in the start options, and the identify options do not
2118
+ // have bootstrap data, then use the bootstrap data from the start options.
2119
+ if (options?.bootstrap && !identifyOptions.bootstrap) {
2120
+ identifyOptions.bootstrap = options.bootstrap;
2121
+ }
2122
+ if (identifyOptions.bootstrap) {
2123
+ try {
2124
+ if (!identifyOptions.bootstrapParsed) {
2125
+ identifyOptions.bootstrapParsed = readFlagsFromBootstrap(this.logger, identifyOptions.bootstrap);
2126
+ }
2127
+ if (identifyOptions.bootstrapParsed) {
2128
+ this.presetFlags(identifyOptions.bootstrapParsed);
2129
+ }
2130
+ }
2131
+ catch (error) {
2132
+ this.logger.error('Failed to bootstrap data', error);
2133
+ }
2134
+ }
2135
+ if (!this.initializedPromise) {
2136
+ this.initializedPromise = new Promise((resolve) => {
2137
+ this.initResolve = resolve;
2138
+ });
2139
+ }
2140
+ this.startPromise = this._promiseWithTimeout(this.initializedPromise, options?.timeout ?? 5, 'start');
2141
+ this.identifyResult(this.initialContext, identifyOptions);
2142
+ return this.startPromise;
2143
+ }
2032
2144
  /**
2033
2145
  * Identifies a context to LaunchDarkly. See {@link LDClient.identify}.
2034
2146
  *
@@ -2066,6 +2178,10 @@ class LDClientImpl {
2066
2178
  // If completed or shed, then we are done.
2067
2179
  }
2068
2180
  async identifyResult(pristineContext, identifyOptions) {
2181
+ if (this._requiresStart && !this.startPromise) {
2182
+ this.logger.error('The client must be started before a context can be identified. Call start() prior to identifying a context.');
2183
+ return { status: 'error', error: new Error('Identify called before start') };
2184
+ }
2069
2185
  const identifyTimeout = identifyOptions?.timeout ?? DEFAULT_IDENTIFY_TIMEOUT_SECONDS;
2070
2186
  const noTimeout = identifyOptions?.timeout === undefined && identifyOptions?.noTimeout === true;
2071
2187
  // When noTimeout is specified, and a timeout is not specified, then this condition cannot
@@ -2175,7 +2291,7 @@ class LDClientImpl {
2175
2291
  // If waitForInitialization was previously called, then return the promise with a timeout.
2176
2292
  // This condition should only be triggered if waitForInitialization was called multiple times.
2177
2293
  if (this.initializedPromise) {
2178
- return this.promiseWithTimeout(this.initializedPromise, timeout);
2294
+ return this._promiseWithTimeout(this.initializedPromise, timeout);
2179
2295
  }
2180
2296
  // Create a new promise for tracking initialization
2181
2297
  if (!this.initializedPromise) {
@@ -2183,22 +2299,18 @@ class LDClientImpl {
2183
2299
  this.initResolve = resolve;
2184
2300
  });
2185
2301
  }
2186
- return this.promiseWithTimeout(this.initializedPromise, timeout);
2302
+ return this._promiseWithTimeout(this.initializedPromise, timeout);
2187
2303
  }
2188
2304
  /**
2189
- * Apply a timeout promise to a base promise. This is for use with waitForInitialization.
2305
+ * Apply a timeout promise to a base promise. This is for use with waitForInitialization
2306
+ * and start.
2190
2307
  *
2191
2308
  * @param basePromise The promise to race against a timeout.
2192
2309
  * @param timeout The timeout in seconds.
2193
2310
  * @returns A promise that resolves to the initialization result or timeout.
2194
- *
2195
- * @privateRemarks
2196
- * This method is protected because it is used by the browser SDK's `start` method.
2197
- * Eventually, the start method will be moved to this common implementation and this method will
2198
- * be made private.
2199
2311
  */
2200
- promiseWithTimeout(basePromise, timeout) {
2201
- const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization');
2312
+ _promiseWithTimeout(basePromise, timeout, label = 'waitForInitialization') {
2313
+ const cancelableTimeout = cancelableTimedPromise(timeout, label);
2202
2314
  return Promise.race([
2203
2315
  basePromise.then((res) => {
2204
2316
  cancelableTimeout.cancel();
@@ -2428,46 +2540,6 @@ function safeRegisterDebugOverridePlugins(logger, debugOverride, plugins) {
2428
2540
  });
2429
2541
  }
2430
2542
 
2431
- function readFlagsFromBootstrap(logger, data) {
2432
- // If the bootstrap data came from an older server-side SDK, we'll have just a map of keys to values.
2433
- // Newer SDKs that have an allFlagsState method will provide an extra "$flagsState" key that contains
2434
- // the rest of the metadata we want. We do it this way for backward compatibility with older JS SDKs.
2435
- const keys = Object.keys(data);
2436
- const metadataKey = '$flagsState';
2437
- const validKey = '$valid';
2438
- const metadata = data[metadataKey];
2439
- if (!metadata && keys.length) {
2440
- logger.warn('LaunchDarkly client was initialized with bootstrap data that did not include flag' +
2441
- ' metadata. Events may not be sent correctly.');
2442
- }
2443
- if (data[validKey] === false) {
2444
- logger.warn('LaunchDarkly bootstrap data is not available because the back end could not read the flags.');
2445
- }
2446
- const ret = {};
2447
- keys.forEach((key) => {
2448
- if (key !== metadataKey && key !== validKey) {
2449
- let flag;
2450
- if (metadata && metadata[key]) {
2451
- flag = {
2452
- value: data[key],
2453
- ...metadata[key],
2454
- };
2455
- }
2456
- else {
2457
- flag = {
2458
- value: data[key],
2459
- version: 0,
2460
- };
2461
- }
2462
- ret[key] = {
2463
- version: flag.version,
2464
- flag,
2465
- };
2466
- }
2467
- });
2468
- return ret;
2469
- }
2470
-
2471
2543
  /**
2472
2544
  * Creates endpoint paths for browser (client-side ID) FDv1 evaluation.
2473
2545
  *
@@ -3190,12 +3262,12 @@ async function loadFromCache(config) {
3190
3262
  const { storage, crypto, environmentNamespace, context, logger } = config;
3191
3263
  if (!storage) {
3192
3264
  logger?.debug('No storage available for cache initializer');
3193
- return interrupted(errorInfoFromUnknown('No storage available'), false);
3265
+ return changeSet({ version: 0, type: 'none', updates: [] }, false);
3194
3266
  }
3195
3267
  const cached = await loadCachedFlags(storage, crypto, environmentNamespace, context, logger);
3196
3268
  if (!cached) {
3197
3269
  logger?.debug('Cache miss for context');
3198
- return interrupted(errorInfoFromUnknown('Cache miss'), false);
3270
+ return changeSet({ version: 0, type: 'none', updates: [] }, false);
3199
3271
  }
3200
3272
  const updates = Object.entries(cached.flags).map(([key, flag]) => ({
3201
3273
  kind: 'flag-eval',
@@ -3228,21 +3300,24 @@ async function loadFromCache(config) {
3228
3300
  * @internal
3229
3301
  */
3230
3302
  function createCacheInitializerFactory(config) {
3231
- // The selectorGetter is ignored — cache data has no selector.
3232
- return (_selectorGetter) => {
3233
- let shutdownResolve;
3234
- const shutdownPromise = new Promise((resolve) => {
3235
- shutdownResolve = resolve;
3236
- });
3237
- return {
3238
- async run() {
3239
- return Promise.race([shutdownPromise, loadFromCache(config)]);
3240
- },
3241
- close() {
3242
- shutdownResolve?.(shutdown());
3243
- shutdownResolve = undefined;
3244
- },
3245
- };
3303
+ return {
3304
+ isCache: true,
3305
+ // The selectorGetter is ignored -- cache data has no selector.
3306
+ create(_selectorGetter) {
3307
+ let shutdownResolve;
3308
+ const shutdownPromise = new Promise((resolve) => {
3309
+ shutdownResolve = resolve;
3310
+ });
3311
+ return {
3312
+ async run() {
3313
+ return Promise.race([shutdownPromise, loadFromCache(config)]);
3314
+ },
3315
+ close() {
3316
+ shutdownResolve?.(shutdown());
3317
+ shutdownResolve = undefined;
3318
+ },
3319
+ };
3320
+ },
3246
3321
  };
3247
3322
  }
3248
3323
 
@@ -3710,7 +3785,7 @@ function createSourceManager(initializerFactories, synchronizerSlots, selectorGe
3710
3785
  return undefined;
3711
3786
  }
3712
3787
  closeActiveSource();
3713
- const initializer = initializerFactories[initializerIndex](selectorGetter);
3788
+ const initializer = initializerFactories[initializerIndex].create(selectorGetter);
3714
3789
  activeSource = initializer;
3715
3790
  return initializer;
3716
3791
  },
@@ -3730,7 +3805,7 @@ function createSourceManager(initializerFactories, synchronizerSlots, selectorGe
3730
3805
  const candidate = synchronizerSlots[synchronizerIndex];
3731
3806
  if (candidate.state === 'available') {
3732
3807
  closeActiveSource();
3733
- const synchronizer = candidate.factory(selectorGetter);
3808
+ const synchronizer = candidate.factory.create(selectorGetter);
3734
3809
  activeSource = synchronizer;
3735
3810
  return synchronizer;
3736
3811
  }
@@ -4120,10 +4195,14 @@ function createDefaultSourceFactoryProvider() {
4120
4195
  switch (entry.type) {
4121
4196
  case 'polling': {
4122
4197
  const requestor = resolvePollingRequestor(ctx, entry.endpoints);
4123
- return (sg) => createPollingInitializer(requestor, ctx.logger, sg);
4198
+ return {
4199
+ create: (sg) => createPollingInitializer(requestor, ctx.logger, sg),
4200
+ };
4124
4201
  }
4125
4202
  case 'streaming':
4126
- return (sg) => createStreamingInitializer(buildStreamingBase(entry, ctx, sg));
4203
+ return {
4204
+ create: (sg) => createStreamingInitializer(buildStreamingBase(entry, ctx, sg)),
4205
+ };
4127
4206
  case 'cache':
4128
4207
  return createCacheInitializerFactory({
4129
4208
  storage: ctx.storage,
@@ -4141,13 +4220,14 @@ function createDefaultSourceFactoryProvider() {
4141
4220
  case 'polling': {
4142
4221
  const intervalMs = (entry.pollInterval ?? ctx.polling.intervalSeconds) * 1000;
4143
4222
  const requestor = resolvePollingRequestor(ctx, entry.endpoints);
4144
- const factory = (sg) => createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs);
4145
- return createSynchronizerSlot(factory);
4146
- }
4147
- case 'streaming': {
4148
- const factory = (sg) => createStreamingSynchronizer(buildStreamingBase(entry, ctx, sg));
4149
- return createSynchronizerSlot(factory);
4223
+ return createSynchronizerSlot({
4224
+ create: (sg) => createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs),
4225
+ });
4150
4226
  }
4227
+ case 'streaming':
4228
+ return createSynchronizerSlot({
4229
+ create: (sg) => createStreamingSynchronizer(buildStreamingBase(entry, ctx, sg)),
4230
+ });
4151
4231
  default:
4152
4232
  return undefined;
4153
4233
  }
@@ -4418,6 +4498,15 @@ function createFDv2DataSource(config) {
4418
4498
  let dataReceived = false;
4419
4499
  let initResolve;
4420
4500
  let initReject;
4501
+ // When every initializer is a cache initializer and there are no
4502
+ // synchronizers, the cache is the only possible data source. A cache miss
4503
+ // in that configuration must not fail initialization -- there is nowhere
4504
+ // else for data to come from, and reporting an error would be meaningless.
4505
+ // Mirrors the Android SDK's InitializerFromCache / hasAvailableSources
4506
+ // behavior.
4507
+ const cacheOnlyDataSystem = initializerFactories.length > 0 &&
4508
+ initializerFactories.every((f) => f.isCache === true) &&
4509
+ synchronizerSlots.length === 0;
4421
4510
  const sourceManager = createSourceManager(initializerFactories, synchronizerSlots, selectorGetter);
4422
4511
  function markInitialized() {
4423
4512
  if (!initialized) {
@@ -4447,6 +4536,10 @@ function createFDv2DataSource(config) {
4447
4536
  // The orchestration loops intentionally use await-in-loop for sequential
4448
4537
  // state machine processing — one result at a time.
4449
4538
  async function runInitializers() {
4539
+ // Tracks whether any initializer reported interrupted/terminal_error.
4540
+ // Used below so the cache-only exhaustion branch does not overwrite
4541
+ // that error status with VALID.
4542
+ let errorReportedDuringInit = false;
4450
4543
  while (!closed) {
4451
4544
  const initializer = sourceManager.getNextInitializerAndSetActive();
4452
4545
  if (initializer === undefined) {
@@ -4456,16 +4549,16 @@ function createFDv2DataSource(config) {
4456
4549
  if (closed) {
4457
4550
  return;
4458
4551
  }
4459
- if (result.type === 'changeSet') {
4552
+ if (result.type === 'changeSet' && result.payload.type !== 'none') {
4460
4553
  applyChangeSet(result);
4461
4554
  if (handleFdv1Fallback(result)) {
4462
- // FDv1 fallback triggered during initialization data was received
4555
+ // FDv1 fallback triggered during initialization -- data was received
4463
4556
  // but we should move to synchronizers where the FDv1 adapter will run.
4464
4557
  dataReceived = true;
4465
4558
  break;
4466
4559
  }
4467
4560
  if (result.payload.state) {
4468
- // Got basis data with a selector initialization is complete.
4561
+ // Got basis data with a selector -- initialization is complete.
4469
4562
  markInitialized();
4470
4563
  return;
4471
4564
  }
@@ -4479,6 +4572,7 @@ function createFDv2DataSource(config) {
4479
4572
  case 'terminal_error':
4480
4573
  logger?.warn(`Initializer failed: ${result.errorInfo?.message ?? 'unknown error'}`);
4481
4574
  reportStatusError(result);
4575
+ errorReportedDuringInit = true;
4482
4576
  break;
4483
4577
  case 'shutdown':
4484
4578
  return;
@@ -4486,8 +4580,30 @@ function createFDv2DataSource(config) {
4486
4580
  handleFdv1Fallback(result);
4487
4581
  }
4488
4582
  }
4583
+ // close() between the last loop iteration and the exhaustion branch.
4584
+ // Exit without marking initialized or emitting a spurious VALID; the
4585
+ // start() promise will be rejected by the post-orchestration handler
4586
+ // with "closed before initialization completed."
4587
+ if (closed) {
4588
+ return;
4589
+ }
4489
4590
  // All initializers exhausted.
4490
- if (dataReceived) {
4591
+ if (cacheOnlyDataSystem) {
4592
+ // Cache-only data system with no synchronizer to produce a VALID
4593
+ // status on its own. On a cache miss with no errors, nothing else
4594
+ // has asserted VALID yet, so do it here. Skip the update if:
4595
+ // - dataReceived (cache hit): applyChangeSet already asserted VALID.
4596
+ // - errorReportedDuringInit: reportError set an error status that
4597
+ // must not be silently overwritten.
4598
+ if (!dataReceived && !errorReportedDuringInit) {
4599
+ statusManager.requestStateUpdate('VALID');
4600
+ }
4601
+ markInitialized();
4602
+ }
4603
+ else if (dataReceived) {
4604
+ // At least one initializer delivered data. Do not overwrite any
4605
+ // error status that a subsequent failed initializer may have
4606
+ // reported -- the status will be driven by the synchronizers.
4491
4607
  markInitialized();
4492
4608
  }
4493
4609
  }
@@ -4718,6 +4834,13 @@ function createFDv2DataManagerBase(baseConfig) {
4718
4834
  let bootstrapped = false;
4719
4835
  let closed = false;
4720
4836
  let flushCallback;
4837
+ /**
4838
+ * The minimum data availability required before the identify promise
4839
+ * resolves. Maps from the public `waitForNetworkResults` option:
4840
+ * - `'cached'` -- resolve as soon as any data (e.g. from cache) is delivered
4841
+ * - `'fresh'` -- wait for network data with a selector (REFRESHED state)
4842
+ */
4843
+ let minimumDataAvailability = 'fresh';
4721
4844
  // Explicit connection mode override — bypasses transition table entirely.
4722
4845
  let connectionModeOverride;
4723
4846
  // Forced/automatic streaming state for browser listener-driven streaming.
@@ -4820,7 +4943,9 @@ function createFDv2DataManagerBase(baseConfig) {
4820
4943
  ? new ServiceEndpoints(fallbackConfig.endpoints.streamingBaseUri ?? ctx.serviceEndpoints.streaming, fallbackConfig.endpoints.pollingBaseUri ?? ctx.serviceEndpoints.polling, ctx.serviceEndpoints.events, ctx.serviceEndpoints.analyticsEventPath, ctx.serviceEndpoints.diagnosticEventPath, ctx.serviceEndpoints.includeAuthorizationHeader, ctx.serviceEndpoints.payloadFilterKey)
4821
4944
  : ctx.serviceEndpoints;
4822
4945
  const fdv1RequestorFactory = () => makeRequestor(ctx.plainContextString, fallbackServiceEndpoints, fdv1Endpoints.polling(), ctx.requests, ctx.encoding, ctx.baseHeaders, ctx.queryParams, config.withReasons, config.useReport);
4823
- const fdv1SyncFactory = () => createFDv1PollingSynchronizer(fdv1RequestorFactory(), fallbackPollIntervalMs, logger);
4946
+ const fdv1SyncFactory = {
4947
+ create: () => createFDv1PollingSynchronizer(fdv1RequestorFactory(), fallbackPollIntervalMs, logger),
4948
+ };
4824
4949
  synchronizerSlots.push(createSynchronizerSlot(fdv1SyncFactory, { isFDv1Fallback: true }));
4825
4950
  }
4826
4951
  return { initializerFactories, synchronizerSlots };
@@ -4839,11 +4964,21 @@ function createFDv2DataManagerBase(baseConfig) {
4839
4964
  }
4840
4965
  const descriptors = flagEvalPayloadToItemDescriptors(payload.updates ?? []);
4841
4966
  // Flag updates and change events happen synchronously inside applyChanges.
4842
- // The returned promise is only for async cache persistence we intentionally
4967
+ // The returned promise is only for async cache persistence -- we intentionally
4843
4968
  // do not await it so the data source pipeline is not blocked by storage I/O.
4844
4969
  flagManager.applyChanges(context, descriptors, payload.type).catch((e) => {
4845
4970
  logger.warn(`${logTag} Failed to persist flag cache: ${e}`);
4846
4971
  });
4972
+ // When the minimum data availability is 'cached', resolve the identify
4973
+ // promise as soon as any data (e.g. from cache) has been delivered. The
4974
+ // data source continues its initialization pipeline normally --
4975
+ // subsequent changesets update flags and fire change events.
4976
+ if (minimumDataAvailability === 'cached' && !initialized && pendingIdentifyResolve) {
4977
+ initialized = true;
4978
+ pendingIdentifyResolve();
4979
+ pendingIdentifyResolve = undefined;
4980
+ pendingIdentifyReject = undefined;
4981
+ }
4847
4982
  }
4848
4983
  /**
4849
4984
  * Create and start a new FDv2DataSource for the given mode.
@@ -4950,6 +5085,7 @@ function createFDv2DataManagerBase(baseConfig) {
4950
5085
  selector = undefined;
4951
5086
  initialized = false;
4952
5087
  bootstrapped = false;
5088
+ minimumDataAvailability = identifyOptions?.waitForNetworkResults ? 'fresh' : 'cached';
4953
5089
  identifiedContext = context;
4954
5090
  pendingIdentifyResolve = identifyResolve;
4955
5091
  pendingIdentifyReject = identifyReject;