Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/01-pr-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: |
9.0.x
10.0.x

- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/02-develop-build-alpha.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: |
9.0.x
10.0.x

- name: Restore dependencies
run: dotnet restore
Expand Down Expand Up @@ -59,7 +61,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: |
9.0.x
10.0.x

- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/03-publish-prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: |
9.0.x
10.0.x

- name: Restore dependencies
run: dotnet restore
Expand Down Expand Up @@ -68,7 +70,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: |
9.0.x
10.0.x

- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/04-publish-stable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x

- name: Restore dependencies
run: dotnet restore
Expand Down Expand Up @@ -94,8 +94,8 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x

- name: Restore dependencies
run: dotnet restore
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## [Unreleased]

> **v6.0.0** — major release.

### Breaking
- **Dropped .NET 8 support.** All packages now multi-target `net9.0` and `net10.0` (was `net8.0` / `net9.0`). Consumers must target .NET 9 or later.
- `Microsoft.Extensions.*` dependencies moved to the `10.0.x` line, aligned with .NET 10. `10.0.x` ships a native `net9.0` target, so .NET 9 consumers take no runtime hit.

### Removed
- `IConfigurationAccessor.GetRequiredConfig<T>()` / `GetRequiredConfig(Type)` — deprecated since v5; use `GetConfig<T>()` / `GetConfig(Type)` (identical throw-on-missing behavior).
- `FromMicrosoftSource(...)` — use `FromIConfiguration(IConfiguration)`.
- `X509CertificateGenerator.GenerateAndSave(...)` — use `GenerateAndSavePfx(...)` / `GenerateAndSavePem(...)`.

### Added
- **`Cocoar.Configuration.WritableStore.Marten`** — opt-in Marten (PostgreSQL) WritableStore backend. `MartenStoreBackend` persists overrides as one `CocoarConfigDocument` per configuration type; `FromMartenStore()` (service-backed, Layer-2) resolves the `IDocumentStore` from DI and, combined with `.TenantScoped()`, gives **database-per-tenant** configuration (each tenant's overlay in its own database).
- **WritableStore `PatchAsync`** — `IWritableStore<T>.PatchAsync(b => b.Set(...).SetSecret(...).Reset(...))` applies any number of mutations as one atomic write and one recompute; single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` delegate to it.

### Changed
- Configuration **layer merging is now case-insensitive** on property names (via Cocoar.Json.Mutable 1.2.0), consistent with how the effective config is read back.

### Fixed
- Resetting a secret-typed member no longer throws `NotSupportedException` (removing an override exposes no plaintext).

## [5.1.0] - 2026-05-31

### Added
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dotnet pack ./src -c Release

## Architecture Overview

**Cocoar.Configuration** is a reactive, strongly-typed configuration library for .NET 8.0+. The architecture follows a modular, capabilities-driven design.
**Cocoar.Configuration** is a reactive, strongly-typed configuration library for .NET 9.0+ (multi-targets `net9.0` and `net10.0`). The architecture follows a modular, capabilities-driven design.

### Core Components

Expand Down Expand Up @@ -72,7 +72,7 @@ capabilityScope.Compose(this).WithPrimary(new ConcreteTypePrimary<T>(...));
SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability<T>(...));
```

**Zero External Dependencies** - Shipped packages have no non-Microsoft dependencies. The reactive internals (`Reactive/Internal/`) are lightweight replacements for the subset of System.Reactive the library used (Subject, BehaviorSubject, Select/Where/DistinctUntilChanged). This is intentional — do not add System.Reactive back. The public API (`IReactiveConfig<T> : IObservable<T>`) uses only BCL types; consumers are free to use System.Reactive on their side. Test projects still reference System.Reactive as a test dependency.
**Zero External Dependencies** - Core shipped packages have no non-Microsoft dependencies. (Opt-in integration packages are the deliberate exception: `Cocoar.Configuration.WritableStore.Marten` takes a Marten dependency. Consumers who don't reference it pay nothing.) The reactive internals (`Reactive/Internal/`) are lightweight replacements for the subset of System.Reactive the library used (Subject, BehaviorSubject, Select/Where/DistinctUntilChanged). This is intentional — do not add System.Reactive back. The public API (`IReactiveConfig<T> : IObservable<T>`) uses only BCL types; consumers are free to use System.Reactive on their side. Test projects still reference System.Reactive as a test dependency.

**Reactive Tuples** - `IReactiveConfig<(T1, T2)>` provides atomic multi-config updates. Multiple configs always update together, preventing inconsistent state.

Expand All @@ -90,6 +90,7 @@ SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability<T>(...));
| `Cocoar.Configuration.AspNetCore` | ASP.NET Core integration, health endpoints, feature flag/entitlement REST endpoints (incl. per-tenant), secrets encryption-key endpoints, scoped tenant config adapter |
| `Cocoar.Configuration.Http` | Remote config provider (polling, SSE, one-time fetch) |
| `Cocoar.Configuration.MicrosoftAdapter` | Bridge to existing `IConfiguration` sources |
| `Cocoar.Configuration.WritableStore.Marten` | Marten (PostgreSQL) WritableStore backend (`MartenStoreBackend`, `FromMartenStore()`); tenant-aware via Marten database-per-tenant. Opt-in integration package that takes a Marten dependency. |
| `Cocoar.Configuration.Analyzers` | Roslyn analyzers (COCFG001, 002, 003, 005, 006) and source generator (COCFLAG001-003). COCFG004 was removed — enforced by `where T : class` constraint instead. |
| `Cocoar.Configuration.Secrets.Cli` | Global .NET tool for encrypting/decrypting secrets in config files |

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dotnet add package Cocoar.Configuration.DI # + Microsoft.Extensions.D
dotnet add package Cocoar.Configuration.AspNetCore # + health endpoints, feature flags
```

You only need **one** — each includes everything above it. Requires .NET 8+.
You only need **one** — each includes everything above it. Requires .NET 9+.

## Quick Start

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,6 @@ public interface IConfigurationAccessor
/// <returns>True if configuration exists for the type; false otherwise.</returns>
bool TryGetConfig<T>(out T? value) where T : class;

/// <summary>
/// Gets configuration, throwing if not found.
/// </summary>
/// <remarks>
/// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered.
/// </remarks>
[Obsolete("Use GetConfig<T>() instead - it now throws if no rule is registered. " +
"This method will be removed in a future version.")]
T GetRequiredConfig<T>();

/// <summary>
/// Gets a configuration instance from the cached snapshot.
/// </summary>
Expand All @@ -58,16 +48,6 @@ public interface IConfigurationAccessor
/// <returns>True if configuration exists for the type; false otherwise.</returns>
bool TryGetConfig(Type type, out object? value);

/// <summary>
/// Gets configuration, throwing if not found.
/// </summary>
/// <remarks>
/// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered.
/// </remarks>
[Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " +
"This method will be removed in a future version.")]
object GetRequiredConfig(Type type);

JsonElement? GetConfigAsJson(Type type);

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Cocoar.Configuration.Analyzers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Validates that rules appear after their dependencies.
```csharp
// ❌ Error:
rule.For<DerivedConfig>()
.When(accessor => accessor.GetRequiredConfig<ApiSettings>().IsEnabled),
.When(accessor => accessor.GetConfig<ApiSettings>()!.IsEnabled),
rule.For<ApiSettings>().FromFile("api.json"),
// COCFG002: ApiSettings not available - move this rule after ApiSettings rule
```
Expand Down
15 changes: 0 additions & 15 deletions src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,4 @@ public static
_ => new MicrosoftConfigurationProviderQueryOptions(),
typeof(T)
);

/// <summary>
/// Creates a Microsoft configuration source rule with custom options.
/// </summary>
[Obsolete("Use FromIConfiguration(IConfiguration) instead.")]
public static
ProviderRuleBuilder<MicrosoftConfigurationSourceProvider, MicrosoftConfigurationSourceProviderOptions,
MicrosoftConfigurationSourceProviderQueryOptions> FromMicrosoftSource<T>(this TypedProviderBuilder<T> builder,
Func<IConfigurationAccessor, MicrosoftConfigurationSourceRuleOptions> optionsFactory)
where T : class
=> new(
cm => optionsFactory(cm).ToProviderOptions(),
cm => optionsFactory(cm).ToQueryOptions(),
typeof(T)
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<IsPackable>true</IsPackable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Description>Marten (PostgreSQL document store) backend for the Cocoar.Configuration WritableStore. Persists writable configuration overlays as documents, with first-class support for Marten database-per-tenant multi-tenancy so each tenant's configuration lives in its own database.</Description>
<PackageTags>configuration;marten;postgresql;postgres;writable-store;multi-tenancy;document-store;dependency-injection</PackageTags>
</PropertyGroup>

<ItemGroup>
<!-- The DI package supplies the service-backed (Layer-2) FromStore((sp,a)=>IStoreBackend) seam this builds on. -->
<ProjectReference Include="..\Cocoar.Configuration.DI\Cocoar.Configuration.DI.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Marten" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Cocoar.Configuration.WritableStore.Marten;

/// <summary>
/// The Marten document that persists one WritableStore overlay. There is one document per configuration type:
/// <see cref="Id"/> is the storage key (the configuration type's full name) and <see cref="Json"/> is the sparse
/// overlay JSON the WritableStore reads and writes.
/// </summary>
/// <remarks>
/// <para>
/// You normally never touch this type directly — <see cref="MartenStoreBackend"/> stores and loads it. It is public
/// so you can register it with Marten to control schema creation, e.g. <c>options.Schema.For&lt;CocoarConfigDocument&gt;()</c>,
/// rather than relying on Marten's runtime auto-creation.
/// </para>
/// <para>
/// With Marten database-per-tenant the document lives in the tenant's own database: the backend opens the session
/// for the current <c>accessor.Tenant</c>, so each tenant's configuration overlay is isolated by database.
/// </para>
/// </remarks>
public sealed class CocoarConfigDocument
{
/// <summary>The storage key — the configuration type's full name (e.g. <c>MyApp.Configuration.SmtpSettings</c>).</summary>
public string Id { get; set; } = default!;

/// <summary>The sparse overlay JSON (UTF-8 text) for this configuration type. Defaults to an empty object.</summary>
public string Json { get; set; } = "{}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text;
using Cocoar.Configuration.Providers;
using global::Marten;

namespace Cocoar.Configuration.WritableStore.Marten;

/// <summary>
/// An <see cref="IStoreBackend"/> that persists WritableStore overlays as <see cref="CocoarConfigDocument"/>
/// documents in a Marten (PostgreSQL) store. One document per configuration type, keyed by the type's full name.
/// </summary>
/// <remarks>
/// <para>
/// <b>Tenant routing.</b> When constructed with a non-empty <c>tenantId</c>, every read and write opens its session
/// for that tenant. With Marten database-per-tenant (a multi-tenanted <see cref="IDocumentStore"/>) this routes the
/// document into the tenant's own database — so each tenant's configuration lives in its own DB. A <c>null</c>/blank
/// tenant uses Marten's default tenant (the single-database case).
/// </para>
/// <para>
/// The backend is intentionally stateless and cheap to construct: it holds the (DI-singleton) document store and a
/// tenant id, and opens a short-lived session per operation. That is exactly what the service-backed
/// <c>FromStore</c> rule expects, so it can re-create the backend each recompute without connection-pool churn
/// (Marten/Npgsql owns the pool).
/// </para>
/// </remarks>
public sealed class MartenStoreBackend : IStoreBackend
{
private readonly IDocumentStore _store;
private readonly string? _tenantId;

/// <summary>
/// Creates a backend over the given Marten <paramref name="store"/>, optionally bound to a tenant.
/// </summary>
/// <param name="store">The Marten document store, typically resolved from DI as a singleton.</param>
/// <param name="tenantId">The tenant whose database to read from / write to. <c>null</c> or blank uses
/// Marten's default tenant. Pass <c>accessor.Tenant</c> from a tenant-scoped rule.</param>
public MartenStoreBackend(IDocumentStore store, string? tenantId = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_tenantId = string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
}

/// <inheritdoc />
public async Task<byte[]?> ReadAsync(string key, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);

await using var session = OpenQuerySession();
var document = await session.LoadAsync<CocoarConfigDocument>(key, ct).ConfigureAwait(false);
return document?.Json is { } json ? Encoding.UTF8.GetBytes(json) : null;
}

/// <inheritdoc />
public async Task WriteAsync(string key, byte[] data, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(data);

await using var session = OpenSession();
// Store is an upsert keyed by Id; SaveChangesAsync commits the single document in one transaction.
session.Store(new CocoarConfigDocument { Id = key, Json = Encoding.UTF8.GetString(data) });
await session.SaveChangesAsync(ct).ConfigureAwait(false);
}

private IQuerySession OpenQuerySession()
=> _tenantId is null ? _store.QuerySession() : _store.QuerySession(_tenantId);

private IDocumentSession OpenSession()
=> _tenantId is null ? _store.LightweightSession() : _store.LightweightSession(_tenantId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Cocoar.Configuration.DI;
using Cocoar.Configuration.Fluent;
using Cocoar.Configuration.Providers;
using global::Marten;
using Microsoft.Extensions.DependencyInjection;

namespace Cocoar.Configuration.WritableStore.Marten;

/// <summary>
/// Service-backed (Layer-2, ADR-006) rule factory that backs a WritableStore with Marten. Valid only inside
/// <c>UseServiceBackedConfiguration(...)</c> — the rule stays dormant until the host starts and resolves the Marten
/// <see cref="IDocumentStore"/> from the application container at recompute time.
/// </summary>
public static class MartenWritableStoreExtensions
{
/// <summary>
/// Backs the configuration type with a Marten (<see cref="MartenStoreBackend"/>) WritableStore. The Marten
/// <see cref="IDocumentStore"/> is resolved from DI and the current <c>accessor.Tenant</c> selects the tenant
/// database — combine with <c>.TenantScoped()</c> for per-tenant, database-per-tenant configuration:
/// <code>
/// builder.UseServiceBackedConfiguration(rules =>
/// [
/// rules.For&lt;TenantSettings&gt;().FromMartenStore().TenantScoped().Build(),
/// ]);
/// </code>
/// This also exposes the <c>IWritableStore&lt;TenantSettings&gt;</c> write facade (per tenant), since it reuses
/// the tenant-keyed WritableStore backend pipeline.
/// </summary>
/// <typeparam name="T">The configuration type to populate from Marten.</typeparam>
/// <param name="builder">The service-backed provider builder (from <c>UseServiceBackedConfiguration</c>).</param>
public static ProviderRuleBuilder<WritableStoreProvider, WritableStoreProviderOptions, WritableStoreProviderQueryOptions>
FromMartenStore<T>(this ServiceBackedProviderBuilder<T> builder)
where T : class
{
ArgumentNullException.ThrowIfNull(builder);

// Reuse the DI package's tenant-keyed, sp-gated FromStore seam: one MartenStoreBackend per tenant, each
// bound to accessor.Tenant so a write lands in that tenant's own database.
return builder.FromStore((sp, accessor) =>
new MartenStoreBackend(sp.GetRequiredService<IDocumentStore>(), accessor.Tenant));
}
}
2 changes: 2 additions & 0 deletions src/Cocoar.Configuration.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<Project Path="tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj" />
<Project Path="tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj" />
<Project Path="tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj" />
<Project Path="tests/Cocoar.Configuration.WritableStore.Marten.Tests/Cocoar.Configuration.WritableStore.Marten.Tests.csproj" />
<Project Path="tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj" />
<Project Path="tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj" />
</Folder>
Expand Down Expand Up @@ -38,6 +39,7 @@
<Project Path="Cocoar.Configuration.AspNetCore/Cocoar.Configuration.AspNetCore.csproj" />
<Project Path="Cocoar.Configuration.DI/Cocoar.Configuration.DI.csproj" Id="87b248db-0c23-4f1c-a6df-8707e4b74153" />
<Project Path="Cocoar.Configuration.Http/Cocoar.Configuration.Http.csproj" />
<Project Path="Cocoar.Configuration.WritableStore.Marten/Cocoar.Configuration.WritableStore.Marten.csproj" />
<Project Path="Cocoar.Configuration.MicrosoftAdapter/Cocoar.Configuration.MicrosoftAdapter.csproj" />
<Project Path="Cocoar.Configuration/Cocoar.Configuration.csproj" />
<Project Path="Cocoar.Configuration.Abstractions/Cocoar.Configuration.Abstractions.csproj" />
Expand Down
Loading
Loading