diff --git a/CHANGELOG.md b/CHANGELOG.md index fec632c..3081824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [5.1.0-beta.1] - 2026-04-11 + +### Added + +- LocalStorage provider — writable configuration backed by persistent storage +- `FromLocalStorage()` fluent API for rule setup (file-based by default) +- `FromLocalStorage(IStorageBackend)` for custom storage backends +- `FromLocalStorage(Func)` config-aware factory overload with dynamic backend swapping at runtime +- `ILocalStorage` write interface in `Cocoar.Configuration.Abstractions` (registered as Singleton via DI) +- `IStorageBackend` abstraction for pluggable persistence (file, database, etc.) +- `FileStorageBackend` default implementation with atomic write-temp-then-rename pattern +- Documentation: LocalStorage provider guide with custom backend examples (Marten, SQLite) + ## [5.0.0] - 2026-03-24 ### Added diff --git a/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs new file mode 100644 index 0000000..6097407 --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs @@ -0,0 +1,38 @@ +namespace Cocoar.Configuration.LocalStorage; + +/// +/// Provides read and write access to the local storage backing a configuration type. +/// Inject via DI to read or write configuration at runtime. +/// Writing triggers a recompute of the configuration pipeline, updating +/// IReactiveConfig<T> for all consumers. +/// +/// The configuration type. +public interface ILocalStorage where T : class +{ + /// + /// Reads the current value from storage. Returns null if nothing + /// has been persisted yet. + /// + /// + /// This returns the raw stored value — not the merged pipeline result. + /// Use IReactiveConfig<T>.CurrentValue for the final merged configuration. + /// + Task ReadAsync(CancellationToken ct = default); + + /// + /// Serializes the value to UTF-8 JSON bytes, persists it to storage, + /// and signals the configuration system to recompute. + /// + Task WriteAsync(T value, CancellationToken ct = default); + + /// + /// Atomically reads the current value, applies the update, and writes it back. + /// The entire operation runs under an exclusive lock — concurrent updates are + /// serialized and each sees the previous update's result. + /// + /// + /// If nothing has been stored yet, the update receives a default-constructed . + /// Mutate the properties you want to change; everything else is preserved. + /// + Task UpdateAsync(Action update, CancellationToken ct = default); +} diff --git a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs index 79cc371..8303c62 100644 --- a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs +++ b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs @@ -1,6 +1,7 @@ using Cocoar.Configuration.Core; using Cocoar.Configuration.Flags; using Cocoar.Configuration.Flags.Internal; +using Cocoar.Configuration.Providers.Abstractions; using Cocoar.Configuration.Reactive; using Microsoft.Extensions.DependencyInjection; @@ -31,6 +32,7 @@ public static void Emit( EmitFlagsServices(services, configManager); EmitEntitlementsServices(services, configManager); + EmitProviderContributedServices(services, configManager); } private static void EmitFlagsServices(IServiceCollection services, ConfigManager configManager) @@ -155,4 +157,30 @@ private static void EmitReactiveService(IServiceCollection services, Type servic return method.Invoke(mgr, null)!; }); } + + /// + /// Discovers provider options that implement + /// and registers their contributed services. Last rule wins per service type. + /// + private static void EmitProviderContributedServices(IServiceCollection services, ConfigManager configManager) + { + var registrations = new Dictionary(); + + foreach (var rule in configManager.Rules) + { + if (rule.ResolveProviderOptions(configManager) is not IProviderServiceRegistration contributor) + continue; + + foreach (var (serviceType, implementation) in contributor.GetServiceRegistrations(rule.ConcreteType)) + { + // Later rules overwrite earlier ones — matches pipeline merge order + registrations[serviceType] = implementation; + } + } + + foreach (var (serviceType, implementation) in registrations) + { + services.AddSingleton(serviceType, implementation); + } + } } diff --git a/src/Cocoar.Configuration/Properties/AssemblyInfo.cs b/src/Cocoar.Configuration/Properties/AssemblyInfo.cs index 25e7aac..0de3212 100644 --- a/src/Cocoar.Configuration/Properties/AssemblyInfo.cs +++ b/src/Cocoar.Configuration/Properties/AssemblyInfo.cs @@ -9,6 +9,7 @@ [assembly: InternalsVisibleTo("Cocoar.Configuration.Secrets.Tests")] [assembly: InternalsVisibleTo("Cocoar.Configuration.AspNetCore")] [assembly: InternalsVisibleTo("Cocoar.Configuration.Http")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.Providers.Tests")] // Type forwarding for types moved to Cocoar.Configuration.Abstractions [assembly: TypeForwardedTo(typeof(IConfigurationAccessor))] diff --git a/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs b/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs new file mode 100644 index 0000000..dc6b8e3 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs @@ -0,0 +1,19 @@ +namespace Cocoar.Configuration.Providers.Abstractions; + +/// +/// Implemented by provider options that need additional services registered in DI +/// beyond the standard config type and IReactiveConfig<T>. +/// +/// The DI emitter discovers this interface by scanning resolved provider options +/// for all rules. No hardcoded provider knowledge is needed in the emitter. +/// +/// +public interface IProviderServiceRegistration +{ + /// + /// Returns additional (serviceType, singletonInstance) pairs to register in DI. + /// Called once during DI setup — not on every recompute. + /// + /// The configuration type this rule targets (e.g., typeof(AppSettings)). + IEnumerable<(Type ServiceType, object Implementation)> GetServiceRegistrations(Type concreteType); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs new file mode 100644 index 0000000..5a3ec33 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs @@ -0,0 +1,42 @@ +namespace Cocoar.Configuration.Providers; + +/// +/// File-based storage backend using atomic write-temp-then-rename pattern. +/// Default directory: {AppContext.BaseDirectory}/.cocoar/localStorage/ +/// +public sealed class FileStorageBackend : IStorageBackend +{ + private readonly string _directory; + + public FileStorageBackend(string? directory = null) + { + _directory = directory + ?? Path.Combine(AppContext.BaseDirectory, ".cocoar", "localStorage"); + } + + public async Task ReadAsync(string key, CancellationToken ct = default) + { + var path = GetFilePath(key); + if (!File.Exists(path)) + return null; + + return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); + } + + public async Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + Directory.CreateDirectory(_directory); + + var path = GetFilePath(key); + var tempPath = path + ".tmp"; + + await File.WriteAllBytesAsync(tempPath, data, ct).ConfigureAwait(false); + File.Move(tempPath, path, overwrite: true); + } + + private string GetFilePath(string key) + { + var safeKey = string.Join("_", key.Split(Path.GetInvalidFileNameChars())); + return Path.Combine(_directory, safeKey + ".json"); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs new file mode 100644 index 0000000..278f5e8 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs @@ -0,0 +1,19 @@ +namespace Cocoar.Configuration.Providers; + +/// +/// Abstraction for the persistence layer used by LocalStorageProvider. +/// Default implementation is file-based; can be replaced with SQLite, Marten, etc. +/// +public interface IStorageBackend +{ + /// + /// Reads raw UTF-8 JSON bytes for the given key. + /// Returns null if no data has been persisted yet. + /// + Task ReadAsync(string key, CancellationToken ct = default); + + /// + /// Writes raw UTF-8 JSON bytes atomically for the given key. + /// + Task WriteAsync(string key, byte[] data, CancellationToken ct = default); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs new file mode 100644 index 0000000..3f5f62f --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Cocoar.Configuration.LocalStorage; + +namespace Cocoar.Configuration.Providers; + +/// +/// Adapts the untyped to the typed interface. +/// Handles serialization from T to UTF-8 JSON bytes. +/// +public sealed class LocalStorageAdapter(LocalStorageStore store) : ILocalStorage where T : class +{ + public async Task ReadAsync(CancellationToken ct = default) + { + var bytes = await store.ReadBytesAsync(ct).ConfigureAwait(false); + + // ReadBytesAsync returns "{}" when nothing is persisted. + // Deserializing "{}" gives a default-constructed T, but we want null + // to clearly signal "nothing stored yet". + if (bytes.Length <= 2) + return null; + + return JsonSerializer.Deserialize(bytes); + } + + public async Task WriteAsync(T value, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(value); + var bytes = JsonSerializer.SerializeToUtf8Bytes(value); + await store.WriteBytesAsync(bytes, ct).ConfigureAwait(false); + } + + public async Task UpdateAsync(Action update, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(update); + await store.UpdateBytesAsync(currentBytes => + { + var current = JsonSerializer.Deserialize(currentBytes) ?? throw new InvalidOperationException( + $"Failed to deserialize {typeof(T).Name} from stored bytes."); + update(current); + return JsonSerializer.SerializeToUtf8Bytes(current); + }, ct).ConfigureAwait(false); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs new file mode 100644 index 0000000..0d940b2 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs @@ -0,0 +1,23 @@ +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +/// +/// Provider that reads from a . +/// The store is shared state owned by the closure in FromLocalStorage() — +/// the provider does NOT own or dispose it. +/// +public sealed class LocalStorageProvider(LocalStorageProviderOptions options) + : ConfigurationProvider(options) +{ + public override async Task FetchConfigurationBytesAsync( + LocalStorageProviderQueryOptions query, CancellationToken ct = default) + { + return await ProviderOptions.Store.ReadBytesAsync(ct).ConfigureAwait(false); + } + + public override IObservable ChangesAsBytes(LocalStorageProviderQueryOptions query) + { + return ProviderOptions.Store.Changes; + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs new file mode 100644 index 0000000..4ecc334 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs @@ -0,0 +1,23 @@ +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class LocalStorageProviderOptions(LocalStorageStore store) + : IProviderConfiguration, IProviderServiceRegistration +{ + public LocalStorageStore Store { get; } = store ?? throw new ArgumentNullException(nameof(store)); + + /// + /// Returns null to indicate non-reusable. Each LocalStorage rule gets its own provider instance + /// because each is backed by a unique tied to a specific configuration type. + /// + public string? GenerateProviderKey() => null; + + public IEnumerable<(Type ServiceType, object Implementation)> GetServiceRegistrations(Type concreteType) + { + var interfaceType = typeof(ILocalStorage<>).MakeGenericType(concreteType); + var implType = typeof(LocalStorageAdapter<>).MakeGenericType(concreteType); + yield return (interfaceType, Activator.CreateInstance(implType, Store)!); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs new file mode 100644 index 0000000..1fc5160 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs @@ -0,0 +1,8 @@ +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class LocalStorageProviderQueryOptions : IProviderQuery +{ + public static readonly LocalStorageProviderQueryOptions Default = new(); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs new file mode 100644 index 0000000..22851a3 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs @@ -0,0 +1,87 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Providers; + +public static class LocalStorageRulesExtensions +{ + /// + /// Creates a local-storage-backed configuration rule. + /// Reads from and writes to persistent storage. By default uses file-based storage + /// at {AppContext.BaseDirectory}/.cocoar/localStorage/. + /// + /// + /// + /// Use (via DI) to write configuration at runtime. + /// Writes trigger a recompute of the configuration pipeline. + /// + /// + /// Position this rule in the pipeline to control priority: later rules override earlier ones. + /// + /// + /// The typed provider builder. + /// Optional custom storage backend. Defaults to . + public static ProviderRuleBuilder + FromLocalStorage(this TypedProviderBuilder builder, IStorageBackend? backend = null) + where T : class + { + var effectiveBackend = backend ?? new FileStorageBackend(); + var storageKey = typeof(T).FullName ?? typeof(T).Name; + var store = new LocalStorageStore(effectiveBackend, storageKey) + { + ConfigurationType = typeof(T) + }; + + return new( + _ => new LocalStorageProviderOptions(store), + _ => LocalStorageProviderQueryOptions.Default, + typeof(T) + ); + } + + /// + /// Creates a local-storage-backed configuration rule using a factory that receives the current + /// configuration state and the current backend. Use this when the storage backend depends on + /// values from earlier rules (e.g., a connection string for a database-backed backend). + /// + /// + /// The factory is called on every recompute. The second parameter (currentBackend) is + /// the backend currently in use (null on the first call). Return it unchanged to skip + /// creating a new instance when nothing relevant changed — this avoids unnecessary + /// connection pool churn for database backends. + /// + /// The typed provider builder. + /// A factory that receives the current + /// and the current (null on first call), and returns the backend to use. + public static ProviderRuleBuilder + FromLocalStorage(this TypedProviderBuilder builder, Func backendFactory) + where T : class + { + ArgumentNullException.ThrowIfNull(backendFactory); + + LocalStorageStore? store = null; + var storageKey = typeof(T).FullName ?? typeof(T).Name; + + return new( + accessor => + { + var currentBackend = store?.Backend; + var backend = backendFactory(accessor, currentBackend); + if (store is null) + { + store = new LocalStorageStore(backend, storageKey) + { + ConfigurationType = typeof(T) + }; + } + else if (!ReferenceEquals(backend, currentBackend)) + { + store.ReplaceBackend(backend); + } + return new LocalStorageProviderOptions(store); + }, + _ => LocalStorageProviderQueryOptions.Default, + typeof(T) + ); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs new file mode 100644 index 0000000..c5c4ea4 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs @@ -0,0 +1,108 @@ +using Cocoar.Configuration.Reactive.Internal; + +namespace Cocoar.Configuration.Providers; + +/// +/// Shared state object that bridges the provider (read path) and ILocalStorage<T> (write path). +/// Created once in FromLocalStorage() and captured by both the provider options closure and DI registration. +/// +public sealed class LocalStorageStore : IDisposable +{ + private volatile IStorageBackend _backend; + private readonly string _storageKey; + private readonly SimpleSubject _changeSubject = new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public LocalStorageStore(IStorageBackend backend, string storageKey) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); + + _backend = backend; + _storageKey = storageKey; + } + + /// + /// The current storage backend. Exposed so the config-aware factory can + /// pass it back to the user for comparison/reuse decisions. + /// + internal IStorageBackend Backend => _backend; + + /// + /// Replaces the storage backend. Called during recompute when config-aware + /// factory produces a new backend (e.g., connection string changed). + /// The store instance stays the same — DI references remain valid. + /// + internal void ReplaceBackend(IStorageBackend backend) + { + ArgumentNullException.ThrowIfNull(backend); + _backend = backend; + } + + /// + /// The configuration type this store is associated with. + /// Used by DI registration to match ILocalStorage<T> to the correct store. + /// + public Type ConfigurationType { get; init; } = null!; + + /// + /// Observable that fires when new bytes are written. Subscribed to by the provider. + /// + internal IObservable Changes => _changeSubject; + + /// + /// Reads current bytes from storage. Returns empty JSON object if nothing persisted yet. + /// + internal async Task ReadBytesAsync(CancellationToken ct = default) + { + var data = await _backend.ReadAsync(_storageKey, ct).ConfigureAwait(false); + return data ?? "{}"u8.ToArray(); + } + + /// + /// Writes bytes to storage and signals the change subject. + /// Thread-safe via SemaphoreSlim. + /// + public async Task WriteBytesAsync(byte[] data, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(data); + + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await _backend.WriteAsync(_storageKey, data, ct).ConfigureAwait(false); + _changeSubject.OnNext(data); + } + finally + { + _writeLock.Release(); + } + } + + /// + /// Atomically reads current bytes, applies a transform, and writes the result. + /// The entire read-transform-write cycle runs under the write lock. + /// + internal async Task UpdateBytesAsync(Func transform, CancellationToken ct = default) + { + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var current = await _backend.ReadAsync(_storageKey, ct).ConfigureAwait(false) + ?? "{}"u8.ToArray(); + var updated = transform(current); + await _backend.WriteAsync(_storageKey, updated, ct).ConfigureAwait(false); + _changeSubject.OnNext(updated); + } + finally + { + _writeLock.Release(); + } + } + + public void Dispose() + { + _writeLock.Dispose(); + _changeSubject.Dispose(); + } +} diff --git a/src/tests/Cocoar.Configuration.DI.Tests/LocalStorageDITests.cs b/src/tests/Cocoar.Configuration.DI.Tests/LocalStorageDITests.cs new file mode 100644 index 0000000..d4a1a50 --- /dev/null +++ b/src/tests/Cocoar.Configuration.DI.Tests/LocalStorageDITests.cs @@ -0,0 +1,339 @@ +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Reactive; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.DI.Tests; + +[Trait("Type", "Unit")] +[Trait("Component", "DI")] +public class LocalStorageDITests : IDisposable +{ + private sealed class AppSettings + { + public string? AppName { get; set; } + public bool FeatureEnabled { get; set; } + } + + private sealed class DbSettings + { + public string? ConnectionString { get; set; } + public int Timeout { get; set; } + } + + private readonly string _testDir; + + public LocalStorageDITests() + { + _testDir = Path.Combine(Path.GetTempPath(), "cocoar_di_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + } + + private IStorageBackend CreateBackend() => new FileStorageBackend(_testDir); + + [Fact] + public void ILocalStorage_IsRegistered_WhenFromLocalStorageUsed() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ])); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetService>(); + + Assert.NotNull(localStorage); + } + + [Fact] + public void ILocalStorage_IsNotRegistered_WhenNotUsed() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"AppName":"Test"}""") + ])); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetService>(); + + Assert.Null(localStorage); + } + + [Fact] + public void MultipleTypes_EachGetOwnLocalStorage() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()), + rules.For().FromLocalStorage(CreateBackend()) + ])); + + var sp = services.BuildServiceProvider(); + var appStorage = sp.GetService>(); + var dbStorage = sp.GetService>(); + + Assert.NotNull(appStorage); + Assert.NotNull(dbStorage); + Assert.NotSame(appStorage, dbStorage); + } + + [Fact] + public async Task WriteViaLocalStorage_UpdatesReactiveConfig() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ]) + .UseDebounce(50)); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetRequiredService>(); + var reactiveConfig = sp.GetRequiredService>(); + + // Initial: defaults + Assert.Null(reactiveConfig.CurrentValue.AppName); + + // Write + await localStorage.WriteAsync(new AppSettings + { + AppName = "MyApp", + FeatureEnabled = true + }); + + // Wait for recompute + await WaitUntilAsync( + () => reactiveConfig.CurrentValue.AppName == "MyApp", + description: "reactive config to update after write"); + + Assert.Equal("MyApp", reactiveConfig.CurrentValue.AppName); + Assert.True(reactiveConfig.CurrentValue.FeatureEnabled); + } + + [Fact] + public async Task WriteViaLocalStorage_PersistsAcrossResolves() + { + var backend = CreateBackend(); + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(backend) + ]) + .UseDebounce(50)); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetRequiredService>(); + + await localStorage.WriteAsync(new AppSettings { AppName = "Persisted" }); + + // Wait for recompute + var reactiveConfig = sp.GetRequiredService>(); + await WaitUntilAsync( + () => reactiveConfig.CurrentValue.AppName == "Persisted", + description: "config to persist"); + + // Verify file exists in backend + var stored = await backend.ReadAsync(typeof(AppSettings).FullName!); + Assert.NotNull(stored); + var deserialized = JsonSerializer.Deserialize(stored); + Assert.Equal("Persisted", deserialized?.AppName); + } + + [Fact] + public async Task ReadAsync_NothingStored_ReturnsNull() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ])); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetRequiredService>(); + + var result = await localStorage.ReadAsync(); + Assert.Null(result); + } + + [Fact] + public async Task ReadAsync_AfterWrite_ReturnsStoredValue() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ]) + .UseDebounce(50)); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetRequiredService>(); + + await localStorage.WriteAsync(new AppSettings { AppName = "Stored", FeatureEnabled = true }); + + var result = await localStorage.ReadAsync(); + Assert.NotNull(result); + Assert.Equal("Stored", result.AppName); + Assert.True(result.FeatureEnabled); + } + + [Fact] + public async Task ReadAsync_ReturnRawStoreValue_NotMergedPipeline() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + // File provides both AppName and FeatureEnabled + rules.For().FromStaticJson("""{"AppName":"FromFile","FeatureEnabled":true}"""), + // LocalStorage only overrides AppName + rules.For().FromLocalStorage(CreateBackend()), + ]) + .UseDebounce(50)); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetRequiredService>(); + var reactiveConfig = sp.GetRequiredService>(); + + await localStorage.WriteAsync(new AppSettings { AppName = "Override" }); + + await WaitUntilAsync( + () => reactiveConfig.CurrentValue.AppName == "Override", + description: "reactive config to update"); + + // Merged pipeline: LocalStorage wins (last-rule-wins) for all properties it sets. + // Since WriteAsync serialized the full object, FeatureEnabled=false overwrites the static true. + Assert.Equal("Override", reactiveConfig.CurrentValue.AppName); + Assert.False(reactiveConfig.CurrentValue.FeatureEnabled); + + // Raw store: exactly what was written + var stored = await localStorage.ReadAsync(); + Assert.NotNull(stored); + Assert.Equal("Override", stored.AppName); + Assert.False(stored.FeatureEnabled); + + // ReadAsync returns the raw store value, NOT the merged pipeline. + // The static rule's FeatureEnabled=true is only visible via IReactiveConfig + // when LocalStorage hasn't overridden it. + } + + [Fact] + public async Task UpdateAsync_ModifiesSingleProperty() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ]) + .UseDebounce(50)); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetRequiredService>(); + + // Write initial state + await localStorage.WriteAsync(new AppSettings { AppName = "MyApp", FeatureEnabled = false }); + + // Update only one property + await localStorage.UpdateAsync(s => s.FeatureEnabled = true); + + var stored = await localStorage.ReadAsync(); + Assert.NotNull(stored); + Assert.Equal("MyApp", stored.AppName); // Preserved + Assert.True(stored.FeatureEnabled); // Updated + } + + [Fact] + public async Task UpdateAsync_ConcurrentUpdates_AreSerialized() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ]) + .UseDebounce(50)); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetRequiredService>(); + + // Write initial + await localStorage.WriteAsync(new AppSettings { AppName = "Start" }); + + // 10 concurrent updates each append to AppName + var tasks = Enumerable.Range(0, 10) + .Select(i => localStorage.UpdateAsync(s => s.AppName += $"_{i}")) + .ToArray(); + + await Task.WhenAll(tasks); + + var result = await localStorage.ReadAsync(); + Assert.NotNull(result); + + // All 10 updates should be present (order may vary but all appended) + for (var i = 0; i < 10; i++) + Assert.Contains($"_{i}", result.AppName); + } + + [Fact] + public async Task UpdateAsync_NothingStored_StartsFromDefaults() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ]) + .UseDebounce(50)); + + var sp = services.BuildServiceProvider(); + var localStorage = sp.GetRequiredService>(); + + // Update without prior write — starts from default-constructed AppSettings + await localStorage.UpdateAsync(s => s.AppName = "FromUpdate"); + + var stored = await localStorage.ReadAsync(); + Assert.NotNull(stored); + Assert.Equal("FromUpdate", stored.AppName); + Assert.False(stored.FeatureEnabled); // Default + } + + [Fact] + public void ILocalStorage_IsSingleton() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ])); + + var sp = services.BuildServiceProvider(); + var instance1 = sp.GetRequiredService>(); + var instance2 = sp.GetRequiredService>(); + + Assert.Same(instance1, instance2); + } + + private static async Task WaitUntilAsync( + Func condition, + TimeSpan timeout = default, + string description = "condition") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + try { if (condition()) return; } catch { } + await Task.Delay(50); + } + throw new TimeoutException($"Timeout waiting for {description} after {timeout}"); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* best effort cleanup */ } + } +} diff --git a/src/tests/Cocoar.Configuration.DI.Tests/ProviderServiceRegistrationTests.cs b/src/tests/Cocoar.Configuration.DI.Tests/ProviderServiceRegistrationTests.cs new file mode 100644 index 0000000..f7e823e --- /dev/null +++ b/src/tests/Cocoar.Configuration.DI.Tests/ProviderServiceRegistrationTests.cs @@ -0,0 +1,128 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.Configuration.Rules; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.DI.Tests; + +/// +/// Proves that the DI emitter discovers +/// generically — without hardcoded knowledge of any specific provider. +/// A completely custom provider can contribute its own DI services. +/// +[Trait("Type", "Unit")] +[Trait("Component", "DI")] +public class ProviderServiceRegistrationTests +{ + private sealed class AppConfig { public string? Name { get; set; } } + + // A custom service that the provider wants to register in DI + public interface ICustomService { string Greeting { get; } } + + private sealed class CustomServiceImpl(string greeting) : ICustomService + { + public string Greeting { get; } = greeting; + } + + // Provider options that contribute a custom service via the generic interface + private sealed class CustomProviderOptions(string greeting) + : IProviderConfiguration, IProviderServiceRegistration + { + public string? GenerateProviderKey() => null; + + public IEnumerable<(Type ServiceType, object Implementation)> GetServiceRegistrations(Type concreteType) + { + var serviceType = typeof(ICustomService<>).MakeGenericType(concreteType); + var implType = typeof(CustomServiceImpl<>).MakeGenericType(concreteType); + yield return (serviceType, Activator.CreateInstance(implType, greeting)!); + } + } + + private sealed class CustomProviderQuery : IProviderQuery + { + public static readonly CustomProviderQuery Default = new(); + } + + // Minimal provider — returns empty JSON + private sealed class CustomProvider(CustomProviderOptions options) + : ConfigurationProvider(options) + { + public override Task FetchConfigurationBytesAsync( + CustomProviderQuery query, CancellationToken ct = default) + => Task.FromResult("{}"u8.ToArray()); + + public override IObservable ChangesAsBytes(CustomProviderQuery query) + => new NeverObservable(); + + private sealed class NeverObservable : IObservable + { + public IDisposable Subscribe(IObserver observer) => new Noop(); + private sealed class Noop : IDisposable { public void Dispose() { } } + } + } + + [Fact] + public void CustomProvider_WithServiceRegistration_IsDiscoveredByEmitter() + { + var services = new ServiceCollection(); + var rule = new ConfigRule( + typeof(CustomProvider), + new CustomProviderOptions("Hello from custom provider"), + CustomProviderQuery.Default, + typeof(AppConfig)); + + services.AddCocoarConfiguration(c => c.UseConfiguration([rule])); + + var sp = services.BuildServiceProvider(); + var customService = sp.GetService>(); + + Assert.NotNull(customService); + Assert.Equal("Hello from custom provider", customService.Greeting); + } + + [Fact] + public void NoProviderServiceRegistration_NothingExtraRegistered() + { + // StaticJson doesn't implement IProviderServiceRegistration + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromStaticJson("""{"Name":"test"}""") + ])); + + var sp = services.BuildServiceProvider(); + + // ICustomService is not registered — only config + reactive + Assert.Null(sp.GetService>()); + Assert.NotNull(sp.GetService()); + } + + [Fact] + public void MultipleRules_LastRegistrationWins() + { + var services = new ServiceCollection(); + + var rule1 = new ConfigRule( + typeof(CustomProvider), + new CustomProviderOptions("First"), + CustomProviderQuery.Default, + typeof(AppConfig)); + + var rule2 = new ConfigRule( + typeof(CustomProvider), + new CustomProviderOptions("Second"), + CustomProviderQuery.Default, + typeof(AppConfig)); + + services.AddCocoarConfiguration(c => c.UseConfiguration([rule1, rule2])); + + var sp = services.BuildServiceProvider(); + var customService = sp.GetRequiredService>(); + + Assert.Equal("Second", customService.Greeting); // Last rule wins + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs new file mode 100644 index 0000000..2b6dcb9 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs @@ -0,0 +1,100 @@ +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +[Trait("Provider", "LocalStorageProvider")] +public class FileStorageBackendTests : IDisposable +{ + private readonly string _testDir; + + public FileStorageBackendTests() + { + _testDir = Path.Combine(Path.GetTempPath(), "cocoar_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + } + + [Fact] + public async Task ReadAsync_MissingKey_ReturnsNull() + { + var backend = new FileStorageBackend(_testDir); + var result = await backend.ReadAsync("nonexistent"); + Assert.Null(result); + } + + [Fact] + public async Task WriteAsync_ThenReadAsync_Roundtrip() + { + var backend = new FileStorageBackend(_testDir); + var data = """{"Name":"Test","Value":42}"""u8.ToArray(); + + await backend.WriteAsync("myKey", data); + var result = await backend.ReadAsync("myKey"); + + Assert.NotNull(result); + Assert.Equal(data, result); + } + + [Fact] + public async Task WriteAsync_OverwritesExistingData() + { + var backend = new FileStorageBackend(_testDir); + var data1 = """{"Version":1}"""u8.ToArray(); + var data2 = """{"Version":2}"""u8.ToArray(); + + await backend.WriteAsync("key", data1); + await backend.WriteAsync("key", data2); + var result = await backend.ReadAsync("key"); + + Assert.Equal(data2, result); + } + + [Fact] + public async Task WriteAsync_CreatesDirectoryIfMissing() + { + var nestedDir = Path.Combine(_testDir, "sub", "dir"); + var backend = new FileStorageBackend(nestedDir); + var data = "{}"u8.ToArray(); + + await backend.WriteAsync("key", data); + + Assert.True(Directory.Exists(nestedDir)); + var result = await backend.ReadAsync("key"); + Assert.Equal(data, result); + } + + [Fact] + public async Task WriteAsync_SanitizesKey_PreventPathTraversal() + { + var backend = new FileStorageBackend(_testDir); + var data = """{"safe":true}"""u8.ToArray(); + var dangerousKey = "..\\..\\etc\\passwd"; + + await backend.WriteAsync(dangerousKey, data); + + // File should be inside _testDir, not escaped + var files = Directory.GetFiles(_testDir, "*.json"); + Assert.Single(files); + Assert.StartsWith(_testDir, files[0]); + } + + [Fact] + public async Task MultipleKeys_IndependentStorage() + { + var backend = new FileStorageBackend(_testDir); + var data1 = """{"Type":"A"}"""u8.ToArray(); + var data2 = """{"Type":"B"}"""u8.ToArray(); + + await backend.WriteAsync("keyA", data1); + await backend.WriteAsync("keyB", data2); + + Assert.Equal(data1, await backend.ReadAsync("keyA")); + Assert.Equal(data2, await backend.ReadAsync("keyB")); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* best effort cleanup */ } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageProviderTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageProviderTests.cs new file mode 100644 index 0000000..981b5c5 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageProviderTests.cs @@ -0,0 +1,241 @@ +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Rules; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +[Trait("Provider", "LocalStorageProvider")] +public class LocalStorageProviderTests : IDisposable +{ + private sealed class AppConfig + { + public string? Name { get; set; } + public int Value { get; set; } + } + + private readonly string _testDir; + + public LocalStorageProviderTests() + { + _testDir = Path.Combine(Path.GetTempPath(), "cocoar_provider_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + } + + private IStorageBackend CreateBackend() => new FileStorageBackend(_testDir); + + [Fact] + public void FromLocalStorage_CreatesValidRule() + { + var rulesBuilder = new RulesBuilder(); + ConfigRule rule = rulesBuilder.For().FromLocalStorage(CreateBackend()); + + Assert.Equal(typeof(LocalStorageProvider), rule.ProviderType); + Assert.Equal(typeof(AppConfig), rule.ConcreteType); + } + + [Fact] + public void ConfigManager_WithLocalStorage_InitializesSuccessfully() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(CreateBackend()) + ])); + + var config = manager.GetConfig(); + Assert.NotNull(config); + // Default values since nothing written yet + Assert.Null(config.Name); + Assert.Equal(0, config.Value); + } + + [Fact] + public async Task ConfigManager_WithLocalStorage_LoadsPersistedData() + { + var backend = CreateBackend(); + // Pre-persist data + await backend.WriteAsync( + typeof(AppConfig).FullName!, + JsonSerializer.SerializeToUtf8Bytes(new AppConfig { Name = "Persisted", Value = 99 }) + ); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(backend) + ])); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("Persisted", config.Name); + Assert.Equal(99, config.Value); + } + + [Fact] + public async Task ConfigManager_WithLocalStorage_MergesWithFileDefaults() + { + var backend = CreateBackend(); + // Pre-persist partial override + await backend.WriteAsync( + typeof(AppConfig).FullName!, + """{"Name":"Override"}"""u8.ToArray() + ); + + // Create a temp file with defaults + var tempFile = Path.Combine(_testDir, "defaults.json"); + System.IO.File.WriteAllText(tempFile, """{"Name":"Default","Value":42}"""); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromFile(tempFile), + rules.For().FromLocalStorage(backend) // Higher prio + ])); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("Override", config.Name); // From LocalStorage + Assert.Equal(42, config.Value); // From File (not overridden) + } + + [Fact] + public async Task WriteToStore_TriggersRecompute() + { + var backend = CreateBackend(); + var storageKey = typeof(AppConfig).FullName!; + var store = new LocalStorageStore(backend, storageKey) + { + ConfigurationType = typeof(AppConfig) + }; + + using var manager = ConfigManager.Create(c => c + .UseConfiguration([ + new ConfigRule( + typeof(LocalStorageProvider), + new LocalStorageProviderOptions(store), + LocalStorageProviderQueryOptions.Default, + typeof(AppConfig)) + ]) + .UseDebounce(50)); + + var reactiveConfig = manager.GetReactiveConfig(); + + // Initial state + Assert.Null(reactiveConfig.CurrentValue.Name); + + // Write new config + var newConfig = new AppConfig { Name = "Updated", Value = 100 }; + await store.WriteBytesAsync(JsonSerializer.SerializeToUtf8Bytes(newConfig)); + + // Wait for recompute (debounce + processing) + await WaitUntilAsync( + () => reactiveConfig.CurrentValue.Name == "Updated", + description: "reactive config to reflect written value"); + + Assert.Equal("Updated", reactiveConfig.CurrentValue.Name); + Assert.Equal(100, reactiveConfig.CurrentValue.Value); + } + + [Fact] + public async Task LocalStorageAdapter_WriteAsync_TriggersRecompute() + { + var backend = CreateBackend(); + var storageKey = typeof(AppConfig).FullName!; + var store = new LocalStorageStore(backend, storageKey) + { + ConfigurationType = typeof(AppConfig) + }; + + using var manager = ConfigManager.Create(c => c + .UseConfiguration([ + new ConfigRule( + typeof(LocalStorageProvider), + new LocalStorageProviderOptions(store), + LocalStorageProviderQueryOptions.Default, + typeof(AppConfig)) + ]) + .UseDebounce(50)); + + var reactiveConfig = manager.GetReactiveConfig(); + var localStorage = new LocalStorageAdapter(store); + + // Write via adapter (same as ILocalStorage.WriteAsync) + await localStorage.WriteAsync(new AppConfig { Name = "ViaAdapter", Value = 77 }); + + await WaitUntilAsync( + () => reactiveConfig.CurrentValue.Name == "ViaAdapter", + description: "reactive config to reflect adapter write"); + + Assert.Equal("ViaAdapter", reactiveConfig.CurrentValue.Name); + Assert.Equal(77, reactiveConfig.CurrentValue.Value); + } + + [Fact] + public void ConfigAwareFactory_ReceivesAccessor() + { + var backend = CreateBackend(); + + // Pre-persist base settings so accessor has something to read + var baseFile = Path.Combine(_testDir, "base.json"); + System.IO.File.WriteAllText(baseFile, """{"Name":"Base","Value":1}"""); + + IConfigurationAccessor? capturedAccessor = null; + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromFile(baseFile), + rules.For().FromLocalStorage((accessor, _) => + { + capturedAccessor = accessor; + return backend; + }) + ])); + + Assert.NotNull(capturedAccessor); + // The accessor should be able to read the config from the earlier rule + var config = capturedAccessor.GetConfig(); + Assert.NotNull(config); + } + + [Fact] + public async Task DuplicateFromLocalStorage_LastRuleWins() + { + var backend1 = new FileStorageBackend(Path.Combine(_testDir, "store1")); + var backend2 = new FileStorageBackend(Path.Combine(_testDir, "store2")); + + // Pre-persist different values + await backend1.WriteAsync(typeof(AppConfig).FullName!, """{"Name":"First"}"""u8.ToArray()); + await backend2.WriteAsync(typeof(AppConfig).FullName!, """{"Name":"Second"}"""u8.ToArray()); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => [ + rules.For().FromLocalStorage(backend1), + rules.For().FromLocalStorage(backend2), + ])); + + var config = manager.GetConfig(); + Assert.NotNull(config); + Assert.Equal("Second", config.Name); // Last rule wins + } + + private static async Task WaitUntilAsync( + Func condition, + TimeSpan timeout = default, + string description = "condition") + { + timeout = timeout == default ? TimeSpan.FromSeconds(15) : timeout; + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + try { if (condition()) return; } catch { } + await Task.Delay(50); + } + throw new TimeoutException($"Timeout waiting for {description} after {timeout}"); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* best effort cleanup */ } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageStoreTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageStoreTests.cs new file mode 100644 index 0000000..cf4b41e --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageStoreTests.cs @@ -0,0 +1,163 @@ +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +[Trait("Provider", "LocalStorageProvider")] +public class LocalStorageStoreTests : IDisposable +{ + private readonly string _testDir; + + public LocalStorageStoreTests() + { + _testDir = Path.Combine(Path.GetTempPath(), "cocoar_store_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + } + + private LocalStorageStore CreateStore(string key = "TestConfig") + { + var backend = new FileStorageBackend(_testDir); + return new LocalStorageStore(backend, key) { ConfigurationType = typeof(object) }; + } + + [Fact] + public async Task ReadBytesAsync_NoData_ReturnsEmptyJson() + { + using var store = CreateStore(); + var result = await store.ReadBytesAsync(); + + Assert.Equal("{}"u8.ToArray(), result); + } + + [Fact] + public async Task WriteBytesAsync_ThenRead_ReturnsWrittenData() + { + using var store = CreateStore(); + var data = """{"Name":"Written"}"""u8.ToArray(); + + await store.WriteBytesAsync(data); + var result = await store.ReadBytesAsync(); + + Assert.Equal(data, result); + } + + [Fact] + public async Task WriteBytesAsync_SignalsChangeObservable() + { + using var store = CreateStore(); + var received = new List(); + using var sub = store.Changes.Subscribe(bytes => received.Add(bytes)); + + var data = """{"Version":1}"""u8.ToArray(); + await store.WriteBytesAsync(data); + + Assert.Single(received); + Assert.Equal(data, received[0]); + } + + [Fact] + public async Task WriteBytesAsync_MultipleWrites_AllSignaled() + { + using var store = CreateStore(); + var received = new List(); + using var sub = store.Changes.Subscribe(bytes => received.Add(bytes)); + + await store.WriteBytesAsync("""{"V":1}"""u8.ToArray()); + await store.WriteBytesAsync("""{"V":2}"""u8.ToArray()); + await store.WriteBytesAsync("""{"V":3}"""u8.ToArray()); + + Assert.Equal(3, received.Count); + } + + [Fact] + public async Task WriteBytesAsync_ConcurrentWrites_AreSerialized() + { + using var store = CreateStore(); + var received = new List(); + using var sub = store.Changes.Subscribe(bytes => received.Add(bytes)); + + var tasks = Enumerable.Range(0, 10) + .Select(i => store.WriteBytesAsync(System.Text.Encoding.UTF8.GetBytes($$"""{"I":{{i}}}"""))) + .ToArray(); + + await Task.WhenAll(tasks); + + // All writes should complete and signal + Assert.Equal(10, received.Count); + + // Final read should return one of the written values + var final = await store.ReadBytesAsync(); + Assert.Contains("\"I\":", System.Text.Encoding.UTF8.GetString(final)); + } + + [Fact] + public async Task WriteBytesAsync_BackendThrows_DoesNotSignalChange() + { + var failingBackend = new FailingStorageBackend(); + using var store = new LocalStorageStore(failingBackend, "key") { ConfigurationType = typeof(object) }; + var received = new List(); + using var sub = store.Changes.Subscribe(bytes => received.Add(bytes)); + + await Assert.ThrowsAsync(() => + store.WriteBytesAsync("""{"V":1}"""u8.ToArray())); + + Assert.Empty(received); + } + + [Fact] + public async Task ReplaceBackend_SwitchesStorageAtRuntime() + { + var dir1 = Path.Combine(_testDir, "backend1"); + var dir2 = Path.Combine(_testDir, "backend2"); + var backend1 = new FileStorageBackend(dir1); + var backend2 = new FileStorageBackend(dir2); + + using var store = new LocalStorageStore(backend1, "key") { ConfigurationType = typeof(object) }; + + // Write to backend1 + await store.WriteBytesAsync("""{"From":"backend1"}"""u8.ToArray()); + var result1 = await store.ReadBytesAsync(); + Assert.Contains("backend1", System.Text.Encoding.UTF8.GetString(result1)); + + // Swap to backend2 + store.ReplaceBackend(backend2); + + // Read returns empty (backend2 has no data) + var result2 = await store.ReadBytesAsync(); + Assert.Equal("{}"u8.ToArray(), result2); + + // Write goes to backend2 + await store.WriteBytesAsync("""{"From":"backend2"}"""u8.ToArray()); + var result3 = await store.ReadBytesAsync(); + Assert.Contains("backend2", System.Text.Encoding.UTF8.GetString(result3)); + + // backend1 still has its old data + var oldData = await backend1.ReadAsync("key"); + Assert.NotNull(oldData); + Assert.Contains("backend1", System.Text.Encoding.UTF8.GetString(oldData)); + } + + [Fact] + public void ConfigurationType_SetViaInit() + { + var backend = new FileStorageBackend(_testDir); + var store = new LocalStorageStore(backend, "key") { ConfigurationType = typeof(string) }; + Assert.Equal(typeof(string), store.ConfigurationType); + store.Dispose(); + } + + private sealed class FailingStorageBackend : IStorageBackend + { + public Task ReadAsync(string key, CancellationToken ct = default) + => Task.FromResult(null); + + public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + => throw new IOException("Disk full"); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* best effort cleanup */ } + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index e4703ef..e08b350 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -65,6 +65,7 @@ export default defineConfig({ { text: 'Command Line', link: '/guide/providers/command-line' }, { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, + { text: 'LocalStorage', link: '/guide/providers/localstorage' }, { text: 'Static & Observable', link: '/guide/providers/static-observable' }, { text: 'Custom Providers ', link: '/guide/providers/custom' }, ], diff --git a/website/changelog.md b/website/changelog.md index a7b45a8..b857a53 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -1,5 +1,18 @@ # Changelog +## [5.1.0-beta.1] — 2026-04-11 + +### Added + +**LocalStorage Provider** +- Writable configuration provider backed by persistent storage — enables runtime config changes via admin UIs, APIs, or background jobs +- `FromLocalStorage()` fluent API — positions as a normal rule in the pipeline, respects last-rule-wins merging +- `ILocalStorage` injectable write interface — write triggers recompute, updates `IReactiveConfig` for all consumers +- `IStorageBackend` pluggable persistence abstraction — implement `ReadAsync`/`WriteAsync` for any backing store +- `FileStorageBackend` built-in default — JSON files in `{AppContext.BaseDirectory}/.cocoar/localStorage/` with atomic writes +- Config-aware factory overload: `FromLocalStorage(accessor => ...)` — backend can depend on values from earlier rules and is swapped transparently on recompute when those values change +- [LocalStorage guide](/guide/providers/localstorage) with custom backend examples (Marten, SQLite) + ## [5.0.0] — 2026-03-24 ### Added diff --git a/website/guide/providers/localstorage.md b/website/guide/providers/localstorage.md new file mode 100644 index 0000000..edcc24f --- /dev/null +++ b/website/guide/providers/localstorage.md @@ -0,0 +1,351 @@ +# LocalStorage Provider + +The LocalStorage provider reads and writes configuration to persistent storage. Unlike other providers, it's **writable from application code** — enabling runtime configuration changes via admin UIs, APIs, or background jobs. + +```csharp +rule.For().FromLocalStorage() +``` + +## How It Works + +1. On startup, reads persisted bytes from the storage backend (default: JSON file on disk) +2. If no data exists yet, returns `{}` — the type is initialized with C# defaults +3. Application code writes new configuration via `ILocalStorage` (injected through DI) +4. Write persists to storage, then signals the provider's change observable +5. The engine recomputes, and `IReactiveConfig` emits the new value to all subscribers + +``` +ILocalStorage.WriteAsync(value) + → serialize to UTF-8 JSON bytes + → persist to storage backend + → signal change observable + → engine recompute (debounced) + → IReactiveConfig fires +``` + +## Reading and Writing + +Inject `ILocalStorage` to read or write the stored configuration at runtime: + +```csharp +// Read the raw stored value (not the merged pipeline result) +app.MapGet("/admin/settings", async (ILocalStorage localStorage) => +{ + var stored = await localStorage.ReadAsync(); + return stored is not null ? Results.Ok(stored) : Results.NotFound(); +}); + +// Write — persists + triggers recompute +app.MapPut("/admin/settings", async ( + AppSettings settings, + ILocalStorage localStorage) => +{ + await localStorage.WriteAsync(settings); + return Results.Ok(); +}); +``` + +`ILocalStorage` is registered as a **Singleton** — it's thread-safe and can be injected anywhere. + +### Atomic Updates + +Use `UpdateAsync` to modify individual properties without replacing the entire object. The read-mutate-write cycle runs under an exclusive lock — concurrent updates are serialized: + +```csharp +// Toggle a single property — everything else is preserved +await localStorage.UpdateAsync(s => s.FeatureEnabled = true); + +// Modify multiple properties atomically +await localStorage.UpdateAsync(s => +{ + s.AppName = "NewName"; + s.MaxRetries = 5; +}); +``` + +If nothing has been stored yet, `UpdateAsync` starts from a default-constructed `T`. Two concurrent `UpdateAsync` calls never lose each other's changes — the second one sees the first one's result. + +### ReadAsync vs IReactiveConfig + +| | `ILocalStorage.ReadAsync()` | `IReactiveConfig.CurrentValue` | +|---|---|---| +| Returns | Raw stored value | Merged pipeline result | +| Nothing stored | `null` | C# defaults (from `{}`) | +| Use case | Show admin what they saved | Show app what's effective | + +::: info Package +`ILocalStorage` is defined in `Cocoar.Configuration.Abstractions`, so library projects can depend on the interface without referencing the full configuration package. +::: + +## Pipeline Position + +LocalStorage is a normal rule in the pipeline. Position it to control priority: + +```csharp +rule => [ + rule.For().FromFile("appsettings.json"), // Defaults + rule.For().FromEnvironment("APP_"), // Deployment overrides + rule.For().FromLocalStorage(), // Admin overrides (highest) +] +``` + +Later rules override earlier ones (last-write-wins). In this example, a value written via `ILocalStorage` takes precedence over both the file and environment variables. But properties that weren't written still fall through to the earlier rules. + +## Default Storage + +By default, configuration is persisted as JSON files in: + +``` +{AppContext.BaseDirectory}/.cocoar/localStorage/ +``` + +Each configuration type gets its own file, named by its full type name: + +``` +.cocoar/localStorage/ + MyApp.Settings.AppSettings.json + MyApp.Settings.AuthSettings.json +``` + +Writes use an atomic temp-file-then-rename pattern to prevent partial reads. + +## Custom Storage Backends + +The default file backend is just one implementation of `IStorageBackend`. You can replace it with any persistence layer. + +### The Interface + +```csharp +public interface IStorageBackend +{ + Task ReadAsync(string key, CancellationToken ct = default); + Task WriteAsync(string key, byte[] data, CancellationToken ct = default); +} +``` + +- **`key`** — the configuration type's full name (e.g., `"MyApp.Settings.AppSettings"`) +- **`ReadAsync`** — returns raw UTF-8 JSON bytes, or `null` if no data exists yet +- **`WriteAsync`** — persists raw UTF-8 JSON bytes atomically + +### Example: Marten Backend + +```csharp +public class MartenStorageBackend(IDocumentStore store) : IStorageBackend +{ + public async Task ReadAsync(string key, CancellationToken ct = default) + { + await using var session = store.QuerySession(); + var doc = await session.LoadAsync(key, ct); + return doc?.JsonBytes; + } + + public async Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + await using var session = store.LightweightSession(); + session.Store(new ConfigDocument { Id = key, JsonBytes = data }); + await session.SaveChangesAsync(ct); + } +} + +public class ConfigDocument +{ + public string Id { get; set; } = ""; + public byte[] JsonBytes { get; set; } = []; +} +``` + +### Example: SQLite Backend + +```csharp +public class SqliteStorageBackend : IStorageBackend +{ + private readonly string _connectionString; + + public SqliteStorageBackend(string dbPath) + { + _connectionString = $"Data Source={dbPath}"; + EnsureTable(); + } + + public async Task ReadAsync(string key, CancellationToken ct = default) + { + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT data FROM config WHERE key = @key"; + cmd.Parameters.AddWithValue("@key", key); + var result = await cmd.ExecuteScalarAsync(ct); + return result as byte[]; + } + + public async Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO config (key, data) VALUES (@key, @data) + ON CONFLICT(key) DO UPDATE SET data = @data + """; + cmd.Parameters.AddWithValue("@key", key); + cmd.Parameters.AddWithValue("@data", data); + await cmd.ExecuteNonQueryAsync(ct); + } + + private void EnsureTable() + { + using var conn = new SqliteConnection(_connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, data BLOB)"; + cmd.ExecuteNonQuery(); + } +} +``` + +### Using a Custom Backend + +Pass your backend to `FromLocalStorage()`: + +```csharp +var martenBackend = new MartenStorageBackend(documentStore); + +rule => [ + rule.For().FromLocalStorage(martenBackend), + rule.For().FromLocalStorage(martenBackend), +] +``` + +Multiple types can share a single backend instance — they're distinguished by key. + +### Config-Aware Backend (Factory Overload) + +When the storage backend depends on values from earlier rules (e.g., a connection string), use the factory overload. The factory receives two parameters: + +- `accessor` — the current configuration state (values from earlier rules) +- `currentBackend` — the backend currently in use (`null` on first call) + +```csharp +rule => [ + rule.For() + .FromFile("infra.json") + .Required(), + + rule.For() + .FromLocalStorage((accessor, currentBackend) => + { + var infra = accessor.GetConfig(); + + // Reuse existing backend if connection string hasn't changed + if (currentBackend is MartenStorageBackend marten + && marten.ConnectionString == infra.ConnectionString) + return currentBackend; + + return new MartenStorageBackend(infra.ConnectionString); + }), +] +``` + +The factory is called on **every recompute**. Returning `currentBackend` unchanged avoids creating a new instance — important for database backends where each instance may hold a connection pool. The store only swaps the backend when the returned reference is different from the current one. + +#### Backend Swapping at Runtime + +The factory can return a completely different backend type based on current configuration: + +```csharp +rule.For().FromLocalStorage((accessor, currentBackend) => +{ + var infra = accessor.GetConfig(); + + if (!string.IsNullOrEmpty(infra.DatabaseConnectionString)) + { + // Reuse if same connection string + if (currentBackend is MartenStorageBackend marten + && marten.ConnectionString == infra.DatabaseConnectionString) + return currentBackend; + + return new MartenStorageBackend(infra.DatabaseConnectionString); + } + + // No DB configured — fall back to file + if (currentBackend is FileStorageBackend) + return currentBackend; + + return new FileStorageBackend(); +}) +``` + +When the earlier rule changes, the recompute triggers the factory with the new values. If the factory returns a different backend, it's swapped on the existing store — all `ILocalStorage` references in DI remain valid and immediately use the new backend. + +::: warning Data is not migrated +Swapping the backend does not move data from the old backend to the new one. After a swap, the new backend starts empty — reads return `{}` (C# defaults) until new data is written. This is consistent with how all providers behave: if the source is empty, you get defaults. +::: + +## First Startup + +When no data has been written yet: + +- `ReadAsync` returns `null` +- The provider returns `{}` +- The configuration type is initialized with C# default values +- This is **not** an error — the rule is optional by default + +The first `WriteAsync` call creates the persisted entry. From then on, the value is loaded on every startup. + +## Common Patterns + +### Admin settings endpoint + +```csharp +// Read via IReactiveConfig (always current) +app.MapGet("/admin/settings", (IReactiveConfig config) => + Results.Ok(config.CurrentValue)); + +// Write via ILocalStorage (persists + triggers recompute) +app.MapPut("/admin/settings", async ( + AppSettings settings, + ILocalStorage localStorage) => +{ + await localStorage.WriteAsync(settings); + return Results.Ok(); +}); +``` + +### File defaults + admin overrides + +```csharp +rule => [ + rule.For() + .FromFile("appsettings.json") + .Required(), + + rule.For() + .FromLocalStorage(), +] +``` + +The file provides the baseline. Admin changes override specific values without replacing the entire config. + +### Multiple writable types + +```csharp +rule => [ + rule.For().FromLocalStorage(), + rule.For().FromLocalStorage(), + rule.For().FromLocalStorage(), +] +``` + +Each type gets its own `ILocalStorage` in DI and its own storage entry. + +## Secrets + +LocalStorage does **not** support `Secret` properties. The standard secrets pipeline works because providers deliver pre-encrypted envelopes — decryption only happens at `Secret.Open()` time, and plaintext is zeroed after use. + +With LocalStorage, data arrives as plaintext JSON from application code (e.g., an admin UI). There is no point in the pipeline where encryption could happen — the value is already plaintext before `WriteAsync` is called, and it's stored as plaintext in the backend. + +::: warning Do not store secrets via LocalStorage +`ILocalStorage.WriteAsync()` persists values as plaintext JSON. There is no encryption, no envelope wrapping, and no zeroing. Sensitive values (API keys, tokens, passwords) should use [`UseSecretsSetup()`](/guide/secrets/overview) with file-based encrypted config instead. + +If you need admin-editable sensitive settings, encrypt at the application layer before writing, or use a backend with built-in encryption (e.g., encrypted SQLite, Marten with column-level encryption, or a secrets manager). +::: diff --git a/website/guide/providers/overview.md b/website/guide/providers/overview.md index fd7d767..6232ac1 100644 --- a/website/guide/providers/overview.md +++ b/website/guide/providers/overview.md @@ -27,6 +27,7 @@ On failure, providers return an empty JSON object `{}` — never null. This mean | [File](/guide/providers/file) | `.FromFile("path")` | File watcher | Core | | [Environment Variables](/guide/providers/environment) | `.FromEnvironment("PREFIX_")` | No | Core | | [Command Line](/guide/providers/command-line) | `.FromCommandLine("--prefix")` | No | Core | +| [LocalStorage](/guide/providers/localstorage) | `.FromLocalStorage()` | Yes (on write) | Core | | [Static JSON](/guide/providers/static-observable#static-json) | `.FromStaticJson("{...}")` | No | Core | | [Observable](/guide/providers/static-observable#observable) | `.FromObservable(obs)` | Yes | Core | | [HTTP](/guide/providers/http-polling) | `.FromHttp(url)` | Polling / SSE / one-time | Http |