Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<IConfigurationAccessor, IStorageBackend>)` config-aware factory overload with dynamic backend swapping at runtime
- `ILocalStorage<T>` 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace Cocoar.Configuration.LocalStorage;

/// <summary>
/// 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
/// <c>IReactiveConfig&lt;T&gt;</c> for all consumers.
/// </summary>
/// <typeparam name="T">The configuration type.</typeparam>
public interface ILocalStorage<T> where T : class
{
/// <summary>
/// Reads the current value from storage. Returns <c>null</c> if nothing
/// has been persisted yet.
/// </summary>
/// <remarks>
/// This returns the raw stored value — not the merged pipeline result.
/// Use <c>IReactiveConfig&lt;T&gt;.CurrentValue</c> for the final merged configuration.
/// </remarks>
Task<T?> ReadAsync(CancellationToken ct = default);

/// <summary>
/// Serializes the value to UTF-8 JSON bytes, persists it to storage,
/// and signals the configuration system to recompute.
/// </summary>
Task WriteAsync(T value, CancellationToken ct = default);

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// If nothing has been stored yet, the update receives a default-constructed <typeparamref name="T"/>.
/// Mutate the properties you want to change; everything else is preserved.
/// </remarks>
Task UpdateAsync(Action<T> update, CancellationToken ct = default);
}
28 changes: 28 additions & 0 deletions src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -155,4 +157,30 @@ private static void EmitReactiveService(IServiceCollection services, Type servic
return method.Invoke(mgr, null)!;
});
}

/// <summary>
/// Discovers provider options that implement <see cref="IProviderServiceRegistration"/>
/// and registers their contributed services. Last rule wins per service type.
/// </summary>
private static void EmitProviderContributedServices(IServiceCollection services, ConfigManager configManager)
{
var registrations = new Dictionary<Type, object>();

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);
}
}
}
1 change: 1 addition & 0 deletions src/Cocoar.Configuration/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Cocoar.Configuration.Providers.Abstractions;

/// <summary>
/// Implemented by provider options that need additional services registered in DI
/// beyond the standard config type and <c>IReactiveConfig&lt;T&gt;</c>.
/// <para>
/// The DI emitter discovers this interface by scanning resolved provider options
/// for all rules. No hardcoded provider knowledge is needed in the emitter.
/// </para>
/// </summary>
public interface IProviderServiceRegistration
{
/// <summary>
/// Returns additional (serviceType, singletonInstance) pairs to register in DI.
/// Called once during DI setup — not on every recompute.
/// </summary>
/// <param name="concreteType">The configuration type this rule targets (e.g., typeof(AppSettings)).</param>
IEnumerable<(Type ServiceType, object Implementation)> GetServiceRegistrations(Type concreteType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace Cocoar.Configuration.Providers;

/// <summary>
/// File-based storage backend using atomic write-temp-then-rename pattern.
/// Default directory: {AppContext.BaseDirectory}/.cocoar/localStorage/
/// </summary>
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<byte[]?> 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");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Cocoar.Configuration.Providers;

/// <summary>
/// Abstraction for the persistence layer used by LocalStorageProvider.
/// Default implementation is file-based; can be replaced with SQLite, Marten, etc.
/// </summary>
public interface IStorageBackend
{
/// <summary>
/// Reads raw UTF-8 JSON bytes for the given key.
/// Returns null if no data has been persisted yet.
/// </summary>
Task<byte[]?> ReadAsync(string key, CancellationToken ct = default);

/// <summary>
/// Writes raw UTF-8 JSON bytes atomically for the given key.
/// </summary>
Task WriteAsync(string key, byte[] data, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Text.Json;
using Cocoar.Configuration.LocalStorage;

namespace Cocoar.Configuration.Providers;

/// <summary>
/// Adapts the untyped <see cref="LocalStorageStore"/> to the typed <see cref="ILocalStorage{T}"/> interface.
/// Handles serialization from T to UTF-8 JSON bytes.
/// </summary>
public sealed class LocalStorageAdapter<T>(LocalStorageStore store) : ILocalStorage<T> where T : class
{
public async Task<T?> 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<T>(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<T> update, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(update);
await store.UpdateBytesAsync(currentBytes =>
{
var current = JsonSerializer.Deserialize<T>(currentBytes) ?? throw new InvalidOperationException(
$"Failed to deserialize {typeof(T).Name} from stored bytes.");
update(current);
return JsonSerializer.SerializeToUtf8Bytes(current);
}, ct).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Cocoar.Configuration.Providers.Abstractions;

namespace Cocoar.Configuration.Providers;

/// <summary>
/// Provider that reads from a <see cref="LocalStorageStore"/>.
/// The store is shared state owned by the closure in <c>FromLocalStorage()</c> —
/// the provider does NOT own or dispose it.
/// </summary>
public sealed class LocalStorageProvider(LocalStorageProviderOptions options)
: ConfigurationProvider<LocalStorageProviderOptions, LocalStorageProviderQueryOptions>(options)
{
public override async Task<byte[]> FetchConfigurationBytesAsync(
LocalStorageProviderQueryOptions query, CancellationToken ct = default)
{
return await ProviderOptions.Store.ReadBytesAsync(ct).ConfigureAwait(false);
}

public override IObservable<byte[]> ChangesAsBytes(LocalStorageProviderQueryOptions query)
{
return ProviderOptions.Store.Changes;
}
}
Original file line number Diff line number Diff line change
@@ -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));

/// <summary>
/// Returns null to indicate non-reusable. Each LocalStorage rule gets its own provider instance
/// because each is backed by a unique <see cref="LocalStorageStore"/> tied to a specific configuration type.
/// </summary>
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)!);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Cocoar.Configuration.Providers.Abstractions;

namespace Cocoar.Configuration.Providers;

public sealed class LocalStorageProviderQueryOptions : IProviderQuery
{
public static readonly LocalStorageProviderQueryOptions Default = new();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using Cocoar.Configuration.Core;
using Cocoar.Configuration.Fluent;

namespace Cocoar.Configuration.Providers;

public static class LocalStorageRulesExtensions
{
/// <summary>
/// Creates a local-storage-backed configuration rule.
/// Reads from and writes to persistent storage. By default uses file-based storage
/// at <c>{AppContext.BaseDirectory}/.cocoar/localStorage/</c>.
/// </summary>
/// <remarks>
/// <para>
/// Use <see cref="Cocoar.Configuration.LocalStorage.ILocalStorage{T}"/> (via DI) to write configuration at runtime.
/// Writes trigger a recompute of the configuration pipeline.
/// </para>
/// <para>
/// Position this rule in the pipeline to control priority: later rules override earlier ones.
/// </para>
/// </remarks>
/// <param name="builder">The typed provider builder.</param>
/// <param name="backend">Optional custom storage backend. Defaults to <see cref="FileStorageBackend"/>.</param>
public static ProviderRuleBuilder<LocalStorageProvider, LocalStorageProviderOptions, LocalStorageProviderQueryOptions>
FromLocalStorage<T>(this TypedProviderBuilder<T> 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)
);
}

/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// The factory is called on every recompute. The second parameter (<c>currentBackend</c>) is
/// the backend currently in use (<c>null</c> 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.
/// </remarks>
/// <param name="builder">The typed provider builder.</param>
/// <param name="backendFactory">A factory that receives the current <see cref="IConfigurationAccessor"/>
/// and the current <see cref="IStorageBackend"/> (null on first call), and returns the backend to use.</param>
public static ProviderRuleBuilder<LocalStorageProvider, LocalStorageProviderOptions, LocalStorageProviderQueryOptions>
FromLocalStorage<T>(this TypedProviderBuilder<T> builder, Func<IConfigurationAccessor, IStorageBackend?, IStorageBackend> 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)
);
}
}
Loading
Loading