@launchdarkly/js-client-sdk-common 1.21.0 → 1.22.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 (78) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/cjs/LDClientImpl.d.ts.map +1 -1
  3. package/dist/cjs/api/LDOptions.d.ts +8 -0
  4. package/dist/cjs/api/LDOptions.d.ts.map +1 -1
  5. package/dist/cjs/api/datasource/ModeResolution.d.ts +75 -0
  6. package/dist/cjs/api/datasource/ModeResolution.d.ts.map +1 -0
  7. package/dist/cjs/api/datasource/index.d.ts +1 -0
  8. package/dist/cjs/api/datasource/index.d.ts.map +1 -1
  9. package/dist/cjs/configuration/Configuration.d.ts +6 -0
  10. package/dist/cjs/configuration/Configuration.d.ts.map +1 -1
  11. package/dist/cjs/configuration/validateOptions.d.ts +1 -1
  12. package/dist/cjs/configuration/validateOptions.d.ts.map +1 -1
  13. package/dist/cjs/configuration/validators.d.ts +5 -2
  14. package/dist/cjs/configuration/validators.d.ts.map +1 -1
  15. package/dist/cjs/datasource/ModeResolver.d.ts +30 -0
  16. package/dist/cjs/datasource/ModeResolver.d.ts.map +1 -0
  17. package/dist/cjs/datasource/fdv2/CacheInitializer.d.ts +17 -0
  18. package/dist/cjs/datasource/fdv2/CacheInitializer.d.ts.map +1 -0
  19. package/dist/cjs/datasource/fdv2/FDv1PollingSynchronizer.d.ts +2 -0
  20. package/dist/cjs/datasource/fdv2/FDv1PollingSynchronizer.d.ts.map +1 -0
  21. package/dist/cjs/datasource/fdv2/FDv2SourceResult.d.ts +3 -1
  22. package/dist/cjs/datasource/fdv2/FDv2SourceResult.d.ts.map +1 -1
  23. package/dist/cjs/datasource/fdv2/calculatePollDelay.d.ts +2 -0
  24. package/dist/cjs/datasource/fdv2/calculatePollDelay.d.ts.map +1 -0
  25. package/dist/cjs/datasource/fdv2/index.d.ts +4 -0
  26. package/dist/cjs/datasource/fdv2/index.d.ts.map +1 -1
  27. package/dist/cjs/datasource/flagEvalMapper.d.ts +3 -3
  28. package/dist/cjs/flag-manager/FlagManager.d.ts +2 -1
  29. package/dist/cjs/flag-manager/FlagManager.d.ts.map +1 -1
  30. package/dist/cjs/flag-manager/FlagPersistence.d.ts +8 -1
  31. package/dist/cjs/flag-manager/FlagPersistence.d.ts.map +1 -1
  32. package/dist/cjs/index.cjs +336 -60
  33. package/dist/cjs/index.cjs.map +1 -1
  34. package/dist/cjs/index.d.ts +3 -1
  35. package/dist/cjs/index.d.ts.map +1 -1
  36. package/dist/cjs/storage/freshness.d.ts +27 -0
  37. package/dist/cjs/storage/freshness.d.ts.map +1 -0
  38. package/dist/cjs/storage/loadCachedFlags.d.ts +25 -0
  39. package/dist/cjs/storage/loadCachedFlags.d.ts.map +1 -0
  40. package/dist/esm/LDClientImpl.d.ts.map +1 -1
  41. package/dist/esm/api/LDOptions.d.ts +8 -0
  42. package/dist/esm/api/LDOptions.d.ts.map +1 -1
  43. package/dist/esm/api/datasource/ModeResolution.d.ts +75 -0
  44. package/dist/esm/api/datasource/ModeResolution.d.ts.map +1 -0
  45. package/dist/esm/api/datasource/index.d.ts +1 -0
  46. package/dist/esm/api/datasource/index.d.ts.map +1 -1
  47. package/dist/esm/configuration/Configuration.d.ts +6 -0
  48. package/dist/esm/configuration/Configuration.d.ts.map +1 -1
  49. package/dist/esm/configuration/validateOptions.d.ts +1 -1
  50. package/dist/esm/configuration/validateOptions.d.ts.map +1 -1
  51. package/dist/esm/configuration/validators.d.ts +5 -2
  52. package/dist/esm/configuration/validators.d.ts.map +1 -1
  53. package/dist/esm/datasource/ModeResolver.d.ts +30 -0
  54. package/dist/esm/datasource/ModeResolver.d.ts.map +1 -0
  55. package/dist/esm/datasource/fdv2/CacheInitializer.d.ts +17 -0
  56. package/dist/esm/datasource/fdv2/CacheInitializer.d.ts.map +1 -0
  57. package/dist/esm/datasource/fdv2/FDv1PollingSynchronizer.d.ts +2 -0
  58. package/dist/esm/datasource/fdv2/FDv1PollingSynchronizer.d.ts.map +1 -0
  59. package/dist/esm/datasource/fdv2/FDv2SourceResult.d.ts +3 -1
  60. package/dist/esm/datasource/fdv2/FDv2SourceResult.d.ts.map +1 -1
  61. package/dist/esm/datasource/fdv2/calculatePollDelay.d.ts +2 -0
  62. package/dist/esm/datasource/fdv2/calculatePollDelay.d.ts.map +1 -0
  63. package/dist/esm/datasource/fdv2/index.d.ts +4 -0
  64. package/dist/esm/datasource/fdv2/index.d.ts.map +1 -1
  65. package/dist/esm/datasource/flagEvalMapper.d.ts +3 -3
  66. package/dist/esm/flag-manager/FlagManager.d.ts +2 -1
  67. package/dist/esm/flag-manager/FlagManager.d.ts.map +1 -1
  68. package/dist/esm/flag-manager/FlagPersistence.d.ts +8 -1
  69. package/dist/esm/flag-manager/FlagPersistence.d.ts.map +1 -1
  70. package/dist/esm/index.d.ts +3 -1
  71. package/dist/esm/index.d.ts.map +1 -1
  72. package/dist/esm/index.mjs +329 -61
  73. package/dist/esm/index.mjs.map +1 -1
  74. package/dist/esm/storage/freshness.d.ts +27 -0
  75. package/dist/esm/storage/freshness.d.ts.map +1 -0
  76. package/dist/esm/storage/loadCachedFlags.d.ts +25 -0
  77. package/dist/esm/storage/loadCachedFlags.d.ts.map +1 -0
  78. package/package.json +1 -1
@@ -289,35 +289,141 @@ function validateOptions(input, validatorMap, defaults, logger, prefix) {
289
289
  });
290
290
  return result;
291
291
  }
292
+ /**
293
+ * Creates a validator for nested objects. When used in a validator map,
294
+ * `validateOptions` will recursively validate the nested object's properties.
295
+ * Defaults for nested fields are passed through from the parent.
296
+ */
297
+ function validatorOf(validators, builtInDefaults) {
298
+ return {
299
+ is: (u) => jsSdkCommon.TypeValidators.Object.is(u),
300
+ getType: () => 'object',
301
+ validate(value, name, logger, defaults) {
302
+ if (!jsSdkCommon.TypeValidators.Object.is(value)) {
303
+ logger?.warn(jsSdkCommon.OptionMessages.wrongOptionType(name, 'object', typeof value));
304
+ return undefined;
305
+ }
306
+ const nestedDefaults = builtInDefaults ??
307
+ (jsSdkCommon.TypeValidators.Object.is(defaults) ? defaults : {});
308
+ const nested = validateOptions(value, validators, nestedDefaults, logger, name);
309
+ return Object.keys(nested).length > 0 ? { value: nested } : undefined;
310
+ },
311
+ };
312
+ }
313
+ /**
314
+ * Creates a validator that tries each provided validator in order and uses the
315
+ * first one whose `is()` check passes. For compound validators the value is
316
+ * processed through `validate()`; for simple validators the value is accepted
317
+ * as-is. If no validator matches, a warning is logged and the default is
318
+ * preserved.
319
+ *
320
+ * @example
321
+ * ```ts
322
+ * // Accepts either a boolean or a nested object with specific fields:
323
+ * anyOf(TypeValidators.Boolean, validatorOf({ lifecycle: TypeValidators.Boolean }))
324
+ * ```
325
+ */
326
+ function anyOf(...validators) {
327
+ return {
328
+ is: (u) => validators.some((v) => v.is(u)),
329
+ getType: () => validators.map((v) => v.getType()).join(' | '),
330
+ validate(value, name, logger, defaults) {
331
+ const match = validators.find((v) => v.is(value));
332
+ if (match) {
333
+ return isCompoundValidator(match)
334
+ ? match.validate(value, name, logger, defaults)
335
+ : { value };
336
+ }
337
+ logger?.warn(jsSdkCommon.OptionMessages.wrongOptionType(name, this.getType(), typeof value));
338
+ return undefined;
339
+ },
340
+ };
341
+ }
292
342
 
293
- // eslint-disable-next-line max-classes-per-file
294
- const validators = {
295
- logger: jsSdkCommon.TypeValidators.Object,
296
- maxCachedContexts: jsSdkCommon.TypeValidators.numberWithMin(0),
297
- baseUri: jsSdkCommon.TypeValidators.String,
298
- streamUri: jsSdkCommon.TypeValidators.String,
299
- eventsUri: jsSdkCommon.TypeValidators.String,
300
- capacity: jsSdkCommon.TypeValidators.numberWithMin(1),
301
- diagnosticRecordingInterval: jsSdkCommon.TypeValidators.numberWithMin(2),
302
- flushInterval: jsSdkCommon.TypeValidators.numberWithMin(2),
303
- streamInitialReconnectDelay: jsSdkCommon.TypeValidators.numberWithMin(0),
304
- allAttributesPrivate: jsSdkCommon.TypeValidators.Boolean,
305
- debug: jsSdkCommon.TypeValidators.Boolean,
306
- diagnosticOptOut: jsSdkCommon.TypeValidators.Boolean,
307
- withReasons: jsSdkCommon.TypeValidators.Boolean,
308
- sendEvents: jsSdkCommon.TypeValidators.Boolean,
343
+ const dataSourceTypeValidator = jsSdkCommon.TypeValidators.oneOf('cache', 'polling', 'streaming');
344
+ const connectionModeValidator = jsSdkCommon.TypeValidators.oneOf('streaming', 'polling', 'offline', 'one-shot', 'background');
345
+ const endpointValidators = {
346
+ pollingBaseUri: jsSdkCommon.TypeValidators.String,
347
+ streamingBaseUri: jsSdkCommon.TypeValidators.String,
348
+ };
349
+ ({
350
+ type: dataSourceTypeValidator,
309
351
  pollInterval: jsSdkCommon.TypeValidators.numberWithMin(30),
310
- useReport: jsSdkCommon.TypeValidators.Boolean,
311
- privateAttributes: jsSdkCommon.TypeValidators.StringArray,
312
- applicationInfo: jsSdkCommon.TypeValidators.Object,
313
- wrapperName: jsSdkCommon.TypeValidators.String,
314
- wrapperVersion: jsSdkCommon.TypeValidators.String,
315
- payloadFilterKey: jsSdkCommon.TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/),
316
- hooks: jsSdkCommon.TypeValidators.createTypeArray('Hook[]', {}),
317
- inspectors: jsSdkCommon.TypeValidators.createTypeArray('LDInspection', {}),
318
- cleanOldPersistentData: jsSdkCommon.TypeValidators.Boolean,
352
+ endpoints: validatorOf(endpointValidators),
353
+ });
354
+ ({
355
+ type: dataSourceTypeValidator,
356
+ initialReconnectDelay: jsSdkCommon.TypeValidators.numberWithMin(1),
357
+ endpoints: validatorOf(endpointValidators),
358
+ });
359
+
360
+ const modeSwitchingValidators = {
361
+ lifecycle: jsSdkCommon.TypeValidators.Boolean,
362
+ network: jsSdkCommon.TypeValidators.Boolean,
363
+ };
364
+ const dataSystemValidators = {
365
+ initialConnectionMode: connectionModeValidator,
366
+ backgroundConnectionMode: connectionModeValidator,
367
+ automaticModeSwitching: anyOf(jsSdkCommon.TypeValidators.Boolean, validatorOf(modeSwitchingValidators)),
368
+ };
369
+ /**
370
+ * Default FDv2 data system configuration for browser SDKs.
371
+ */
372
+ const BROWSER_DATA_SYSTEM_DEFAULTS = {
373
+ initialConnectionMode: 'one-shot',
374
+ backgroundConnectionMode: undefined,
375
+ automaticModeSwitching: false,
376
+ };
377
+ /**
378
+ * Default FDv2 data system configuration for mobile (React Native) SDKs.
379
+ */
380
+ const MOBILE_DATA_SYSTEM_DEFAULTS = {
381
+ initialConnectionMode: 'streaming',
382
+ backgroundConnectionMode: 'background',
383
+ automaticModeSwitching: true,
384
+ };
385
+ /**
386
+ * Default FDv2 data system configuration for desktop SDKs (Electron, etc.).
387
+ */
388
+ const DESKTOP_DATA_SYSTEM_DEFAULTS = {
389
+ initialConnectionMode: 'streaming',
390
+ backgroundConnectionMode: undefined,
391
+ automaticModeSwitching: false,
319
392
  };
320
393
 
394
+ function createValidators(options) {
395
+ return {
396
+ logger: jsSdkCommon.TypeValidators.Object,
397
+ maxCachedContexts: jsSdkCommon.TypeValidators.numberWithMin(0),
398
+ baseUri: jsSdkCommon.TypeValidators.String,
399
+ streamUri: jsSdkCommon.TypeValidators.String,
400
+ eventsUri: jsSdkCommon.TypeValidators.String,
401
+ capacity: jsSdkCommon.TypeValidators.numberWithMin(1),
402
+ diagnosticRecordingInterval: jsSdkCommon.TypeValidators.numberWithMin(2),
403
+ flushInterval: jsSdkCommon.TypeValidators.numberWithMin(2),
404
+ streamInitialReconnectDelay: jsSdkCommon.TypeValidators.numberWithMin(0),
405
+ allAttributesPrivate: jsSdkCommon.TypeValidators.Boolean,
406
+ debug: jsSdkCommon.TypeValidators.Boolean,
407
+ diagnosticOptOut: jsSdkCommon.TypeValidators.Boolean,
408
+ withReasons: jsSdkCommon.TypeValidators.Boolean,
409
+ sendEvents: jsSdkCommon.TypeValidators.Boolean,
410
+ pollInterval: jsSdkCommon.TypeValidators.numberWithMin(30),
411
+ useReport: jsSdkCommon.TypeValidators.Boolean,
412
+ privateAttributes: jsSdkCommon.TypeValidators.StringArray,
413
+ disableCache: jsSdkCommon.TypeValidators.Boolean,
414
+ applicationInfo: jsSdkCommon.TypeValidators.Object,
415
+ wrapperName: jsSdkCommon.TypeValidators.String,
416
+ wrapperVersion: jsSdkCommon.TypeValidators.String,
417
+ payloadFilterKey: jsSdkCommon.TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/),
418
+ hooks: jsSdkCommon.TypeValidators.createTypeArray('Hook[]', {}),
419
+ inspectors: jsSdkCommon.TypeValidators.createTypeArray('LDInspection', {}),
420
+ cleanOldPersistentData: jsSdkCommon.TypeValidators.Boolean,
421
+ dataSystem: options?.dataSystemDefaults
422
+ ? validatorOf(dataSystemValidators, options.dataSystemDefaults)
423
+ : jsSdkCommon.TypeValidators.Object,
424
+ };
425
+ }
426
+
321
427
  const DEFAULT_POLLING_INTERVAL = 60 * 5;
322
428
  const DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com';
323
429
  const DEFAULT_STREAM = 'https://clientstream.launchdarkly.com';
@@ -343,6 +449,7 @@ class ConfigurationImpl {
343
449
  // eslint-disable-next-line @typescript-eslint/naming-convention
344
450
  this.streamUri = DEFAULT_STREAM;
345
451
  this.maxCachedContexts = 5;
452
+ this.disableCache = false;
346
453
  this.capacity = 100;
347
454
  this.diagnosticRecordingInterval = 900;
348
455
  this.flushInterval = 30;
@@ -359,6 +466,9 @@ class ConfigurationImpl {
359
466
  this.hooks = [];
360
467
  this.inspectors = [];
361
468
  this.logger = ensureSafeLogger(pristineOptions.logger);
469
+ const validators = createValidators({
470
+ dataSystemDefaults: internalOptions.dataSystemDefaults,
471
+ });
362
472
  const validated = validateOptions(pristineOptions, validators, {}, this.logger);
363
473
  Object.entries(validated).forEach(([k, v]) => {
364
474
  if (k !== 'logger') {
@@ -711,6 +821,71 @@ class EventFactory extends jsSdkCommon.internal.EventFactoryBase {
711
821
  }
712
822
  }
713
823
 
824
+ /**
825
+ * Suffix appended to context storage keys to form the freshness storage key.
826
+ */
827
+ const FRESHNESS_SUFFIX = '_freshness';
828
+ /**
829
+ * Computes a SHA-256 hash of the context's full canonical JSON.
830
+ * Returns `undefined` if the context cannot be serialized.
831
+ */
832
+ async function hashContext(crypto, context) {
833
+ const json = context.canonicalUnfilteredJson();
834
+ if (!json) {
835
+ return undefined;
836
+ }
837
+ return digest(crypto.createHash('sha256').update(json), 'base64');
838
+ }
839
+
840
+ function isValidFlag(value) {
841
+ return value !== null && typeof value === 'object' && typeof value.version === 'number';
842
+ }
843
+ /**
844
+ * Loads cached flag data from storage for the given context.
845
+ *
846
+ * Checks the current storage key first, then falls back to the legacy
847
+ * canonical key location (pre-10.3.1). Does NOT perform migration — the
848
+ * caller is responsible for migrating data if {@link CachedFlagData.fromLegacyKey}
849
+ * is true.
850
+ *
851
+ * @returns The cached flag data, or `undefined` on cache miss or parse error.
852
+ */
853
+ async function loadCachedFlags(storage, crypto, environmentNamespace, context, logger) {
854
+ const storageKey = await namespaceForContextData(crypto, environmentNamespace, context);
855
+ let flagsJson = await storage.get(storageKey);
856
+ let fromLegacyKey = false;
857
+ if (flagsJson === null || flagsJson === undefined) {
858
+ // Fallback: in version <10.3.1 flag data was stored under the canonical key.
859
+ flagsJson = await storage.get(context.canonicalKey);
860
+ if (flagsJson === null || flagsJson === undefined) {
861
+ return undefined;
862
+ }
863
+ fromLegacyKey = true;
864
+ }
865
+ try {
866
+ const parsed = JSON.parse(flagsJson);
867
+ if (parsed === null || typeof parsed !== 'object') {
868
+ logger?.warn('Cached flag data is not a valid object');
869
+ return undefined;
870
+ }
871
+ const entries = Object.entries(parsed);
872
+ const invalidKey = entries.find(([, value]) => !isValidFlag(value));
873
+ if (invalidKey) {
874
+ logger?.warn(`Discarding cached flags due to invalid entry: ${invalidKey[0]}`);
875
+ return undefined;
876
+ }
877
+ const flags = entries.reduce((acc, [key, value]) => {
878
+ acc[key] = value;
879
+ return acc;
880
+ }, {});
881
+ return { flags, storageKey, fromLegacyKey };
882
+ }
883
+ catch (e) {
884
+ logger?.warn(`Could not parse cached flag evaluations from persistent storage: ${e.message}`);
885
+ return undefined;
886
+ }
887
+ }
888
+
714
889
  /**
715
890
  * An index for tracking the most recently used contexts by timestamp with the ability to
716
891
  * update entry timestamps and prune out least used contexts above a max capacity provided.
@@ -776,12 +951,18 @@ class ContextIndex {
776
951
  * This class handles persisting and loading flag values from a persistent
777
952
  * store. It intercepts updates and forwards them to the flag updater and
778
953
  * then persists changes after the updater has completed.
954
+ *
955
+ * Freshness metadata (timestamp + context attribute hash) is stored in a
956
+ * separate storage key (`{contextKey}_freshness`) alongside the flag data.
957
+ * Both keys are managed together — when a context is evicted, both the flag
958
+ * data and freshness record are cleared.
779
959
  */
780
960
  class FlagPersistence {
781
- constructor(_platform, _environmentNamespace, _maxCachedContexts, _flagStore, _flagUpdater, _logger, _timeStamper = () => Date.now()) {
961
+ constructor(_platform, _environmentNamespace, _maxCachedContexts, _disableCache, _flagStore, _flagUpdater, _logger, _timeStamper = () => Date.now()) {
782
962
  this._platform = _platform;
783
963
  this._environmentNamespace = _environmentNamespace;
784
964
  this._maxCachedContexts = _maxCachedContexts;
965
+ this._disableCache = _disableCache;
785
966
  this._flagStore = _flagStore;
786
967
  this._flagUpdater = _flagUpdater;
787
968
  this._logger = _logger;
@@ -813,35 +994,38 @@ class FlagPersistence {
813
994
  * {@link FlagUpdater} this {@link FlagPersistence} was constructed with.
814
995
  */
815
996
  async loadCached(context) {
816
- const storageKey = await namespaceForContextData(this._platform.crypto, this._environmentNamespace, context);
817
- let flagsJson = await this._platform.storage?.get(storageKey);
818
- if (flagsJson === null || flagsJson === undefined) {
819
- // Fallback: in version <10.3.1 flag data was stored under the canonical key, check
820
- // to see if data is present and migrate the data if present.
821
- flagsJson = await this._platform.storage?.get(context.canonicalKey);
822
- if (flagsJson === null || flagsJson === undefined) {
823
- // return false indicating cache did not load if flag json is still absent
824
- return false;
825
- }
826
- // migrate data from version <10.3.1 and cleanup data that was under canonical key
827
- await this._platform.storage?.set(storageKey, flagsJson);
828
- await this._platform.storage?.clear(context.canonicalKey);
997
+ if (this._disableCache || this._maxCachedContexts <= 0) {
998
+ return false;
829
999
  }
830
- try {
831
- const flags = JSON.parse(flagsJson);
832
- // mapping flags to item descriptors
833
- const descriptors = Object.entries(flags).reduce((acc, [key, flag]) => {
834
- acc[key] = { version: flag.version, flag };
835
- return acc;
836
- }, {});
837
- this._flagUpdater.initCached(context, descriptors);
838
- this._logger.debug('Loaded cached flag evaluations from persistent storage');
839
- return true;
1000
+ if (!this._platform.storage) {
1001
+ return false;
840
1002
  }
841
- catch (e) {
842
- this._logger.warn(`Could not load cached flag evaluations from persistent storage: ${e.message}`);
1003
+ const cached = await loadCachedFlags(this._platform.storage, this._platform.crypto, this._environmentNamespace, context, this._logger);
1004
+ if (!cached) {
843
1005
  return false;
844
1006
  }
1007
+ // Migrate data from version <10.3.1 stored under the canonical key
1008
+ if (cached.fromLegacyKey) {
1009
+ await this._platform.storage.set(cached.storageKey, JSON.stringify(cached.flags));
1010
+ await this._platform.storage.clear(context.canonicalKey);
1011
+ }
1012
+ // mapping flags to item descriptors
1013
+ const descriptors = Object.entries(cached.flags).reduce((acc, [key, flag]) => {
1014
+ acc[key] = { version: flag.version, flag };
1015
+ return acc;
1016
+ }, {});
1017
+ this._flagUpdater.initCached(context, descriptors);
1018
+ this._logger.debug('Loaded cached flag evaluations from persistent storage');
1019
+ return true;
1020
+ }
1021
+ async _storeFreshness(contextStorageKey, context, timestamp) {
1022
+ const contextHash = await hashContext(this._platform.crypto, context);
1023
+ if (contextHash === undefined) {
1024
+ this._logger.error('Could not serialize context for freshness tracking');
1025
+ return;
1026
+ }
1027
+ const record = { timestamp, contextHash };
1028
+ await this._platform.storage?.set(`${contextStorageKey}${FRESHNESS_SUFFIX}`, JSON.stringify(record));
845
1029
  }
846
1030
  async _loadIndex() {
847
1031
  if (this._contextIndex !== undefined) {
@@ -863,13 +1047,26 @@ class FlagPersistence {
863
1047
  return this._contextIndex;
864
1048
  }
865
1049
  async _storeCache(context) {
1050
+ if (this._disableCache) {
1051
+ return;
1052
+ }
1053
+ const now = this._timeStamper();
866
1054
  const index = await this._loadIndex();
867
1055
  const storageKey = await namespaceForContextData(this._platform.crypto, this._environmentNamespace, context);
868
- index.notice(storageKey, this._timeStamper());
1056
+ if (this._maxCachedContexts > 0) {
1057
+ index.notice(storageKey, now);
1058
+ }
869
1059
  const pruned = index.prune(this._maxCachedContexts);
870
- await Promise.all(pruned.map(async (it) => this._platform.storage?.clear(it.id)));
871
- // store index
1060
+ // If maxCachedContexts <= 0, current context was never added, so always skip flag write
1061
+ const currentContextWasPruned = this._maxCachedContexts <= 0 || pruned.some((it) => it.id === storageKey);
1062
+ await Promise.all(pruned.flatMap((it) => [
1063
+ this._platform.storage?.clear(it.id),
1064
+ this._platform.storage?.clear(`${it.id}${FRESHNESS_SUFFIX}`),
1065
+ ]));
872
1066
  await this._platform.storage?.set(await this._indexKeyPromise, index.toJson());
1067
+ if (currentContextWasPruned) {
1068
+ return;
1069
+ }
873
1070
  const allFlags = this._flagStore.getAll();
874
1071
  // mapping item descriptors to flags
875
1072
  const flags = Object.entries(allFlags).reduce((acc, [key, descriptor]) => {
@@ -879,8 +1076,15 @@ class FlagPersistence {
879
1076
  return acc;
880
1077
  }, {});
881
1078
  const jsonAll = JSON.stringify(flags);
882
- // store flag data
1079
+ // store flag data first, so freshness is never newer than the flags it describes
883
1080
  await this._platform.storage?.set(storageKey, jsonAll);
1081
+ // store freshness — best-effort, must not block flag persistence
1082
+ try {
1083
+ await this._storeFreshness(storageKey, context, now);
1084
+ }
1085
+ catch (e) {
1086
+ this._logger.warn(`Failed to store freshness data: ${e.message}`);
1087
+ }
884
1088
  }
885
1089
  }
886
1090
 
@@ -996,17 +1200,18 @@ class DefaultFlagManager {
996
1200
  * @param platform implementation of various platform provided functionality
997
1201
  * @param sdkKey that will be used to distinguish different environments
998
1202
  * @param maxCachedContexts that specifies the max number of contexts that will be cached in persistence
1203
+ * @param disableCache set to true to completely disable the persistent flag cache
999
1204
  * @param logger used for logging various messages
1000
1205
  * @param timeStamper exists for testing purposes
1001
1206
  */
1002
- constructor(platform, sdkKey, maxCachedContexts, logger, timeStamper = () => Date.now()) {
1207
+ constructor(platform, sdkKey, maxCachedContexts, disableCache, logger, timeStamper = () => Date.now()) {
1003
1208
  this._flagStore = createDefaultFlagStore();
1004
1209
  this._flagUpdater = createFlagUpdater(this._flagStore, logger);
1005
- this._flagPersistencePromise = this._initPersistence(platform, sdkKey, maxCachedContexts, logger, timeStamper);
1210
+ this._flagPersistencePromise = this._initPersistence(platform, sdkKey, maxCachedContexts, disableCache, logger, timeStamper);
1006
1211
  }
1007
- async _initPersistence(platform, sdkKey, maxCachedContexts, logger, timeStamper = () => Date.now()) {
1212
+ async _initPersistence(platform, sdkKey, maxCachedContexts, disableCache, logger, timeStamper = () => Date.now()) {
1008
1213
  const environmentNamespace = await namespaceForEnvironment(platform.crypto, sdkKey);
1009
- return new FlagPersistence(platform, environmentNamespace, maxCachedContexts, this._flagStore, this._flagUpdater, logger, timeStamper);
1214
+ return new FlagPersistence(platform, environmentNamespace, maxCachedContexts, disableCache, this._flagStore, this._flagUpdater, logger, timeStamper);
1010
1215
  }
1011
1216
  get(key) {
1012
1217
  if (this._overrides && Object.prototype.hasOwnProperty.call(this._overrides, key)) {
@@ -1482,7 +1687,7 @@ class LDClientImpl {
1482
1687
  this._config = new ConfigurationImpl(options, internalOptions);
1483
1688
  this.logger = this._config.logger;
1484
1689
  this._baseHeaders = jsSdkCommon.defaultHeaders(this.sdkKey, this.platform.info, this._config.tags, this._config.serviceEndpoints.includeAuthorizationHeader, this._config.userAgentHeaderName);
1485
- this._flagManager = new DefaultFlagManager(this.platform, sdkKey, this._config.maxCachedContexts, this._config.logger);
1690
+ this._flagManager = new DefaultFlagManager(this.platform, sdkKey, this._config.maxCachedContexts, this._config.disableCache ?? false, this._config.logger);
1486
1691
  this._diagnosticsManager = createDiagnosticsManager(sdkKey, this._config, platform);
1487
1692
  this._eventProcessor = createEventProcessor(sdkKey, this._config, platform, this._baseHeaders, this._diagnosticsManager);
1488
1693
  this.emitter = new LDEmitter();
@@ -2576,15 +2781,86 @@ class BaseDataManager {
2576
2781
  }
2577
2782
  }
2578
2783
 
2784
+ function allConditionsMatch(conditions, input) {
2785
+ return Object.entries(conditions).every(([key, value]) => value === undefined || input[key] === value);
2786
+ }
2787
+ /**
2788
+ * Given a mode resolution table and the current input state, returns the
2789
+ * resolved FDv2 connection mode.
2790
+ *
2791
+ * Iterates entries in order. The first entry whose conditions all match the
2792
+ * input wins. If no entry matches (should not happen when the table ends with
2793
+ * a catch-all), falls back to `input.foregroundMode`.
2794
+ */
2795
+ const CONFIGURED_MODE_MAP = {
2796
+ foreground: 'foregroundMode',
2797
+ background: 'backgroundMode',
2798
+ };
2799
+ function resolveConnectionMode(table, input) {
2800
+ const match = table.find((entry) => allConditionsMatch(entry.conditions, input));
2801
+ if (match) {
2802
+ const { mode } = match;
2803
+ if (typeof mode === 'object') {
2804
+ return input[CONFIGURED_MODE_MAP[mode.configured]];
2805
+ }
2806
+ return mode;
2807
+ }
2808
+ return input.foregroundMode;
2809
+ }
2810
+ /**
2811
+ * Mode resolution table for mobile platforms (React Native, etc.).
2812
+ *
2813
+ * - No network → offline.
2814
+ * - Background → configured background mode.
2815
+ * - Foreground → configured foreground mode.
2816
+ */
2817
+ const MOBILE_TRANSITION_TABLE = [
2818
+ { conditions: { networkAvailable: false }, mode: 'offline' },
2819
+ { conditions: { lifecycle: 'background' }, mode: { configured: 'background' } },
2820
+ { conditions: { lifecycle: 'foreground' }, mode: { configured: 'foreground' } },
2821
+ ];
2822
+ /**
2823
+ * Mode resolution table for browser platforms.
2824
+ *
2825
+ * - No network → offline.
2826
+ * - Otherwise → configured foreground mode.
2827
+ *
2828
+ * Browser listener-driven streaming (auto-promotion to streaming when change
2829
+ * listeners are registered) is handled externally by the caller modifying
2830
+ * `foregroundMode` before consulting this table.
2831
+ */
2832
+ const BROWSER_TRANSITION_TABLE = [
2833
+ { conditions: { networkAvailable: false }, mode: 'offline' },
2834
+ { conditions: {}, mode: { configured: 'foreground' } },
2835
+ ];
2836
+ /**
2837
+ * Mode resolution table for desktop platforms (Electron, etc.).
2838
+ *
2839
+ * - No network → offline.
2840
+ * - Otherwise → configured foreground mode.
2841
+ */
2842
+ const DESKTOP_TRANSITION_TABLE = [
2843
+ { conditions: { networkAvailable: false }, mode: 'offline' },
2844
+ { conditions: {}, mode: { configured: 'foreground' } },
2845
+ ];
2846
+
2579
2847
  exports.platform = jsSdkCommon__namespace;
2848
+ exports.BROWSER_DATA_SYSTEM_DEFAULTS = BROWSER_DATA_SYSTEM_DEFAULTS;
2849
+ exports.BROWSER_TRANSITION_TABLE = BROWSER_TRANSITION_TABLE;
2580
2850
  exports.BaseDataManager = BaseDataManager;
2851
+ exports.DESKTOP_DATA_SYSTEM_DEFAULTS = DESKTOP_DATA_SYSTEM_DEFAULTS;
2852
+ exports.DESKTOP_TRANSITION_TABLE = DESKTOP_TRANSITION_TABLE;
2581
2853
  exports.DataSourceState = DataSourceState;
2582
2854
  exports.LDClientImpl = LDClientImpl;
2855
+ exports.MOBILE_DATA_SYSTEM_DEFAULTS = MOBILE_DATA_SYSTEM_DEFAULTS;
2856
+ exports.MOBILE_TRANSITION_TABLE = MOBILE_TRANSITION_TABLE;
2583
2857
  exports.browserFdv1Endpoints = browserFdv1Endpoints;
2858
+ exports.dataSystemValidators = dataSystemValidators;
2584
2859
  exports.fdv2Endpoints = fdv2Endpoints;
2585
2860
  exports.makeRequestor = makeRequestor;
2586
2861
  exports.mobileFdv1Endpoints = mobileFdv1Endpoints;
2587
2862
  exports.readFlagsFromBootstrap = readFlagsFromBootstrap;
2863
+ exports.resolveConnectionMode = resolveConnectionMode;
2588
2864
  exports.safeRegisterDebugOverridePlugins = safeRegisterDebugOverridePlugins;
2589
2865
  exports.validateOptions = validateOptions;
2590
2866
  Object.keys(jsSdkCommon).forEach(function (k) {