From 62254621f920d2dd4e26e0459979d4b84b013cb0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:52:58 -0700 Subject: [PATCH 1/5] feat: Add the FDv2 data system and expose it through configuration --- .../lib/launchdarkly_common_client.dart | 15 ++ .../lib/src/config/data_system_config.dart | 74 +++++++++ .../src/data_sources/data_source_manager.dart | 35 +++-- .../src/data_sources/fdv2/data_system.dart | 140 ++++++++++++++++++ .../lib/src/ld_common_client.dart | 25 +++- .../lib/src/ld_common_config.dart | 22 ++- .../data_source_manager_test.dart | 47 +++++- .../data_sources/fdv2/data_system_test.dart | 85 +++++++++++ 8 files changed, 418 insertions(+), 25 deletions(-) create mode 100644 packages/common_client/lib/src/config/data_system_config.dart create mode 100644 packages/common_client/lib/src/data_sources/fdv2/data_system.dart create mode 100644 packages/common_client/test/data_sources/fdv2/data_system_test.dart diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index 475948d7..50a01e3e 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -6,6 +6,21 @@ export 'src/ld_common_config.dart' AutoEnvAttributes, PollingConfig; +export 'src/config/data_system_config.dart' + show DataSystemConfig, ConnectionModeId; +export 'src/data_sources/fdv2/mode_definition.dart' + show + ModeDefinition, + EndpointConfig, + InitializerEntry, + SynchronizerEntry, + CacheInitializer, + PollingInitializer, + StreamingInitializer, + PollingSynchronizer, + StreamingSynchronizer, + Fdv1FallbackConfig; + export 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' show LDContext, diff --git a/packages/common_client/lib/src/config/data_system_config.dart b/packages/common_client/lib/src/config/data_system_config.dart new file mode 100644 index 00000000..334362e1 --- /dev/null +++ b/packages/common_client/lib/src/config/data_system_config.dart @@ -0,0 +1,74 @@ +import '../data_sources/fdv2/mode_definition.dart'; + +// Maintainer note (not public API): ConnectionModeId is a sealed +// hierarchy rather than an enum so a custom-mode variant can be added +// later without changing this surface. The planned extension is a custom +// variant constructed as `ConnectionModeId.custom('my-mode')`: +// +// factory ConnectionModeId.custom(String name) = _CustomConnectionMode; +// final class _CustomConnectionMode extends ConnectionModeId { +// final String name; +// const _CustomConnectionMode(this.name); +// // value equality on name so it works as an override-map key +// } +// +// A custom mode is a distinct type from a built-in, so the two share no +// namespace: a custom id never equals a built-in id (even with the same +// name), and so cannot collide with a current or future built-in. The +// type is the namespace -- no name prefix is needed. This holds only +// while custom modes stay typed; if one is ever reduced to a bare string +// (logs, persistence) that reintroduces a shared string space where a +// prefix would matter again. +// +// Equality split: the built-in values are const singletons relying on +// canonical-instance identity, which lets a connectionModes map of only +// built-in keys be a const map. A runtime-constructed custom variant must +// carry value equality, so an override map holding a custom key would be +// non-const. The built-in variant therefore must not override +// `==`/`hashCode`. + +/// Identifies a built-in connection mode whose data-source pipeline can be +/// overridden through [DataSystemConfig.connectionModes]: [streaming], +/// [polling], or [background]. +sealed class ConnectionModeId { + const ConnectionModeId(); + + /// The built-in streaming mode. + static const ConnectionModeId streaming = _BuiltInConnectionMode('streaming'); + + /// The built-in polling mode. + static const ConnectionModeId polling = _BuiltInConnectionMode('polling'); + + /// The built-in background mode. + static const ConnectionModeId background = + _BuiltInConnectionMode('background'); +} + +final class _BuiltInConnectionMode extends ConnectionModeId { + final String name; + + const _BuiltInConnectionMode(this.name); + + @override + String toString() => 'ConnectionModeId.$name'; +} + +/// Configuration for the FDv2 data system. +/// +/// Providing a [DataSystemConfig] (even an empty one) opts the SDK into +/// the FDv2 data acquisition protocol. When absent the SDK uses the +/// FDv1 data sources. +/// +/// This feature is not stable, and not subject to any backwards +/// compatibility guarantees or semantic versioning. It is in early +/// access. If you want access to this feature please join the EAP. +final class DataSystemConfig { + /// Overrides for built-in connection modes. A definition given here + /// replaces the built-in pipeline for that mode; modes not present keep + /// their built-in definition. + final Map connectionModes; + + const DataSystemConfig({ + this.connectionModes = const {}, + }); +} diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index ceab262e..49a20c11 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -92,6 +92,20 @@ final class DataSourceManager { _activeDataSource = null; } + void _completeIdentify(MessageStatus handled) { + if (handled == MessageStatus.messageHandled && _identifyCompleter != null) { + if (_identifyCompleter!.isCompleted) { + _logger.error('Identify was already complete before receiving ' + 'data. This could represent an issue with SDK logic. Please' + 'make a bug report if you encounter this situation.'); + } else { + _identifyCompleter!.complete(); + } + } + // Only need to complete this the first time. + _identifyCompleter = null; + } + DataSource? _createDataSource(FDv2ConnectionMode mode) { if (_activeContext != null) { if (_dataSourceFactories[mode] == null) { @@ -146,23 +160,14 @@ final class DataSourceManager { var handled = await _dataSourceEventHandler.handleMessage( _activeContext!, event.type, event.data, environmentId: event.environmentId); - if (handled == MessageStatus.messageHandled && - _identifyCompleter != null) { - if (_identifyCompleter!.isCompleted) { - _logger.error('Identify was already complete before receiving ' - 'data. This could represent an issue with SDK logic. Please' - 'make a bug report if you encounter this situation.'); - } else { - _identifyCompleter!.complete(); - } - } - // Only need to complete this the first time. - _identifyCompleter = null; + _completeIdentify(handled); return handled; case PayloadEvent(): - // The FDv1 data sources this manager runs never produce FDv2 - // payload events. - return MessageStatus.messageHandled; + var handled = await _dataSourceEventHandler.handlePayload( + _activeContext!, event.changeSet, + environmentId: event.environmentId); + _completeIdentify(handled); + return handled; case StatusEvent(): if (_identifyCompleter != null && !_identifyCompleter!.isCompleted) { _identifyCompleter!.completeError(Exception(event.message)); diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart new file mode 100644 index 00000000..1d6a9d02 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -0,0 +1,140 @@ +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; + +import '../../config/data_system_config.dart'; +import '../../config/service_endpoints.dart'; +import '../../fdv2_connection_mode.dart'; +import '../data_source_manager.dart'; +import '../data_source_status_manager.dart'; +import 'built_in_modes.dart'; +import 'entry_factories.dart'; +import 'mode_definition.dart'; +import 'orchestrator.dart'; +import 'requestor.dart'; +import 'selector.dart'; +import 'source_factory_context.dart'; +import 'source_manager.dart'; + +/// Composes the FDv2 data source factories consumed by the +/// DataSourceManager and owns the state that must outlive any single +/// orchestrator instance: the current selector and the context it +/// belongs to. +/// +/// A fresh orchestrator is created per connection-mode switch and per +/// identify. The selector survives mode switches (initializers are +/// skipped when a selector is held) but is reset whenever the context +/// changes, since a selector is specific to a single context. +final class FDv2DataSystem { + final String _credential; + final LDLogger _logger; + final HttpProperties _httpProperties; + final ServiceEndpoints _serviceEndpoints; + final bool _withReasons; + final Duration _defaultPollingInterval; + final DataSourceStatusManager _statusManager; + final Map _connectionModeOverrides; + final FDv2SseClientFactory _sseClientFactory; + final HttpClientFactory? _httpClientFactory; + + Selector _selector = Selector.empty; + LDContext? _lastContext; + + FDv2DataSystem({ + required DataSystemConfig config, + required String credential, + required LDLogger logger, + required HttpProperties httpProperties, + required ServiceEndpoints serviceEndpoints, + required bool withReasons, + required Duration defaultPollingInterval, + required DataSourceStatusManager statusManager, + FDv2SseClientFactory sseClientFactory = defaultSseClientFactory, + HttpClientFactory? httpClientFactory, + }) : _credential = credential, + _logger = logger, + _httpProperties = httpProperties, + _serviceEndpoints = serviceEndpoints, + _withReasons = withReasons, + _defaultPollingInterval = defaultPollingInterval, + _statusManager = statusManager, + _sseClientFactory = sseClientFactory, + _httpClientFactory = httpClientFactory, + _connectionModeOverrides = config.connectionModes; + + /// The definition for a built-in mode: the user's override if one was + /// given for it, otherwise the built-in default. + ModeDefinition _resolve(ConnectionModeId mode, ModeDefinition builtIn) => + _connectionModeOverrides[mode] ?? builtIn; + + /// Produces the factory map for the DataSourceManager. Offline carries + /// no factory; the manager handles offline without a data source. + Map buildFactories() { + return { + const FDv2Streaming(): _factoryForMode( + _resolve(ConnectionModeId.streaming, BuiltInModes.streaming)), + const FDv2Polling(): _factoryForMode( + _resolve(ConnectionModeId.polling, BuiltInModes.polling)), + const FDv2Background(): _factoryForMode( + _resolve(ConnectionModeId.background, BuiltInModes.background)), + }; + } + + DataSourceFactory _factoryForMode(ModeDefinition modeDefinition) { + return (LDContext context) { + if (!identical(context, _lastContext)) { + // A new identify produces a new decorated context instance; a + // mode switch re-uses the active one. The selector belongs to a + // single context and must not be reused across identifies. + _lastContext = context; + _selector = Selector.empty; + } + + final factoryContext = SourceFactoryContext.fromClientConfig( + context: context, + credential: _credential, + logger: _logger, + httpProperties: _httpProperties, + serviceEndpoints: _serviceEndpoints, + withReasons: _withReasons, + defaultPollingInterval: _defaultPollingInterval, + // The common client loads cached flags into the flag store before + // the data source starts (FlagManager.loadCached during identify), + // so the cache is already applied by the time this chain runs. + // Reporting a miss advances the chain without re-applying it. + cachedFlagsReader: (_) async => null, + httpClientFactory: _httpClientFactory, + ); + + // When a selector is held the SDK already has basis data for this + // context; mode switches go straight to synchronizers. + final includeInitializers = _selector.isEmpty; + final initializerFactories = includeInitializers + ? buildInitializerFactories( + modeDefinition.initializers, factoryContext) + : []; + + // The FDv1 fallback tier (modeDefinition.fdv1Fallback) is not built + // into a slot yet. When it is, mark that slot isFdv1Fallback and keep + // its source incapable of emitting a result with fdv1Fallback set: + // it is the terminal tier, so re-asserting the directive from there + // would drive the orchestrator to re-engage FDv1 fallback on every + // result, undelayed and blocking no slot. A source that cannot emit + // the directive is simpler than guarding the orchestrator against + // re-engaging while already on FDv1. + final synchronizerSlots = buildSynchronizerFactories( + modeDefinition.synchronizers, factoryContext, + sseClientFactory: _sseClientFactory) + .map((factory) => SynchronizerSlot(factory: factory)) + .toList(); + + return FDv2DataSourceOrchestrator( + initializerFactories: initializerFactories, + synchronizerSlots: synchronizerSlots, + selectorGetter: () => _selector, + selectorUpdater: (selector) => _selector = selector, + statusManager: _statusManager, + logger: _logger, + ); + }; + } +} diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index f13bf3c2..b1d4f47b 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -19,6 +19,7 @@ import 'hooks/hook_runner.dart'; import 'data_sources/data_source.dart'; import 'data_sources/data_source_event_handler.dart'; import 'data_sources/fdv2/built_in_modes.dart'; +import 'data_sources/fdv2/data_system.dart'; import 'data_sources/data_source_manager.dart'; import 'data_sources/data_source_status.dart'; import 'data_sources/data_source_status_manager.dart'; @@ -421,10 +422,26 @@ final class LDCommonClient { _updateEventSendingState(); if (!_config.offline) { - _dataSourceManager.setFactories(_composeFactoriesForManager( - fdv1Factories: _dataSourceFactories(_config, _logger, httpProperties), - backgroundFactory: _backgroundFactory(_config, _logger, httpProperties), - )); + if (_config.dataSystem case final dataSystemConfig?) { + final dataSystem = FDv2DataSystem( + config: dataSystemConfig, + credential: _config.sdkCredential, + logger: _logger, + httpProperties: httpProperties, + serviceEndpoints: _config.serviceEndpoints, + withReasons: _config.dataSourceConfig.evaluationReasons, + defaultPollingInterval: + _config.dataSourceConfig.polling.pollingInterval, + statusManager: _dataSourceStatusManager, + ); + _dataSourceManager.setFactories(dataSystem.buildFactories()); + } else { + _dataSourceManager.setFactories(_composeFactoriesForManager( + fdv1Factories: _dataSourceFactories(_config, _logger, httpProperties), + backgroundFactory: + _backgroundFactory(_config, _logger, httpProperties), + )); + } } else { DataSource nullSource(LDContext _) => NullDataSource(); _dataSourceManager.setFactories({ diff --git a/packages/common_client/lib/src/ld_common_config.dart b/packages/common_client/lib/src/ld_common_config.dart index e62aa295..e175c0c0 100644 --- a/packages/common_client/lib/src/ld_common_config.dart +++ b/packages/common_client/lib/src/ld_common_config.dart @@ -1,13 +1,15 @@ import 'dart:collection'; import 'dart:math'; -import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; import 'hooks/hook.dart'; +import 'config/data_system_config.dart'; import 'config/defaults/default_config.dart'; import 'config/events_config.dart'; import 'connection_mode.dart'; -import 'config/service_endpoints.dart' as client_endpoints; +import 'config/service_endpoints.dart'; /// Configuration which affects how the SDK uses persistence. final class PersistenceConfig { @@ -132,6 +134,16 @@ abstract class LDCommonConfig { /// An initial list of hooks. final UnmodifiableListView? hooks; + /// Configuration for the FDv2 data system. Providing this (even an + /// empty configuration) opts the SDK into the FDv2 data acquisition + /// protocol. + /// + /// This feature is not stable, and not subject to any backwards + /// compatibility guarantees or semantic versioning. It is in early + /// access. If you want access to this feature please join the EAP. + /// https://launchdarkly.com/docs/sdk/features/data-saving-mode + final DataSystemConfig? dataSystem; + LDCommonConfig(this.sdkCredential, this.autoEnvAttributes, {this.applicationInfo, HttpProperties? httpProperties, @@ -143,10 +155,10 @@ abstract class LDCommonConfig { DataSourceConfig? dataSourceConfig, bool? allAttributesPrivate, List? globalPrivateAttributes, - List? hooks}) + List? hooks, + this.dataSystem}) : httpProperties = httpProperties ?? HttpProperties(), - serviceEndpoints = - serviceEndpoints ?? client_endpoints.ServiceEndpoints(), + serviceEndpoints = serviceEndpoints ?? ServiceEndpoints(), events = events ?? EventsConfig(), persistence = persistence ?? PersistenceConfig(), offline = offline ?? DefaultConfig.defaultOffline, diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index e2d518da..47230d5e 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -6,23 +6,32 @@ import 'package:launchdarkly_common_client/src/data_sources/data_source_event_ha import 'package:launchdarkly_common_client/src/data_sources/data_source_manager.dart'; import 'package:launchdarkly_common_client/src/data_sources/data_source_status.dart'; import 'package:launchdarkly_common_client/src/data_sources/data_source_status_manager.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/payload.dart'; import 'package:launchdarkly_common_client/src/flag_manager/flag_manager.dart'; +import 'package:launchdarkly_common_client/src/item_descriptor.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; import 'package:test/test.dart'; final class MockDataSource implements DataSource { final StreamController controller = StreamController(); + final List _startEvents; bool startCalled = false; bool stopCalled = false; bool restartCalled = false; + MockDataSource({List? startEvents}) + : _startEvents = startEvents ?? [DataEvent('put', '{}')]; + @override Stream get events => controller.stream; @override void start() { startCalled = true; - controller.sink.add(DataEvent('put', '{}')); + for (final event in _startEvents) { + controller.sink.add(event); + } } @override @@ -112,6 +121,42 @@ void main() { )); }); + test('it applies an FDv2 payload event and completes identify', () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + final changeSet = ChangeSet(type: PayloadType.full, updates: { + 'flag-a': ItemDescriptor( + version: 3, + flag: LDEvaluationResult( + version: 3, + detail: LDEvaluationDetail( + LDValue.ofBool(true), 0, LDEvaluationReason.off()), + ), + ), + }); + final factories = { + const FDv2Streaming(): (_) => + MockDataSource(startEvents: [PayloadEvent(changeSet)]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + expectLater( + statusManager.changes, + emits(DataSourceStatus( + state: DataSourceState.valid, stateSince: DateTime(1)))); + + final completer = Completer(); + manager.identify(context, completer); + + // The payload reaches handlePayload, which applies the change set, + // marks the source valid, and completes the pending identify. (A + // dropped/no-op payload would leave the identify hanging.) + await completer.future; + }); + test('it can transition to offline and tear-down the previous connection', () { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); diff --git a/packages/common_client/test/data_sources/fdv2/data_system_test.dart b/packages/common_client/test/data_sources/fdv2/data_system_test.dart new file mode 100644 index 00000000..b8428f32 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/data_system_test.dart @@ -0,0 +1,85 @@ +import 'package:launchdarkly_common_client/src/config/data_system_config.dart'; +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_status_manager.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/built_in_modes.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/data_system.dart'; +import 'package:launchdarkly_common_client/src/fdv2_connection_mode.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +FDv2DataSystem makeDataSystem( + {DataSystemConfig config = const DataSystemConfig()}) => + FDv2DataSystem( + config: config, + credential: 'the-credential', + logger: LDLogger(level: LDLogLevel.none), + httpProperties: HttpProperties(), + serviceEndpoints: ServiceEndpoints(), + withReasons: false, + defaultPollingInterval: const Duration(seconds: 300), + statusManager: DataSourceStatusManager(), + ); + +LDContext _context() => LDContextBuilder().kind('user', 'bob').build(); + +void main() { + test('an empty data system config overrides no modes', () { + expect(const DataSystemConfig().connectionModes, isEmpty); + }); + + test('buildFactories exposes streaming, polling, and background', () { + final factories = makeDataSystem().buildFactories(); + + expect( + factories.keys, + containsAll([ + const FDv2Streaming(), + const FDv2Polling(), + const FDv2Background(), + ])); + expect(factories.containsKey(const FDv2Offline()), isFalse, + reason: 'offline has no data source; the manager handles it directly'); + }); + + test('a factory builds a data source, fresh on each call', () { + final factory = makeDataSystem().buildFactories()[const FDv2Streaming()]!; + final context = _context(); + + final first = factory(context); + final second = factory(context); + + expect(first, isA()); + expect(identical(first, second), isFalse, + reason: 'a fresh orchestrator is created per connection'); + + first.stop(); + second.stop(); + }); + + test('an override replaces a built-in mode definition', () { + // Override streaming with the polling definition; the streaming + // factory should still build a usable data source from it. + final factory = makeDataSystem( + config: const DataSystemConfig(connectionModes: { + ConnectionModeId.streaming: BuiltInModes.polling, + })).buildFactories()[const FDv2Streaming()]!; + + final source = factory(_context()); + expect(source, isA()); + source.stop(); + }); + + test('the override map is keyed only by built-in modes', () { + // ConnectionModeId is a sealed type whose only nameable values are the + // built-in modes, so a custom/arbitrary mode name cannot be expressed + // as a key. Providing an override for a built-in resolves; the others + // keep their built-in definitions. + const config = DataSystemConfig(connectionModes: { + ConnectionModeId.polling: BuiltInModes.streaming, + }); + final factories = makeDataSystem(config: config).buildFactories(); + expect(factories.keys, hasLength(3)); + }); +} From 6c00880e5533a116c50dc7e9d6eb097d32517852 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:18:35 -0700 Subject: [PATCH 2/5] feat: load the FDv2 cache through the pipeline and run offline as a data source Split the identify strategy into FDv1/FDv2 data managers so cache handling lives with each protocol. FDv2 no longer loads the cache at identify; the data system reads it through a real cachedFlagsReader and feeds it into the pipeline. A PayloadEvent basis flag, set by the orchestrator, gates both the valid status (moved out of handlePayload) and wait-for-network resolution, so cached flags apply without reporting a live connection. Offline is now a real pipeline mode (cache initializer, no synchronizer) that loads cache while the manager keeps the offline status. Adds ConnectionModeId.offline and FlagManager.readCached. --- .../lib/src/config/data_system_config.dart | 7 +- .../lib/src/data_sources/data_manager.dart | 70 +++++++++++ .../lib/src/data_sources/data_source.dart | 15 ++- .../data_source_event_handler.dart | 9 +- .../src/data_sources/data_source_manager.dart | 50 ++++++-- .../src/data_sources/fdv2/data_system.dart | 22 ++-- .../src/data_sources/fdv2/orchestrator.dart | 29 +++-- .../lib/src/flag_manager/flag_manager.dart | 8 ++ .../src/flag_manager/flag_persistence.dart | 36 ++++-- .../lib/src/ld_common_client.dart | 25 ++-- .../data_source_event_handler_test.dart | 12 +- .../data_source_manager_test.dart | 111 ++++++++++++++++++ .../data_sources/fdv2/data_system_test.dart | 9 +- .../data_sources/fdv2/orchestrator_test.dart | 45 +++++++ .../test/flag_persistence_test.dart | 57 +++++++++ 15 files changed, 444 insertions(+), 61 deletions(-) create mode 100644 packages/common_client/lib/src/data_sources/data_manager.dart diff --git a/packages/common_client/lib/src/config/data_system_config.dart b/packages/common_client/lib/src/config/data_system_config.dart index 334362e1..4a7a9e53 100644 --- a/packages/common_client/lib/src/config/data_system_config.dart +++ b/packages/common_client/lib/src/config/data_system_config.dart @@ -29,7 +29,7 @@ import '../data_sources/fdv2/mode_definition.dart'; /// Identifies a built-in connection mode whose data-source pipeline can be /// overridden through [DataSystemConfig.connectionModes]: [streaming], -/// [polling], or [background]. +/// [polling], [background], or [offline]. sealed class ConnectionModeId { const ConnectionModeId(); @@ -42,6 +42,11 @@ sealed class ConnectionModeId { /// The built-in background mode. static const ConnectionModeId background = _BuiltInConnectionMode('background'); + + /// The built-in offline mode. Its pipeline loads cached flags and runs + /// no synchronizer, so overriding it customizes how the SDK behaves + /// while offline (for example, the cache initializer it uses). + static const ConnectionModeId offline = _BuiltInConnectionMode('offline'); } final class _BuiltInConnectionMode extends ConnectionModeId { diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart new file mode 100644 index 00000000..56df0635 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + show LDContext; + +import '../flag_manager/flag_manager.dart'; +import 'data_source_manager.dart'; + +/// Owns the data-acquisition strategy for an identify: how the cache is +/// loaded and when the identify resolves. The FDv1 and FDv2 protocols +/// diverge here, so each has its own implementation; everything else +/// (connection lifecycle, mode switching, event routing) is shared in the +/// [DataSourceManager] that both delegate to. +abstract interface class DataManager { + /// Brings the SDK to a usable state for [context], resolving when the + /// manager's data-availability strategy is satisfied. + /// + /// When [waitForNetworkResults] is true the returned future resolves + /// only once network (or otherwise fresh) data has arrived; otherwise it + /// may resolve as soon as cached data is available. + Future identify(LDContext context, + {required bool waitForNetworkResults}); +} + +/// FDv1 data manager. +/// +/// The cache is loaded imperatively at identify time via +/// [FlagManager.loadCached]. A cache hit resolves identify immediately +/// unless the caller is waiting for network results; either way the +/// network connection is started so live data follows. +final class FDv1DataManager implements DataManager { + final DataSourceManager _dataSourceManager; + final FlagManager _flagManager; + + FDv1DataManager(this._dataSourceManager, this._flagManager); + + @override + Future identify(LDContext context, + {required bool waitForNetworkResults}) async { + final completer = Completer(); + final loadedFromCache = await _flagManager.loadCached(context); + _dataSourceManager.identify(context, completer); + if (loadedFromCache && !waitForNetworkResults) { + return; + } + return completer.future; + } +} + +/// FDv2 data manager. +/// +/// The cache is not loaded at identify time; the data source pipeline's +/// cache initializer loads it as the first tier. Identify resolves on the +/// first delivered payload, or -- when waiting for network results -- only +/// on basis (network or terminal) data, so a cache load alone does not +/// satisfy a wait-for-network identify. +final class FDv2DataManager implements DataManager { + final DataSourceManager _dataSourceManager; + + FDv2DataManager(this._dataSourceManager); + + @override + Future identify(LDContext context, + {required bool waitForNetworkResults}) { + final completer = Completer(); + _dataSourceManager.identify(context, completer, + requireFreshData: waitForNetworkResults); + return completer.future; + } +} diff --git a/packages/common_client/lib/src/data_sources/data_source.dart b/packages/common_client/lib/src/data_sources/data_source.dart index af0f4f0b..3c728708 100644 --- a/packages/common_client/lib/src/data_sources/data_source.dart +++ b/packages/common_client/lib/src/data_sources/data_source.dart @@ -18,7 +18,20 @@ final class PayloadEvent implements DataSourceEvent { final ChangeSet changeSet; final String? environmentId; - PayloadEvent(this.changeSet, {this.environmentId}); + /// Whether this payload represents the freshest data the active source + /// can produce for this initialization -- network basis data, an FDv1 + /// fallback transfer, or the terminal payload of a cache-only system. + /// + /// False only for preliminary cache data delivered while a fresher + /// source is still expected (e.g. the cache initializer ahead of a + /// streaming synchronizer). The manager uses this to decide whether to + /// mark the source valid and whether to resolve an identify that is + /// waiting for network results: cached flags are applied either way, + /// but a non-basis payload neither drives the status to valid nor + /// satisfies a wait-for-network identify. + final bool basis; + + PayloadEvent(this.changeSet, {this.environmentId, this.basis = true}); } final class StatusEvent implements DataSourceEvent { diff --git a/packages/common_client/lib/src/data_sources/data_source_event_handler.dart b/packages/common_client/lib/src/data_sources/data_source_event_handler.dart index c28daa77..634d9a96 100644 --- a/packages/common_client/lib/src/data_sources/data_source_event_handler.dart +++ b/packages/common_client/lib/src/data_sources/data_source_event_handler.dart @@ -101,14 +101,19 @@ final class DataSourceEventHandler { /// /// Full change sets replace the stored flags, partial change sets apply /// each update, and a change set of type none confirms the SDK is up to - /// date without changing data. All three mark the data source valid. + /// date without changing data. + /// + /// This does not touch the data source status. Unlike the FDv1 verbs, + /// an FDv2 payload only marks the source valid when it is network basis + /// data, and never while offline; the DataSourceManager makes that call + /// from the payload's basis flag and the active mode, so applying cached + /// flags here does not prematurely report valid. Future handlePayload(LDContext context, ChangeSet changeSet, {String? environmentId}) async { try { await _flagManager.applyChanges( context, changeSet.updates, changeSet.type, environmentId: environmentId); - _statusManager.setValid(); return MessageStatus.messageHandled; } catch (err) { _logger.error('Failed to apply an FDv2 change set: ${err.runtimeType}'); diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index 49a20c11..05e7f520 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -38,6 +38,11 @@ final class DataSourceManager { Completer? _identifyCompleter; + /// When true, the active identify resolves only on basis (network or + /// terminal) data, not on preliminary cache data. Set per identify from + /// the caller's wait-for-network-results preference. + bool _requireFreshData = false; + DataSourceManager({ ConnectionMode startingMode = ConnectionMode.streaming, required DataSourceStatusManager statusManager, @@ -61,8 +66,10 @@ final class DataSourceManager { _dataSourceFactories.addAll(factories); } - void identify(LDContext context, Completer completer) { + void identify(LDContext context, Completer completer, + {bool requireFreshData = false}) { _identifyCompleter = completer; + _requireFreshData = requireFreshData; _activeContext = context; _setupConnection(); @@ -92,15 +99,22 @@ final class DataSourceManager { _activeDataSource = null; } - void _completeIdentify(MessageStatus handled) { - if (handled == MessageStatus.messageHandled && _identifyCompleter != null) { - if (_identifyCompleter!.isCompleted) { - _logger.error('Identify was already complete before receiving ' - 'data. This could represent an issue with SDK logic. Please' - 'make a bug report if you encounter this situation.'); - } else { - _identifyCompleter!.complete(); - } + void _completeIdentify(MessageStatus handled, {bool basis = true}) { + if (handled != MessageStatus.messageHandled || _identifyCompleter == null) { + return; + } + // An identify waiting for network results resolves only on basis + // (network or terminal) data; preliminary cache data is applied but + // leaves the identify pending so a later basis payload resolves it. + if (_requireFreshData && !basis) { + return; + } + if (_identifyCompleter!.isCompleted) { + _logger.error('Identify was already complete before receiving ' + 'data. This could represent an issue with SDK logic. Please' + 'make a bug report if you encounter this situation.'); + } else { + _identifyCompleter!.complete(); } // Only need to complete this the first time. _identifyCompleter = null; @@ -132,6 +146,11 @@ final class DataSourceManager { switch (_activeConnectionMode) { case FDv2Offline(): + // Report why the SDK is offline. When an offline data source is + // configured (the FDv2 data system supplies one) it then loads + // cached flags through the pipeline below; its payload does not + // drive the status to valid while offline, so this status stands. + // FDv1 has no offline factory, so offline stays status-only. switch (_offlineDetail) { case OfflineSetOffline(): _statusManager.setOffline(); @@ -140,7 +159,6 @@ final class DataSourceManager { case OfflineBackgroundDisabled(): _statusManager.setBackgroundDisabled(); } - return; case FDv2Streaming(): case FDv2Polling(): case FDv2Background(): @@ -166,7 +184,15 @@ final class DataSourceManager { var handled = await _dataSourceEventHandler.handlePayload( _activeContext!, event.changeSet, environmentId: event.environmentId); - _completeIdentify(handled); + if (handled == MessageStatus.messageHandled && event.basis) { + // Basis data marks the source valid, except while offline: + // there the offline status set in _setupConnection stands and + // a cache load must not be reported as a live connection. + if (_activeConnectionMode is! FDv2Offline) { + _statusManager.setValid(); + } + } + _completeIdentify(handled, basis: event.basis); return handled; case StatusEvent(): if (_identifyCompleter != null && !_identifyCompleter!.isCompleted) { diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index 1d6a9d02..ca599c9a 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -7,6 +7,7 @@ import '../../fdv2_connection_mode.dart'; import '../data_source_manager.dart'; import '../data_source_status_manager.dart'; import 'built_in_modes.dart'; +import 'cache_initializer.dart'; import 'entry_factories.dart'; import 'mode_definition.dart'; import 'orchestrator.dart'; @@ -33,6 +34,7 @@ final class FDv2DataSystem { final Duration _defaultPollingInterval; final DataSourceStatusManager _statusManager; final Map _connectionModeOverrides; + final CachedFlagsReader _cachedFlagsReader; final FDv2SseClientFactory _sseClientFactory; final HttpClientFactory? _httpClientFactory; @@ -48,6 +50,7 @@ final class FDv2DataSystem { required bool withReasons, required Duration defaultPollingInterval, required DataSourceStatusManager statusManager, + required CachedFlagsReader cachedFlagsReader, FDv2SseClientFactory sseClientFactory = defaultSseClientFactory, HttpClientFactory? httpClientFactory, }) : _credential = credential, @@ -57,6 +60,7 @@ final class FDv2DataSystem { _withReasons = withReasons, _defaultPollingInterval = defaultPollingInterval, _statusManager = statusManager, + _cachedFlagsReader = cachedFlagsReader, _sseClientFactory = sseClientFactory, _httpClientFactory = httpClientFactory, _connectionModeOverrides = config.connectionModes; @@ -66,8 +70,11 @@ final class FDv2DataSystem { ModeDefinition _resolve(ConnectionModeId mode, ModeDefinition builtIn) => _connectionModeOverrides[mode] ?? builtIn; - /// Produces the factory map for the DataSourceManager. Offline carries - /// no factory; the manager handles offline without a data source. + /// Produces the factory map for the DataSourceManager. Offline is a + /// real pipeline mode: its data source runs the cache initializer with + /// no synchronizer, so the SDK serves cached flags while offline. The + /// manager reports the offline status itself; the offline source's + /// payload does not drive the status to valid. Map buildFactories() { return { const FDv2Streaming(): _factoryForMode( @@ -76,6 +83,8 @@ final class FDv2DataSystem { _resolve(ConnectionModeId.polling, BuiltInModes.polling)), const FDv2Background(): _factoryForMode( _resolve(ConnectionModeId.background, BuiltInModes.background)), + const FDv2Offline(): _factoryForMode( + _resolve(ConnectionModeId.offline, BuiltInModes.offline)), }; } @@ -97,11 +106,10 @@ final class FDv2DataSystem { serviceEndpoints: _serviceEndpoints, withReasons: _withReasons, defaultPollingInterval: _defaultPollingInterval, - // The common client loads cached flags into the flag store before - // the data source starts (FlagManager.loadCached during identify), - // so the cache is already applied by the time this chain runs. - // Reporting a miss advances the chain without re-applying it. - cachedFlagsReader: (_) async => null, + // The FDv2 data system owns cache loading: the cache initializer + // reads persistence through this reader and feeds the result into + // the pipeline, rather than the client applying it at identify. + cachedFlagsReader: _cachedFlagsReader, httpClientFactory: _httpClientFactory, ); diff --git a/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart b/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart index 2c3caf4b..c8611d2e 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart @@ -63,6 +63,11 @@ final class FDv2DataSourceOrchestrator implements DataSource { bool _closed = false; bool _emittedPayload = false; + /// True when the only sources are cache initializers (no synchronizers). + /// In such a system the cache load is the freshest data that will ever + /// arrive, so its payload is treated as basis rather than preliminary. + final bool _cacheOnlyDataSystem; + /// Resolves the outcome of the active synchronizer run. Set while a /// synchronizer is running; [restart] and [stop] use it to interrupt /// the run. @@ -86,6 +91,9 @@ final class FDv2DataSourceOrchestrator implements DataSource { _recoveryTimeout = recoveryTimeout, _recycleDelay = recycleDelay, _logger = logger.subLogger('FDv2Orchestrator'), + _cacheOnlyDataSystem = initializerFactories.isNotEmpty && + initializerFactories.every((f) => f.isCache) && + synchronizerSlots.isEmpty, _sourceManager = SourceManager( initializerFactories: initializerFactories, synchronizerSlots: synchronizerSlots, @@ -137,7 +145,7 @@ final class FDv2DataSourceOrchestrator implements DataSource { } } - void _emitPayload(ChangeSetResult result) { + void _emitPayload(ChangeSetResult result, {bool basis = true}) { if (_closed || _controller.isClosed) return; // An intent of "none" means the SDK is already up to date; it carries // no selector and must not regress the one we hold. For any other @@ -150,8 +158,8 @@ final class FDv2DataSourceOrchestrator implements DataSource { _selectorUpdater(result.changeSet.selector); } _emittedPayload = true; - _controller.add( - PayloadEvent(result.changeSet, environmentId: result.environmentId)); + _controller.add(PayloadEvent(result.changeSet, + environmentId: result.environmentId, basis: basis)); } void _reportTransientError(StatusResult result) { @@ -200,7 +208,15 @@ final class FDv2DataSourceOrchestrator implements DataSource { switch (result) { case ChangeSetResult(): if (result.changeSet.type != PayloadType.none) { - _emitPayload(result); + // Selector-bearing data is network basis; an FDv1 fallback + // transfer is fresh network data too; and in a cache-only + // system the cache load is the freshest data there will be. + // Otherwise this is preliminary cache data ahead of a + // synchronizer: applied, but not basis. + final isBasis = result.changeSet.selector.isNotEmpty || + result.fdv1Fallback || + _cacheOnlyDataSystem; + _emitPayload(result, basis: isBasis); if (_handleFdv1Fallback(result)) { // Data was received but the server directed FDv1 fallback; @@ -240,10 +256,7 @@ final class FDv2DataSourceOrchestrator implements DataSource { // miss -- there is nowhere else for data to come from. Emit an empty // payload so the pipeline reaches a valid state, unless an error has // already been reported. - final cacheOnlyDataSystem = _initializerFactories.isNotEmpty && - _initializerFactories.every((f) => f.isCache) && - _synchronizerSlots.isEmpty; - if (cacheOnlyDataSystem && !_emittedPayload && !errorDuringInit) { + if (_cacheOnlyDataSystem && !_emittedPayload && !errorDuringInit) { _emitPayload(const ChangeSetResult( changeSet: ChangeSet(type: PayloadType.none, updates: {}), persist: false, diff --git a/packages/common_client/lib/src/flag_manager/flag_manager.dart b/packages/common_client/lib/src/flag_manager/flag_manager.dart index 1e9c7973..381eb479 100644 --- a/packages/common_client/lib/src/flag_manager/flag_manager.dart +++ b/packages/common_client/lib/src/flag_manager/flag_manager.dart @@ -72,6 +72,14 @@ final class FlagManager { return _flagPersistence.loadCached(context); } + /// Reads cached values from persistence without applying them to the + /// store. Used by the FDv2 cache initializer, which loads the cache + /// through the data source pipeline rather than at identify time. + Future<({Map flags, String? environmentId})?> + readCached(LDContext context) async { + return _flagPersistence.readCached(context); + } + /// A broadcast stream which emits events as flag changes occur based either /// on loading cached values or updates from the data source. Stream get changes => _flagUpdater.changes; diff --git a/packages/common_client/lib/src/flag_manager/flag_persistence.dart b/packages/common_client/lib/src/flag_manager/flag_persistence.dart index 7ce0477c..e0ed7f68 100644 --- a/packages/common_client/lib/src/flag_manager/flag_persistence.dart +++ b/packages/common_client/lib/src/flag_manager/flag_persistence.dart @@ -81,12 +81,20 @@ final class FlagPersistence { return false; } - Future loadCached(LDContext context) async { + /// Reads the cached flag state for [context] from persistence without + /// applying it to the store. Returns null on a cache miss, an + /// unreadable entry, or a parse failure. + /// + /// The FDv2 data system loads the cache through its cache initializer + /// rather than the [loadCached] apply-at-identify path, so it needs the + /// parsed flags back rather than a side effect on the store. + Future<({Map flags, String? environmentId})?> + readCached(LDContext context) async { final json = await _persistence?.read( _environmentKey, encodePersistenceKey(context.canonicalKey)); if (json == null) { - return false; + return null; } final environmentId = await _persistence?.read(_environmentKey, _envIdKey); @@ -94,18 +102,26 @@ final class FlagPersistence { try { final flagConfig = LDEvaluationResultsSerialization.fromJson(jsonDecode(json)); - - _updater.initCached( - context, - flagConfig.map((key, value) => MapEntry( - key, ItemDescriptor(version: value.version, flag: value))), - environmentId: environmentId); - _logger.debug('Loaded a cached flag config from persistence.'); - return true; + return (flags: flagConfig, environmentId: environmentId); } catch (e) { _logger.warn('Could not load cached flag values for context: $e'); + return null; + } + } + + Future loadCached(LDContext context) async { + final cached = await readCached(context); + if (cached == null) { return false; } + + _updater.initCached( + context, + cached.flags.map((key, value) => + MapEntry(key, ItemDescriptor(version: value.version, flag: value))), + environmentId: cached.environmentId); + _logger.debug('Loaded a cached flag config from persistence.'); + return true; } Future _loadIndex() async { diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index b1d4f47b..21032d97 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -16,6 +16,7 @@ import 'context_modifiers/context_modifier.dart'; import 'context_modifiers/env_context_modifier.dart'; import 'hooks/hook.dart'; import 'hooks/hook_runner.dart'; +import 'data_sources/data_manager.dart'; import 'data_sources/data_source.dart'; import 'data_sources/data_source_event_handler.dart'; import 'data_sources/fdv2/built_in_modes.dart'; @@ -205,6 +206,11 @@ final class LDCommonClient { final CommonPlatform _platform; late final DataSourceManager _dataSourceManager; + + /// Owns the per-protocol identify strategy (cache load + resolution). + /// Selected from [_config.dataSystem]: FDv2 when a data system is + /// configured, otherwise FDv1. + late final DataManager _dataManager; late final EnvironmentReport _envReport; late final AsyncSingleQueue _identifyQueue = AsyncSingleQueue(); late final DataSourceFactoriesFn _dataSourceFactories; @@ -283,6 +289,11 @@ final class LDCommonClient { dataSourceEventHandler: dataSourceEventHandler, logger: _logger); + // FDv2 loads the cache through its pipeline; FDv1 loads it at identify. + _dataManager = _config.dataSystem != null + ? FDv2DataManager(_dataSourceManager) + : FDv1DataManager(_dataSourceManager, _flagManager); + if (_config.offline) { _dataSourceStatusManager.setOffline(); } @@ -433,6 +444,7 @@ final class LDCommonClient { defaultPollingInterval: _config.dataSourceConfig.polling.pollingInterval, statusManager: _dataSourceStatusManager, + cachedFlagsReader: _flagManager.readCached, ); _dataSourceManager.setFactories(dataSystem.buildFactories()); } else { @@ -550,19 +562,18 @@ final class LDCommonClient { final afterIdentify = _hookRunner.identify(_context); hookCallback(afterIdentify); - final completer = Completer(); _eventProcessor?.processIdentifyEvent(IdentifyEvent(context: _context)); - final loadedFromCache = await _flagManager.loadCached(_context); if (_config.offline) { + // Fully offline: there is no data source to run, so load the cache + // directly to serve flags. (Distinct from the offline connection + // mode, whose pipeline loads the cache for the FDv2 data system.) + await _flagManager.loadCached(_context); return; } - _dataSourceManager.identify(_context, completer); - if (loadedFromCache && !waitForNetworkResults) { - return; - } - return completer.future; + return _dataManager.identify(_context, + waitForNetworkResults: waitForNetworkResults); } /// Returns the value of flag [flagKey] for the current context as a bool. diff --git a/packages/common_client/test/data_sources/data_source_event_handler_test.dart b/packages/common_client/test/data_sources/data_source_event_handler_test.dart index 488a4f0c..e20a8ced 100644 --- a/packages/common_client/test/data_sources/data_source_event_handler_test.dart +++ b/packages/common_client/test/data_sources/data_source_event_handler_test.dart @@ -303,13 +303,7 @@ void main() { ), ); - test('a full change set replaces the stored flags and sets valid', - () async { - expectLater( - statusManager!.changes, - emits(DataSourceStatus( - state: DataSourceState.valid, stateSince: DateTime(2)))); - + test('a full change set replaces the stored flags', () async { await eventHandler!.handlePayload(context, ChangeSet(type: PayloadType.full, updates: {'flagA': flagEval(1)})); @@ -329,7 +323,7 @@ void main() { test( 'a partial change set applies updates without per-item version ' - 'comparison and sets valid', () async { + 'comparison', () async { await eventHandler!.handlePayload(context, ChangeSet(type: PayloadType.full, updates: {'flagA': flagEval(7)})); @@ -348,7 +342,7 @@ void main() { expect(updated.detail.value, LDValue.ofBool(false)); }); - test('a change set of none changes no data and sets valid', () async { + test('a change set of none changes no data', () async { await eventHandler!.handlePayload(context, ChangeSet(type: PayloadType.full, updates: {'flagA': flagEval(1)})); diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index 47230d5e..e91d7cd1 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -295,4 +295,115 @@ void main() { expect(createdDataSource.controller.hasListener, isTrue); expect(createdDataSource.restartCalled, isTrue); }); + + ChangeSet aChangeSet() => ChangeSet(type: PayloadType.full, updates: { + 'flag-a': ItemDescriptor( + version: 3, + flag: LDEvaluationResult( + version: 3, + detail: LDEvaluationDetail( + LDValue.ofBool(true), 0, LDEvaluationReason.off()), + ), + ), + }); + + test( + 'a non-basis payload applies data and resolves a cached identify ' + 'without marking the source valid', () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + final factories = { + const FDv2Streaming(): (_) => MockDataSource( + startEvents: [PayloadEvent(aChangeSet(), basis: false)]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + final completer = Completer(); + // requireFreshData defaults false: a cached identify resolves on the + // preliminary cache payload. + manager.identify(context, completer); + await completer.future; + + expect(statusManager.status.state, isNot(DataSourceState.valid), + reason: 'preliminary cache data must not report a live connection'); + }); + + test( + 'an identify requiring fresh data ignores a non-basis payload and ' + 'resolves on a basis payload, then marks valid', () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + final factories = { + const FDv2Streaming(): (_) => MockDataSource(startEvents: [ + PayloadEvent(aChangeSet(), basis: false), + PayloadEvent(aChangeSet(), basis: true), + ]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + final completer = Completer(); + manager.identify(context, completer, requireFreshData: true); + await completer.future; + + expect(statusManager.status.state, DataSourceState.valid); + }); + + test('an identify requiring fresh data does not resolve on cache alone', + () async { + final context = LDContextBuilder().kind('user', 'bob').build(); + final factories = { + const FDv2Streaming(): (_) => MockDataSource( + startEvents: [PayloadEvent(aChangeSet(), basis: false)]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = makeManager(context, factories); + + final completer = Completer(); + manager.identify(context, completer, requireFreshData: true); + await pumpEventQueue(); + + expect(completer.isCompleted, isFalse, + reason: + 'cache data alone must not satisfy a wait-for-network identify'); + }); + + test( + 'offline runs its data source to load cache but keeps the offline status', + () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + var offlineStarted = false; + final factories = { + const FDv2Streaming(): (_) => MockDataSource(), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + const FDv2Offline(): (_) { + offlineStarted = true; + // A cache-only system: the cache load is the terminal (basis) + // payload, so the identify resolves -- but the manager must keep + // the offline status rather than report valid. + return MockDataSource( + startEvents: [PayloadEvent(aChangeSet(), basis: true)]); + }, + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + manager.setMode(const ResolvedOffline(OfflineSetOffline())); + final completer = Completer(); + manager.identify(context, completer); + await completer.future; + + expect(offlineStarted, isTrue, + reason: 'offline is a pipeline mode that runs its data source'); + expect(statusManager.status.state, DataSourceState.setOffline, + reason: 'a cache load while offline must not report valid'); + }); } diff --git a/packages/common_client/test/data_sources/fdv2/data_system_test.dart b/packages/common_client/test/data_sources/fdv2/data_system_test.dart index b8428f32..0348a2e7 100644 --- a/packages/common_client/test/data_sources/fdv2/data_system_test.dart +++ b/packages/common_client/test/data_sources/fdv2/data_system_test.dart @@ -20,6 +20,7 @@ FDv2DataSystem makeDataSystem( withReasons: false, defaultPollingInterval: const Duration(seconds: 300), statusManager: DataSourceStatusManager(), + cachedFlagsReader: (_) async => null, ); LDContext _context() => LDContextBuilder().kind('user', 'bob').build(); @@ -29,7 +30,8 @@ void main() { expect(const DataSystemConfig().connectionModes, isEmpty); }); - test('buildFactories exposes streaming, polling, and background', () { + test('buildFactories exposes streaming, polling, background, and offline', + () { final factories = makeDataSystem().buildFactories(); expect( @@ -38,9 +40,8 @@ void main() { const FDv2Streaming(), const FDv2Polling(), const FDv2Background(), + const FDv2Offline(), ])); - expect(factories.containsKey(const FDv2Offline()), isFalse, - reason: 'offline has no data source; the manager handles it directly'); }); test('a factory builds a data source, fresh on each call', () { @@ -80,6 +81,6 @@ void main() { ConnectionModeId.polling: BuiltInModes.streaming, }); final factories = makeDataSystem(config: config).buildFactories(); - expect(factories.keys, hasLength(3)); + expect(factories.keys, hasLength(4)); }); } diff --git a/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart b/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart index 59a5589d..88a1ca23 100644 --- a/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart +++ b/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart @@ -194,6 +194,51 @@ void main() { harness.orchestrator.stop(); }); + test('tags preliminary cache data as non-basis and network data as basis', + () async { + final synchronizers = []; + final harness = Harness(initializerFactories: [ + // A cache hit (full data, no selector) ahead of a synchronizer. + initializerFactory(changeSet(type: PayloadType.full), isCache: true), + ], synchronizerSlots: [ + synchronizerSlot(synchronizers), + ]); + + harness.orchestrator.start(); + await harness.pump(); + + final afterCache = harness.events.whereType().toList(); + expect(afterCache, hasLength(1)); + expect(afterCache.single.basis, isFalse, + reason: 'cache data ahead of a synchronizer is preliminary'); + + synchronizers.single.controller + .add(changeSet(selector: const Selector(state: 'state-1', version: 1))); + await harness.pump(); + + final all = harness.events.whereType().toList(); + expect(all, hasLength(2)); + expect(all.last.basis, isTrue, reason: 'network data is basis'); + + harness.orchestrator.stop(); + }); + + test('a cache-only system tags the cache load as basis', () async { + final harness = Harness(initializerFactories: [ + initializerFactory(changeSet(type: PayloadType.full), isCache: true), + ], synchronizerSlots: []); + + harness.orchestrator.start(); + await harness.pump(); + + final payloads = harness.events.whereType().toList(); + expect(payloads, hasLength(1)); + expect(payloads.single.basis, isTrue, + reason: 'with no fresher source, the cache load is the basis'); + + harness.orchestrator.stop(); + }); + test('synchronizer change sets are emitted and update the selector', () async { final synchronizers = []; diff --git a/packages/common_client/test/flag_persistence_test.dart b/packages/common_client/test/flag_persistence_test.dart index 12c8e528..470e7e8c 100644 --- a/packages/common_client/test/flag_persistence_test.dart +++ b/packages/common_client/test/flag_persistence_test.dart @@ -372,6 +372,63 @@ void main() { expect(flagStore.get('flagB'), basicData['flagB']); }); + test('readCached returns parsed flags without applying them', () async { + final context = LDContextBuilder().kind('user', 'user-key').build(); + final contextPersistenceKey = + sha256.convert(utf8.encode(context.canonicalKey)).toString(); + + final flagStore = FlagStore(); + final mockPersistence = MockPersistence(); + + mockPersistence.storage[sdkKeyPersistence] = { + contextPersistenceKey: '{"flagA":{' + '"version":1,' + '"value":"test",' + '"variation":0,' + '"reason":{"kind":"OFF"}' + '},' + '"flagB":{' + '"version":2,' + '"value":"test2",' + '"variation":1,' + '"reason":{"kind":"TARGET_MATCH"}' + '}}', + }; + + final flagPersistence = FlagPersistence( + persistence: mockPersistence, + updater: FlagUpdater(flagStore: flagStore, logger: logger), + store: flagStore, + sdkKey: sdkKey, + maxCachedContexts: 5, + logger: logger, + stamper: () => DateTime.fromMillisecondsSinceEpoch(0)); + + final cached = await flagPersistence.readCached(context); + + expect(cached, isNotNull); + expect(cached!.flags.keys, containsAll(['flagA', 'flagB'])); + expect(flagStore.getAll(), isEmpty, + reason: 'readCached must not apply to the store'); + }); + + test('readCached returns null on a cache miss', () async { + final context = LDContextBuilder().kind('user', 'user-key').build(); + final flagStore = FlagStore(); + final mockPersistence = MockPersistence(); + + final flagPersistence = FlagPersistence( + persistence: mockPersistence, + updater: FlagUpdater(flagStore: flagStore, logger: logger), + store: flagStore, + sdkKey: sdkKey, + maxCachedContexts: 5, + logger: logger, + stamper: () => DateTime.fromMillisecondsSinceEpoch(0)); + + expect(await flagPersistence.readCached(context), isNull); + }); + test('it can handle a corrupt cached flag payload', () async { final context = LDContextBuilder().kind('user', 'user-key').build(); final contextPersistenceKey = From 7f96be53dedaad4842d2f46ccb0e24f1f2a7d9c3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:00:37 -0700 Subject: [PATCH 3/5] refactor: reset the FDv2 basis at identify, not in the data source factory The held selector was discarded inside the data source factory by comparing context instance identity, which only held while the factory was invoked for every context change. Move the reset to the data manager's identify path, keyed on the context canonical key, so a context change clears the basis regardless of the active connection mode (including offline) and independent of factory invocation timing. The factory no longer tracks the context. --- .../lib/src/data_sources/data_manager.dart | 17 ++++- .../src/data_sources/fdv2/data_system.dart | 29 ++++---- .../lib/src/ld_common_client.dart | 14 ++-- .../test/data_sources/data_manager_test.dart | 71 +++++++++++++++++++ 4 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 packages/common_client/test/data_sources/data_manager_test.dart diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart index 56df0635..8ebcdee8 100644 --- a/packages/common_client/lib/src/data_sources/data_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -54,14 +54,29 @@ final class FDv1DataManager implements DataManager { /// first delivered payload, or -- when waiting for network results -- only /// on basis (network or terminal) data, so a cache load alone does not /// satisfy a wait-for-network identify. +/// +/// Identify is also where a held basis is discarded: a selector points at +/// one context's data, so on a context change [resetBasis] is invoked +/// before connecting. This is keyed on the context's canonical key and +/// driven here rather than inferred from the context instance inside the +/// data source factory, so it holds regardless of which connection mode is +/// active when the context changes (including offline). final class FDv2DataManager implements DataManager { final DataSourceManager _dataSourceManager; + final void Function() _resetBasis; + + String? _lastContextKey; - FDv2DataManager(this._dataSourceManager); + FDv2DataManager(this._dataSourceManager, this._resetBasis); @override Future identify(LDContext context, {required bool waitForNetworkResults}) { + final key = context.canonicalKey; + if (key != _lastContextKey) { + _lastContextKey = key; + _resetBasis(); + } final completer = Completer(); _dataSourceManager.identify(context, completer, requireFreshData: waitForNetworkResults); diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index ca599c9a..85ec2a2f 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -17,14 +17,16 @@ import 'source_factory_context.dart'; import 'source_manager.dart'; /// Composes the FDv2 data source factories consumed by the -/// DataSourceManager and owns the state that must outlive any single -/// orchestrator instance: the current selector and the context it -/// belongs to. +/// DataSourceManager and owns the selector, which must outlive any single +/// orchestrator instance. /// /// A fresh orchestrator is created per connection-mode switch and per /// identify. The selector survives mode switches (initializers are -/// skipped when a selector is held) but is reset whenever the context -/// changes, since a selector is specific to a single context. +/// skipped when a selector is held). It is specific to a single context, +/// so it must be reset on a context change; that is driven explicitly by +/// the data manager via [clearSelector] at identify time rather than +/// inferred here from the context instance, which depends on the factory +/// being invoked for every change. final class FDv2DataSystem { final String _credential; final LDLogger _logger; @@ -39,7 +41,6 @@ final class FDv2DataSystem { final HttpClientFactory? _httpClientFactory; Selector _selector = Selector.empty; - LDContext? _lastContext; FDv2DataSystem({ required DataSystemConfig config, @@ -70,6 +71,14 @@ final class FDv2DataSystem { ModeDefinition _resolve(ConnectionModeId mode, ModeDefinition builtIn) => _connectionModeOverrides[mode] ?? builtIn; + /// Discards the held selector so the next source rebuilds a basis from + /// its initializers. Called when identifying a new context, since a + /// selector points at one context's data and cannot seed a delta for + /// another. Mode switches keep the selector and so do not call this. + void clearSelector() { + _selector = Selector.empty; + } + /// Produces the factory map for the DataSourceManager. Offline is a /// real pipeline mode: its data source runs the cache initializer with /// no synchronizer, so the SDK serves cached flags while offline. The @@ -90,14 +99,6 @@ final class FDv2DataSystem { DataSourceFactory _factoryForMode(ModeDefinition modeDefinition) { return (LDContext context) { - if (!identical(context, _lastContext)) { - // A new identify produces a new decorated context instance; a - // mode switch re-uses the active one. The selector belongs to a - // single context and must not be reused across identifies. - _lastContext = context; - _selector = Selector.empty; - } - final factoryContext = SourceFactoryContext.fromClientConfig( context: context, credential: _credential, diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index 21032d97..cbd549b1 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -289,11 +289,6 @@ final class LDCommonClient { dataSourceEventHandler: dataSourceEventHandler, logger: _logger); - // FDv2 loads the cache through its pipeline; FDv1 loads it at identify. - _dataManager = _config.dataSystem != null - ? FDv2DataManager(_dataSourceManager) - : FDv1DataManager(_dataSourceManager, _flagManager); - if (_config.offline) { _dataSourceStatusManager.setOffline(); } @@ -447,12 +442,18 @@ final class LDCommonClient { cachedFlagsReader: _flagManager.readCached, ); _dataSourceManager.setFactories(dataSystem.buildFactories()); + // FDv2 loads the cache through its pipeline and resets the held + // basis on a context change (clearSelector). + _dataManager = + FDv2DataManager(_dataSourceManager, dataSystem.clearSelector); } else { _dataSourceManager.setFactories(_composeFactoriesForManager( fdv1Factories: _dataSourceFactories(_config, _logger, httpProperties), backgroundFactory: _backgroundFactory(_config, _logger, httpProperties), )); + // FDv1 loads the cache imperatively at identify. + _dataManager = FDv1DataManager(_dataSourceManager, _flagManager); } } else { DataSource nullSource(LDContext _) => NullDataSource(); @@ -461,6 +462,9 @@ final class LDCommonClient { const FDv2Polling(): nullSource, const FDv2Background(): nullSource, }); + // Fully offline serves cached flags directly at identify; the data + // manager is not exercised, but assign one so the field is set. + _dataManager = FDv1DataManager(_dataSourceManager, _flagManager); } } diff --git a/packages/common_client/test/data_sources/data_manager_test.dart b/packages/common_client/test/data_sources/data_manager_test.dart new file mode 100644 index 00000000..4703752f --- /dev/null +++ b/packages/common_client/test/data_sources/data_manager_test.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:launchdarkly_common_client/src/data_sources/data_manager.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_event_handler.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_manager.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_status_manager.dart'; +import 'package:launchdarkly_common_client/src/flag_manager/flag_manager.dart'; +import 'package:launchdarkly_common_client/src/offline_detail.dart'; +import 'package:launchdarkly_common_client/src/resolved_connection_mode.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; +import 'package:test/test.dart'; + +/// A data source manager with no factories. Its identify is a no-op +/// connection-wise (no factory builds a source), which is all these tests +/// need: they exercise the data manager's own logic, not the connection. +DataSourceManager _managerWithoutFactories() { + final logger = LDLogger(level: LDLogLevel.none); + final statusManager = DataSourceStatusManager(); + return DataSourceManager( + statusManager: statusManager, + dataSourceEventHandler: DataSourceEventHandler( + flagManager: FlagManager( + sdkKey: 'sdk-key', maxCachedContexts: 5, logger: logger), + statusManager: statusManager, + logger: logger), + logger: logger, + ); +} + +LDContext _ctx(String key) => LDContextBuilder().kind('user', key).build(); + +void main() { + group('FDv2DataManager', () { + test('resets the basis on a context change but not when it repeats', () { + var resets = 0; + final manager = + FDv2DataManager(_managerWithoutFactories(), () => resets++); + + // The returned futures never complete (no factory delivers data); we + // only care that the basis-reset decision fires correctly. + unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); + unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); + unawaited(manager.identify(_ctx('b'), waitForNetworkResults: false)); + unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); + + // a (first), b, a-again -> 3 resets. The repeated 'a' keeps its basis. + expect(resets, 3); + }); + + test( + 'resets the basis on a context change regardless of intervening mode ' + 'switches', () { + // The reset is driven at identify time, not by the data source factory, + // so a mode switch between identifies (e.g. going offline) cannot leave + // a stale basis behind for the next context. Mode switches go through + // DataSourceManager.setMode and never reach this manager, so they do not + // reset the basis themselves. + var resets = 0; + final dataSourceManager = _managerWithoutFactories(); + final manager = FDv2DataManager(dataSourceManager, () => resets++); + + unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); + dataSourceManager.setMode(const ResolvedOffline(OfflineSetOffline())); + unawaited(manager.identify(_ctx('b'), waitForNetworkResults: false)); + dataSourceManager.setMode(const ResolvedStreaming()); + + // Reset fired for 'a' and 'b'; the offline/online switches did not. + expect(resets, 2); + }); + }); +} From 03fa4eb547026716ed71bf0b802d1cec8633eea4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:40:12 -0700 Subject: [PATCH 4/5] test: verify the FDv2 mode override is selected at the resolution layer The override test asserted only that a DataSource was produced, which holds whether or not the override is honored. Assert that resolvedDefinition picks the override for the overridden mode and the built-in otherwise. Translating a definition's entries into concrete sources stays covered by the entry-factory tests. Adds a visibleForTesting accessor and the meta dependency it needs. --- .../src/data_sources/fdv2/data_system.dart | 43 +++++++++++++------ packages/common_client/pubspec.yaml | 1 + .../data_sources/fdv2/data_system_test.dart | 23 ++++++---- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index 85ec2a2f..f05917b6 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -1,5 +1,6 @@ import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' hide ServiceEndpoints; +import 'package:meta/meta.dart'; import '../../config/data_system_config.dart'; import '../../config/service_endpoints.dart'; @@ -66,10 +67,30 @@ final class FDv2DataSystem { _httpClientFactory = httpClientFactory, _connectionModeOverrides = config.connectionModes; - /// The definition for a built-in mode: the user's override if one was - /// given for it, otherwise the built-in default. - ModeDefinition _resolve(ConnectionModeId mode, ModeDefinition builtIn) => - _connectionModeOverrides[mode] ?? builtIn; + /// The built-in definition for each connection mode, before any override. + static const Map _builtInDefinitions = { + ConnectionModeId.streaming: BuiltInModes.streaming, + ConnectionModeId.polling: BuiltInModes.polling, + ConnectionModeId.background: BuiltInModes.background, + ConnectionModeId.offline: BuiltInModes.offline, + }; + + /// The definition for [mode]: the user's override if one was given for + /// it, otherwise the built-in default. + ModeDefinition _resolve(ConnectionModeId mode) { + if (_builtInDefinitions[mode] case final builtIn?) { + return _connectionModeOverrides[mode] ?? builtIn; + } + // Unreachable: ConnectionModeId is sealed over the built-in modes, each + // of which has an entry above. + throw StateError('No built-in definition for connection mode: $mode'); + } + + /// The resolved definition for [mode], exposed so tests can confirm that + /// an override is selected over the built-in. How a definition's entries + /// become concrete data sources is covered by the entry-factory tests. + @visibleForTesting + ModeDefinition resolvedDefinition(ConnectionModeId mode) => _resolve(mode); /// Discards the held selector so the next source rebuilds a basis from /// its initializers. Called when identifying a new context, since a @@ -86,14 +107,12 @@ final class FDv2DataSystem { /// payload does not drive the status to valid. Map buildFactories() { return { - const FDv2Streaming(): _factoryForMode( - _resolve(ConnectionModeId.streaming, BuiltInModes.streaming)), - const FDv2Polling(): _factoryForMode( - _resolve(ConnectionModeId.polling, BuiltInModes.polling)), - const FDv2Background(): _factoryForMode( - _resolve(ConnectionModeId.background, BuiltInModes.background)), - const FDv2Offline(): _factoryForMode( - _resolve(ConnectionModeId.offline, BuiltInModes.offline)), + const FDv2Streaming(): + _factoryForMode(_resolve(ConnectionModeId.streaming)), + const FDv2Polling(): _factoryForMode(_resolve(ConnectionModeId.polling)), + const FDv2Background(): + _factoryForMode(_resolve(ConnectionModeId.background)), + const FDv2Offline(): _factoryForMode(_resolve(ConnectionModeId.offline)), }; } diff --git a/packages/common_client/pubspec.yaml b/packages/common_client/pubspec.yaml index 1242bf51..a332af05 100644 --- a/packages/common_client/pubspec.yaml +++ b/packages/common_client/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: launchdarkly_dart_common: 1.8.1 launchdarkly_event_source_client: 2.2.0 crypto: ^3.0.3 + meta: ^1.16.0 uuid: ">= 3.0.7 <5.0.0" dev_dependencies: diff --git a/packages/common_client/test/data_sources/fdv2/data_system_test.dart b/packages/common_client/test/data_sources/fdv2/data_system_test.dart index 0348a2e7..3f3981c4 100644 --- a/packages/common_client/test/data_sources/fdv2/data_system_test.dart +++ b/packages/common_client/test/data_sources/fdv2/data_system_test.dart @@ -59,17 +59,24 @@ void main() { second.stop(); }); - test('an override replaces a built-in mode definition', () { - // Override streaming with the polling definition; the streaming - // factory should still build a usable data source from it. - final factory = makeDataSystem( + test('an override is selected over the built-in for that mode', () { + // The data system's job here is resolution: the overridden mode uses + // the override definition, others keep their built-in. Translating a + // definition's entries into concrete sources (e.g. that the polling + // definition yields a polling source) is covered by entry_factories. + final dataSystem = makeDataSystem( config: const DataSystemConfig(connectionModes: { ConnectionModeId.streaming: BuiltInModes.polling, - })).buildFactories()[const FDv2Streaming()]!; + })); - final source = factory(_context()); - expect(source, isA()); - source.stop(); + expect(dataSystem.resolvedDefinition(ConnectionModeId.streaming), + same(BuiltInModes.polling), + reason: 'the override replaces the built-in streaming definition'); + expect(dataSystem.resolvedDefinition(ConnectionModeId.polling), + same(BuiltInModes.polling), + reason: 'an un-overridden mode keeps its built-in'); + expect(dataSystem.resolvedDefinition(ConnectionModeId.offline), + same(BuiltInModes.offline)); }); test('the override map is keyed only by built-in modes', () { From beda26363215fa3dd8aaf9b378671b8d5e366eb7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:12:49 -0700 Subject: [PATCH 5/5] fix: lower the meta constraint to 1.12.0 for the older Flutter toolchain Flutter 3.22.3 pins meta to 1.12.0 through its SDK dependency (via connectivity_plus), so a ^1.16.0 floor broke workspace version solving on that toolchain while passing on the newer one. visibleForTesting has been in meta since well before 1.12.0. --- packages/common_client/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common_client/pubspec.yaml b/packages/common_client/pubspec.yaml index a332af05..7efc6d8b 100644 --- a/packages/common_client/pubspec.yaml +++ b/packages/common_client/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: launchdarkly_dart_common: 1.8.1 launchdarkly_event_source_client: 2.2.0 crypto: ^3.0.3 - meta: ^1.16.0 + meta: ^1.12.0 uuid: ">= 3.0.7 <5.0.0" dev_dependencies: