From 900bfd02ae9b49aaa801c9515debe003c818dd3d Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 1 Jun 2026 09:07:48 +0200 Subject: [PATCH] feat: WritableStore PatchAsync (atomic batch writes) + case-insensitive layer merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PatchAsync to IWritableStore for atomic batch overlay writes: any number of Set/SetSecret/Reset mutations apply under one read-merge-write — one backend write, one recompute — instead of one per property. A 20-field form save no longer fires 20 recomputes, and subscribers never observe a half-applied state. For a DB-backed IStoreBackend this collapses N round-trips into one. - IWritableStorePatch builder: Set (value, incl. explicit null), SetSecret (pre-encrypted envelope), Reset. Presence-based semantics — present = set, absent = untouched, Reset = remove the override. - Sync + async (Func<.., Task>) overloads; the async one closes the async-void footgun and allows awaiting (e.g. secret encryption) inline. - Single-value SetAsync/SetSecretAsync/ResetAsync now delegate to PatchAsync. Fix: resetting a secret-typed member no longer throws (removing an override exposes no plaintext) — symmetry with SetSecretAsync. Internal cleanup (no public-API impact): pipeline layer merging is now case-insensitive on property names (Cocoar.Json.Mutable 1.2.0), consistent with how the effective config is read back (System.Text.Json case-insensitive, like IConfiguration). This removes the overlay's write-time base-casing alignment (Trap B): SparseOverlayMutator is reduced to thin MutableJsonPath glue (baseDom gone), and PatchAsync parses/serializes the overlay once per batch instead of once per property. Docs, changelog ([Unreleased]) and the WritableStoreExample updated. Co-Authored-By: Claude Opus 4.8 --- .../WritableStore/IWritableStore.cs | 15 ++ .../WritableStore/IWritableStorePatch.cs | 32 +++ .../Core/ConfigManager.cs | 2 +- .../Core/ConfigMergeOptions.cs | 14 ++ .../Core/ConfigurationEngine.cs | 4 +- .../Core/TenantPipeline.cs | 2 +- .../OverlayPathResolver.cs | 25 +- .../OverlaySerialization.cs | 4 + .../SparseOverlayMutator.cs | 147 +++--------- .../StorePatchBuilder.cs | 62 +++++ .../WritableStoreAdapter.cs | 213 ++++++++++-------- .../Rules/AggregateRuleManager.cs | 2 +- src/Directory.Packages.props | 2 +- src/Examples/WritableStoreExample/Program.cs | 12 + .../SparseOverlayMutatorTests.cs | 44 ++-- .../WritableStore/WritableStorePatchTests.cs | 186 +++++++++++++++ website/changelog.md | 17 +- website/guide/providers/writable-store.md | 69 +++++- 18 files changed, 594 insertions(+), 258 deletions(-) create mode 100644 src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStorePatch.cs create mode 100644 src/Cocoar.Configuration/Core/ConfigMergeOptions.cs create mode 100644 src/Cocoar.Configuration/Providers/WritableStoreProvider/StorePatchBuilder.cs create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStorePatchTests.cs diff --git a/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStore.cs b/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStore.cs index d9774f5..bae12ff 100644 --- a/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStore.cs +++ b/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStore.cs @@ -67,6 +67,21 @@ public interface IWritableStore where T : class /// Task> DescribeAsync(CancellationToken ct = default); + /// + /// Applies one or more property mutations atomically: one lock acquisition, one write to storage, + /// one recompute. Prefer this over multiple calls whenever more than + /// one property changes together (e.g. a form save), so a 20-field save triggers one recompute, not 20. + /// The mutations are applied when returns — there is no separate commit step. + /// + Task PatchAsync(Action> configure, CancellationToken ct = default); + + /// + /// Async-callback overload of + /// for when gathering values is asynchronous (e.g. await encrypting a secret envelope inline). + /// The mutations are applied after the returned task completes. + /// + Task PatchAsync(Func, Task> configureAsync, CancellationToken ct = default); + /// /// The raw, key-path overlay surface for dynamic or non-expressible paths. /// diff --git a/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStorePatch.cs b/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStorePatch.cs new file mode 100644 index 0000000..30bdb07 --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/WritableStore/IWritableStorePatch.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.WritableStore; + +/// +/// Fluent builder for a batched, atomic store write. Collect one or more mutations via +/// , , or ; they are all applied in call order, +/// in one lock acquisition — one write to storage, one recompute — when the PatchAsync callback returns. +/// There is no separate commit step. +/// +/// Semantics: calling sets the value (including an explicit ); not +/// calling it leaves the property untouched; removes the override entirely. Mapping any +/// external input (HTTP body, an Optional<T> DTO, …) onto these calls is the caller's concern. +/// +/// +/// The configuration type this patch targets. +public interface IWritableStorePatch where T : class +{ + /// Sets a single non-secret property. + /// The selector targets a secret-typed member or contains one — use . + [SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", + Justification = "'Set' is the established fluent setter verb for this problem (EF Core SetProperty, MongoDB/Marten Set); renaming hurts the common path.")] + IWritableStorePatch Set(Expression> selector, TValue value); + + /// Sets a pre-encrypted secret envelope for a secret-typed member. Mirrors IWritableStore<T>.SetSecretAsync. + IWritableStorePatch SetSecret(Expression>> selector, SecretEnvelope envelope); + + /// Removes the override for a single property (secret members included), restoring inheritance from lower layers. + IWritableStorePatch Reset(Expression> selector); +} diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index ecbfe2b..766334a 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -298,7 +298,7 @@ internal MutableJsonObject BuildBaseJson(Type configType, Func +/// Shared merge policy for the configuration pipeline: layer merging matches property names +/// case-insensitively. This is consistent with how the effective config is read back +/// (System.Text.Json case-insensitive, like IConfiguration), and it removes the need to align an +/// overlay's key casing to the lower layers at write time — a layer's own casing no longer matters. +/// +internal static class ConfigMergeOptions +{ + internal static readonly MutableJsonMergeOptions CaseInsensitive = new() { PropertyNameCaseInsensitive = true }; +} diff --git a/src/Cocoar.Configuration/Core/ConfigurationEngine.cs b/src/Cocoar.Configuration/Core/ConfigurationEngine.cs index 7242d23..dfcd2bd 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationEngine.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationEngine.cs @@ -411,7 +411,7 @@ private void RestorePrefixContributions( } var mergedConfig = GetOrCreateMergedConfig(mergedConfigs, ruleManager.TypeDefinition); - MutableJsonMerge.Merge(mergedConfig, lastContribution); + MutableJsonMerge.Merge(mergedConfig, lastContribution, ConfigMergeOptions.CaseInsensitive); _state.UpdateConfiguration(ruleManager.TypeDefinition, mergedConfig); } @@ -500,7 +500,7 @@ private void ProcessRuleResult( // Lock on mergedConfig to prevent readers from serializing while we're merging lock (mergedConfig) { - MutableJsonMerge.Merge(mergedConfig, newContribution); + MutableJsonMerge.Merge(mergedConfig, newContribution, ConfigMergeOptions.CaseInsensitive); } ruleManager.LastJsonContribution = newContribution; diff --git a/src/Cocoar.Configuration/Core/TenantPipeline.cs b/src/Cocoar.Configuration/Core/TenantPipeline.cs index bbef4a8..48d9700 100644 --- a/src/Cocoar.Configuration/Core/TenantPipeline.cs +++ b/src/Cocoar.Configuration/Core/TenantPipeline.cs @@ -129,7 +129,7 @@ public MutableJsonObject BuildBaseJson(Type configType, Func if (manager.LastJsonContribution is { } contribution) { - MutableJsonMerge.Merge(merged, contribution); + MutableJsonMerge.Merge(merged, contribution, ConfigMergeOptions.CaseInsensitive); } } diff --git a/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlayPathResolver.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlayPathResolver.cs index add145c..2850b61 100644 --- a/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlayPathResolver.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlayPathResolver.cs @@ -12,29 +12,36 @@ namespace Cocoar.Configuration.Providers; /// internal static class OverlayPathResolver { + /// Non-generic overload used by the batch-patch adapter when mutations are stored as . + internal static string ResolveKeyPath(LambdaExpression selector, Type rootType, bool allowSecretMembers = false) + { + ArgumentNullException.ThrowIfNull(selector); + return ResolveCore(selector.Body, selector.ToString(), allowSecretMembers); + } + internal static string ResolveKeyPath(Expression> selector, bool allowSecretMembers = false) { ArgumentNullException.ThrowIfNull(selector); + return ResolveCore(selector.Body, selector.ToString(), allowSecretMembers); + } - var body = Unwrap(selector.Body); + private static string ResolveCore(Expression body, string selectorText, bool allowSecretMembers) + { + body = Unwrap(body)!; var members = new List(); var current = body; while (current is MemberExpression memberExpression) { if (memberExpression.Member is not PropertyInfo and not FieldInfo) - { - throw Unsupported(selector); - } + throw Unsupported(selectorText); members.Add(memberExpression.Member); current = Unwrap(memberExpression.Expression); } if (current is not ParameterExpression || members.Count == 0) - { - throw Unsupported(selector); - } + throw Unsupported(selectorText); members.Reverse(); // root → leaf @@ -148,9 +155,9 @@ private static bool ContainsSecret(Type? type, HashSet visited) return false; } - private static NotSupportedException Unsupported(Expression> selector) + private static NotSupportedException Unsupported(string selectorText) => new( - $"Selector '{selector}' is not supported. Only simple member-access chains are allowed " + + $"Selector '{selectorText}' is not supported. Only simple member-access chains are allowed " + "(no method calls, indexers, or array element access). " + "Use the raw Overlay surface for dynamic key paths."); } diff --git a/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlaySerialization.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlaySerialization.cs index 36f3478..4d9265b 100644 --- a/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlaySerialization.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/OverlaySerialization.cs @@ -41,4 +41,8 @@ private static JsonSerializerOptions CreateReadOptions() /// internal static JsonNode? SerializeValue(TValue value) => JsonSerializer.SerializeToNode(value, WriteOptions); + + /// Non-generic overload for runtime-typed values (used by the batch-patch adapter). + internal static JsonNode? SerializeValue(object? value, Type valueType) + => JsonSerializer.SerializeToNode(value, valueType, WriteOptions); } diff --git a/src/Cocoar.Configuration/Providers/WritableStoreProvider/SparseOverlayMutator.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/SparseOverlayMutator.cs index 6a5bbc9..375df60 100644 --- a/src/Cocoar.Configuration/Providers/WritableStoreProvider/SparseOverlayMutator.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/SparseOverlayMutator.cs @@ -5,52 +5,43 @@ namespace Cocoar.Configuration.Providers; /// -/// Applies sparse mutations to the persisted overlay bytes: setting a single leaf (creating intermediate -/// objects on demand) and removing a leaf (pruning emptied ancestors). Key casing is aligned to the existing -/// overlay first, then to the base layers (Trap B), so an override lands byte-identically on the lower-layer -/// key and acts as an override rather than a sibling. +/// Thin glue between the overlay's persisted bytes / values and the +/// primitives: set a sparse leaf (creating intermediate objects) and remove a +/// leaf (pruning emptied ancestors). Keys are matched case-insensitively. +/// +/// Key-casing alignment to the lower layers is intentionally not done here: the pipeline merge is +/// case-insensitive (), so an overlay key overrides the base key +/// regardless of casing — the overlay's own casing no longer matters. +/// /// internal static class SparseOverlayMutator { - /// - /// Returns new overlay bytes with set at . - /// A writes an explicit JSON-null leaf. - /// - internal static byte[] Set(byte[] currentBytes, string keyPath, JsonNode? valueNode, MutableJsonObject? baseDom) - { - var root = MutableJsonDocument.Parse(currentBytes) as MutableJsonObject ?? new MutableJsonObject(); - var segments = keyPath.Split('.'); + private static readonly MutableJsonPathOptions SetOptions = + new() { PropertyNameCaseInsensitive = true }; - var parent = root; - var baseObj = baseDom; + private static readonly MutableJsonRemovePathOptions RemoveOptions = + new() { PropertyNameCaseInsensitive = true, PruneEmptyAncestors = true }; - for (var i = 0; i < segments.Length - 1; i++) - { - var name = ResolveName(parent, baseObj, segments[i]); + // ---------------------------------------------------------------- in-memory (batch: parse once, write once) - if (TryGetChild(parent, name) is not MutableJsonObject child) - { - child = new MutableJsonObject(); - parent.Set(name, child); - } + internal static void Set(MutableJsonObject root, string keyPath, JsonNode? value) + => root.SetAtPath(keyPath.Split('.'), ToMutable(value), SetOptions); - parent = child; - // Descend the base by the BASE's own key for this segment (case-insensitive), independent of the - // name chosen for the overlay — so leaf casing keeps aligning to the base even if an intermediate - // overlay key has drifted from the base's casing (Trap B, resolve against the base position). - baseObj = DescendBase(baseObj, segments[i]); - } + internal static bool Remove(MutableJsonObject root, string keyPath) + => root.RemoveAtPath(keyPath.Split('.'), RemoveOptions); - var leafName = ResolveName(parent, baseObj, segments[^1]); - parent.Set(leafName, ToMutable(valueNode)); + internal static MutableJsonObject Parse(byte[] bytes) + => MutableJsonDocument.Parse(bytes) as MutableJsonObject ?? new MutableJsonObject(); + // ---------------------------------------------------------------- byte wrappers (single raw-overlay ops) + + internal static byte[] Set(byte[] currentBytes, string keyPath, JsonNode? value) + { + var root = Parse(currentBytes); + Set(root, keyPath, value); return MutableJsonDocument.ToUtf8Bytes(root); } - /// - /// Returns new overlay bytes with the leaf at removed and any ancestors that - /// became empty pruned. The boolean indicates whether anything was removed (idempotent no-op if absent). - /// internal static (byte[] Bytes, bool Removed) Remove(byte[] currentBytes, string keyPath) { if (MutableJsonDocument.Parse(currentBytes) is not MutableJsonObject root) @@ -58,95 +49,13 @@ internal static (byte[] Bytes, bool Removed) Remove(byte[] currentBytes, string return (currentBytes, false); } - var segments = keyPath.Split('.'); - var chain = new List<(MutableJsonObject Parent, string Name)>(segments.Length - 1); - var parent = root; - - for (var i = 0; i < segments.Length - 1; i++) - { - var name = FindExistingName(parent, segments[i]); - if (name is null || TryGetChild(parent, name) is not MutableJsonObject child) - { - return (currentBytes, false); // path not present → not overridden - } - - chain.Add((parent, name)); - parent = child; - } - - var leafName = FindExistingName(parent, segments[^1]); - if (leafName is null) - { - return (currentBytes, false); - } - - parent.Remove(leafName); - - // Prune empty ancestors bottom-up so ReadOverlay / provenance stays clean. - for (var i = chain.Count - 1; i >= 0; i--) - { - var (ancestor, name) = chain[i]; - if (TryGetChild(ancestor, name) is MutableJsonObject obj && obj.Properties.Count == 0) - { - ancestor.Remove(name); - } - else - { - break; - } - } - - return (MutableJsonDocument.ToUtf8Bytes(root), true); + return Remove(root, keyPath) + ? (MutableJsonDocument.ToUtf8Bytes(root), true) + : (currentBytes, false); } private static MutableJsonNode ToMutable(JsonNode? node) => node is null ? MutableJsonNull.Instance : MutableJsonDocument.Parse(Encoding.UTF8.GetBytes(node.ToJsonString())); - - /// - /// Resolves the exact key name to write: reuse an existing overlay key (avoid casing-variant siblings), - /// else reuse the base layer's key casing (Trap B), else fall back to the supplied default. - /// - private static string ResolveName(MutableJsonObject overlayParent, MutableJsonObject? baseObj, string defaultName) - => FindExistingName(overlayParent, defaultName) - ?? (baseObj is null ? null : FindExistingName(baseObj, defaultName)) - ?? defaultName; - - private static MutableJsonObject? DescendBase(MutableJsonObject? baseObj, string segment) - { - if (baseObj is null) - { - return null; - } - - var baseName = FindExistingName(baseObj, segment); - return baseName is not null ? TryGetChild(baseObj, baseName) as MutableJsonObject : null; - } - - private static string? FindExistingName(MutableJsonObject obj, string name) - { - foreach (var property in obj.Properties) - { - if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) - { - return property.Name; - } - } - - return null; - } - - private static MutableJsonNode? TryGetChild(MutableJsonObject obj, string exactName) - { - foreach (var property in obj.Properties) - { - if (string.Equals(property.Name, exactName, StringComparison.Ordinal)) - { - return property.Value; - } - } - - return null; - } } diff --git a/src/Cocoar.Configuration/Providers/WritableStoreProvider/StorePatchBuilder.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/StorePatchBuilder.cs new file mode 100644 index 0000000..ea14dce --- /dev/null +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/StorePatchBuilder.cs @@ -0,0 +1,62 @@ +using System.Linq.Expressions; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Configuration.WritableStore; + +namespace Cocoar.Configuration.Providers; + +/// +/// Implements — collects typed and raw mutations for a single +/// atomic PatchAsync commit. Not thread-safe; one instance per PatchAsync call. +/// +internal sealed class StorePatchBuilder : IWritableStorePatch where T : class +{ + // One ordered list of steps (typed sets/resets and JSON applies). Resolved in call order so that + // last-write-wins reflects the order the caller actually wrote — including across Set and ApplyJson. + internal readonly List Mutations = []; + + public IWritableStorePatch Set(Expression> selector, TValue value) + { + ArgumentNullException.ThrowIfNull(selector); + if (OverlayPathResolver.ContainsSecret(typeof(TValue))) + throw new NotSupportedException( + $"Cannot store a value of type '{typeof(TValue).Name}' because it is, or contains, a secret. " + + "Use SetSecret with a pre-encrypted SecretEnvelope for secret members."); + Mutations.Add(new TypedSetMutation(selector, value, typeof(TValue))); + return this; + } + + public IWritableStorePatch SetSecret(Expression>> selector, SecretEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(selector); + ArgumentNullException.ThrowIfNull(envelope); + Mutations.Add(new TypedSecretMutation(selector, envelope)); + return this; + } + + public IWritableStorePatch Reset(Expression> selector) + { + ArgumentNullException.ThrowIfNull(selector); + Mutations.Add(new TypedResetMutation(selector)); + return this; + } +} + +internal abstract class StorePatchMutation { } + +internal sealed class TypedSetMutation(LambdaExpression selector, object? value, Type valueType) : StorePatchMutation +{ + internal LambdaExpression Selector => selector; + internal object? Value => value; + internal Type ValueType => valueType; +} + +internal sealed class TypedSecretMutation(LambdaExpression selector, object envelope) : StorePatchMutation +{ + internal LambdaExpression Selector => selector; + internal object Envelope => envelope; +} + +internal sealed class TypedResetMutation(LambdaExpression selector) : StorePatchMutation +{ + internal LambdaExpression Selector => selector; +} diff --git a/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreAdapter.cs b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreAdapter.cs index 19e1062..bcbd298 100644 --- a/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreAdapter.cs +++ b/src/Cocoar.Configuration/Providers/WritableStoreProvider/WritableStoreAdapter.cs @@ -13,9 +13,9 @@ namespace Cocoar.Configuration.Providers; /// /// Implements both the type-safe facade and the raw /// surface over a single . -/// All writes are sparse (only the touched leaf is persisted) and go through the store's atomic -/// read-transform-write lock; provenance is computed from the base layers, the merged effective value, -/// and the persisted overlay. +/// All writes are sparse (only the touched leaf is persisted). PatchAsync applies any number +/// of mutations under one atomic read-transform-write — one write, one recompute — and the single-value +/// shorthands (SetAsync etc.) delegate to it. /// internal sealed class WritableStoreAdapter : IWritableStore, IWritableStoreOverlay, IDisposable where T : class @@ -33,80 +33,59 @@ public WritableStoreAdapter(IWritableStoreHost host, WritableStoreState store) public IWritableStoreOverlay Overlay => this; - // ---------------------------------------------------------------- typed facade + // ---------------------------------------------------------------- single-value shorthands public Task SetAsync(Expression> selector, TValue value, CancellationToken ct = default) - { - if (OverlayPathResolver.ContainsSecret(typeof(TValue))) - { - throw new NotSupportedException( - $"Cannot store a value of type '{typeof(TValue).Name}' via SetAsync because it is, or contains, a secret. " + - "A secret would be serialized as plaintext (or lost). Set secret members individually via " + - "SetSecretAsync with a pre-encrypted SecretEnvelope."); - } + => PatchAsync(b => b.Set(selector, value), ct); - var keyPath = OverlayPathResolver.ResolveKeyPath(selector); - var node = OverlaySerialization.SerializeValue(value); - return SetAsync(keyPath, node, ct); - } + public Task SetSecretAsync(Expression>> selector, SecretEnvelope envelope, CancellationToken ct = default) + => PatchAsync(b => b.SetSecret(selector, envelope), ct); public Task ResetAsync(Expression> selector, CancellationToken ct = default) { - var keyPath = OverlayPathResolver.ResolveKeyPath(selector); + // Resetting a secret member is safe — it only removes the overlay key (no plaintext is written). + var keyPath = OverlayPathResolver.ResolveKeyPath(selector, allowSecretMembers: true); return ResetAsync(keyPath, ct); } - public Task SetSecretAsync(Expression>> selector, SecretEnvelope envelope, CancellationToken ct = default) + // ---------------------------------------------------------------- batch patch + + public Task PatchAsync(Action> configure, CancellationToken ct = default) { - ArgumentNullException.ThrowIfNull(envelope); - // Secret members are allowed here ONLY because the value is a pre-encrypted envelope (validated below). - var keyPath = OverlayPathResolver.ResolveKeyPath(selector, allowSecretMembers: true); - var node = JsonSerializer.SerializeToNode(envelope)!; - return SetSecretEnvelopeAsync(keyPath, node, ct); + ArgumentNullException.ThrowIfNull(configure); + var builder = new StorePatchBuilder(); + configure(builder); + return CommitAsync(builder, ct); } - public async Task ReadAsync(CancellationToken ct = default) + public async Task PatchAsync(Func, Task> configureAsync, CancellationToken ct = default) { - var bytes = await _store.ReadBytesAsync(ct).ConfigureAwait(false); - if (bytes.Length <= 2) - { - return null; - } - - return JsonSerializer.Deserialize(bytes, OverlaySerialization.ReadOptions); + ArgumentNullException.ThrowIfNull(configureAsync); + var builder = new StorePatchBuilder(); + await configureAsync(builder).ConfigureAwait(false); + await CommitAsync(builder, ct).ConfigureAwait(false); } - public async Task> DescribeAsync(CancellationToken ct = default) + private Task CommitAsync(StorePatchBuilder builder, CancellationToken ct) { - var baseElement = ToJsonElement(_host.BuildBaseJson(typeof(T), IsThisLayer)); - var effective = _host.GetConfigAsJson(typeof(T)); - var overlayNode = await ReadOverlayAsync(ct).ConfigureAwait(false); + // Resolve typed selectors outside the lock (reflection / expression walking). + var resolved = ResolveMutations(builder); + if (resolved.Count == 0) + return Task.CompletedTask; - var overriddenPaths = new HashSet(StringComparer.Ordinal); - if (overlayNode is JsonObject overlayObject) - { - CollectOverlayPaths(overlayObject, null, overriddenPaths); - } - - var allPaths = new SortedSet(StringComparer.Ordinal); - CollectLeafPaths(baseElement, null, allPaths); - if (effective is { } effectiveElement) + return _store.UpdateBytesAsync(currentBytes => { - CollectLeafPaths(effectiveElement, null, allPaths); - } - allPaths.UnionWith(overriddenPaths); - - var entries = new List(allPaths.Count); - foreach (var path in allPaths) - { - JsonElement? baseValue = TrySelect(baseElement, path, out var bv) ? bv : null; - JsonElement? effectiveValue = - effective is { } e && TrySelect(e, path, out var ev) ? ev : null; - - entries.Add(new StoreEntry(path, baseValue, effectiveValue, overriddenPaths.Contains(path))); - } - - return entries; + // Parse the overlay once, apply every mutation in-memory, serialize once. + var root = SparseOverlayMutator.Parse(currentBytes); + foreach (var op in resolved) + { + if (op.IsReset) + SparseOverlayMutator.Remove(root, op.KeyPath); + else + SparseOverlayMutator.Set(root, op.KeyPath, op.Value); + } + return MutableJsonDocument.ToUtf8Bytes(root); + }, ct); } // ---------------------------------------------------------------- raw overlay surface @@ -114,8 +93,7 @@ public async Task> DescribeAsync(CancellationToken ct public async Task SetAsync(string keyPath, JsonNode? value, CancellationToken ct = default) { ValidateKeyPath(keyPath); - var baseDom = _host.BuildBaseJson(typeof(T), IsThisLayer); - await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, value, baseDom), ct) + await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, value), ct) .ConfigureAwait(false); } @@ -135,8 +113,7 @@ public async Task SetSecretEnvelopeAsync(string keyPath, JsonNode envelope, Canc nameof(envelope)); } - var baseDom = _host.BuildBaseJson(typeof(T), IsThisLayer); - await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, envelope, baseDom), ct) + await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, envelope), ct) .ConfigureAwait(false); } @@ -156,41 +133,93 @@ await _store.UpdateBytesAsync(bytes => public Task ClearAsync(CancellationToken ct = default) => _store.WriteBytesAsync("{}"u8.ToArray(), ct); - public async Task ReadOverlayAsync(CancellationToken ct = default) + public async Task ReadAsync(CancellationToken ct = default) { var bytes = await _store.ReadBytesAsync(ct).ConfigureAwait(false); if (bytes.Length <= 2) - { return null; + return JsonSerializer.Deserialize(bytes, OverlaySerialization.ReadOptions); + } + + public async Task> DescribeAsync(CancellationToken ct = default) + { + var baseElement = ToJsonElement(_host.BuildBaseJson(typeof(T), IsThisLayer)); + var effective = _host.GetConfigAsJson(typeof(T)); + var overlayNode = await ReadOverlayAsync(ct).ConfigureAwait(false); + + // Case-insensitive: the pipeline merge is case-insensitive, so the overlay may store a key in a + // different casing than the base/effective. Treat them as the same provenance entry. + var overriddenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + if (overlayNode is JsonObject overlayObject) + CollectOverlayPaths(overlayObject, null, overriddenPaths); + + var allPaths = new SortedSet(StringComparer.OrdinalIgnoreCase); + CollectLeafPaths(baseElement, null, allPaths); + if (effective is { } effectiveElement) + CollectLeafPaths(effectiveElement, null, allPaths); + allPaths.UnionWith(overriddenPaths); + + var entries = new List(allPaths.Count); + foreach (var path in allPaths) + { + JsonElement? baseValue = TrySelect(baseElement, path, out var bv) ? bv : null; + JsonElement? effectiveValue = effective is { } e && TrySelect(e, path, out var ev) ? ev : null; + entries.Add(new StoreEntry(path, baseValue, effectiveValue, overriddenPaths.Contains(path))); } - return JsonNode.Parse(bytes); + return entries; } - public void Dispose() => _store.Dispose(); + public async Task ReadOverlayAsync(CancellationToken ct = default) + { + var bytes = await _store.ReadBytesAsync(ct).ConfigureAwait(false); + return bytes.Length <= 2 ? null : JsonNode.Parse(bytes); + } - // ---------------------------------------------------------------- helpers + public void Dispose() => _store.Dispose(); - private bool IsThisLayer(IRuleManager manager) - => manager.CurrentProvider is WritableStoreProvider provider && ReferenceEquals(provider.Store, _store); + // ---------------------------------------------------------------- mutation resolution - private static void ValidateKeyPath(string keyPath) + private List ResolveMutations(StorePatchBuilder builder) { - if (string.IsNullOrWhiteSpace(keyPath)) - { - throw new ArgumentException("Key path must be a non-empty, dotted property path.", nameof(keyPath)); - } + var ops = new List(builder.Mutations.Count + 8); - foreach (var segment in keyPath.Split('.')) + foreach (var mutation in builder.Mutations) { - if (string.IsNullOrWhiteSpace(segment)) + switch (mutation) { - throw new ArgumentException( - $"Key path '{keyPath}' contains an empty segment.", nameof(keyPath)); + case TypedSetMutation set: + { + var keyPath = OverlayPathResolver.ResolveKeyPath(set.Selector, typeof(T), allowSecretMembers: false); + var node = OverlaySerialization.SerializeValue(set.Value, set.ValueType); + ops.Add(new ResolvedPatchOperation(keyPath, node, IsReset: false)); + break; + } + case TypedSecretMutation secret: + { + var keyPath = OverlayPathResolver.ResolveKeyPath(secret.Selector, typeof(T), allowSecretMembers: true); + var node = JsonSerializer.SerializeToNode(secret.Envelope)!; + ops.Add(new ResolvedPatchOperation(keyPath, node, IsReset: false)); + break; + } + case TypedResetMutation reset: + { + // Resetting a secret member is safe — only the overlay key is removed. + var keyPath = OverlayPathResolver.ResolveKeyPath(reset.Selector, typeof(T), allowSecretMembers: true); + ops.Add(new ResolvedPatchOperation(keyPath, Value: null, IsReset: true)); + break; + } } } + + return ops; } + // ---------------------------------------------------------------- helpers + + private bool IsThisLayer(IRuleManager manager) + => manager.CurrentProvider is WritableStoreProvider provider && ReferenceEquals(provider.Store, _store); + private static JsonElement ToJsonElement(MutableJsonObject obj) { var bytes = MutableJsonDocument.ToUtf8Bytes(obj); @@ -198,6 +227,17 @@ private static JsonElement ToJsonElement(MutableJsonObject obj) return document.RootElement.Clone(); } + private static void ValidateKeyPath(string keyPath) + { + if (string.IsNullOrWhiteSpace(keyPath)) + throw new ArgumentException("Key path must be a non-empty, dotted property path.", nameof(keyPath)); + foreach (var segment in keyPath.Split('.')) + { + if (string.IsNullOrWhiteSpace(segment)) + throw new ArgumentException($"Key path '{keyPath}' contains an empty segment.", nameof(keyPath)); + } + } + private static void CollectLeafPaths(JsonElement element, string? prefix, ISet paths) { if (element.ValueKind == JsonValueKind.Object) @@ -207,15 +247,10 @@ private static void CollectLeafPaths(JsonElement element, string? prefix, ISet paths) @@ -224,14 +259,9 @@ private static void CollectOverlayPaths(JsonObject node, string? prefix, ISetA resolved, key-path-based operation ready for . +internal sealed record ResolvedPatchOperation(string KeyPath, JsonNode? Value, bool IsReset); diff --git a/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs b/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs index f2ff66e..956d4f7 100644 --- a/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs +++ b/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs @@ -97,7 +97,7 @@ public AggregateRuleManager(AggregateConfigRule rule, ILogger logger, ProviderRe if (node is MutableJsonObject obj && obj.Properties.Count > 0) { merged ??= new MutableJsonObject(); - MutableJsonMerge.Merge(merged, obj); + MutableJsonMerge.Merge(merged, obj, Core.ConfigMergeOptions.CaseInsensitive); anyContributed = true; } } diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 4425be1..62f2af7 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,7 +5,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Examples/WritableStoreExample/Program.cs b/src/Examples/WritableStoreExample/Program.cs index d642815..38fe68c 100644 --- a/src/Examples/WritableStoreExample/Program.cs +++ b/src/Examples/WritableStoreExample/Program.cs @@ -71,6 +71,18 @@ public static async Task Main() await storage.ClearAsync(); await WaitUntilAsync(() => !manager.GetConfig()!.UseSsl); Print("After ClearAsync()", manager.GetConfig()!); + + // Batch write: change several fields in ONE atomic call → ONE write, ONE recompute. + // (A form save changes N fields together; PatchAsync avoids N separate recomputes, + // and subscribers never observe a half-applied state.) + await storage.PatchAsync(b => b + .Set(x => x.Host, "smtp.batch.example.com") + .Set(x => x.Port, 2525) + .Set(x => x.UseSsl, true)); + await WaitUntilAsync(() => manager.GetConfig()!.Port == 2525); + Print("After PatchAsync (3 fields, 1 recompute)", manager.GetConfig()!); + + await storage.ClearAsync(); // tidy up } private static void Print(string label, SmtpSettings s) => diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/SparseOverlayMutatorTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/SparseOverlayMutatorTests.cs index ca4cefc..238baa6 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/SparseOverlayMutatorTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/SparseOverlayMutatorTests.cs @@ -2,7 +2,6 @@ using System.Text.Json; using System.Text.Json.Nodes; using Cocoar.Configuration.Providers; -using Cocoar.Json.Mutable; using Xunit; namespace Cocoar.Configuration.Providers.Tests.WritableStore; @@ -11,9 +10,6 @@ public class SparseOverlayMutatorTests { private static byte[] Empty => "{}"u8.ToArray(); - private static MutableJsonObject Base(string json) - => (MutableJsonObject)MutableJsonDocument.Parse(Encoding.UTF8.GetBytes(json)); - private static JsonElement Parse(byte[] bytes) { using var doc = JsonDocument.Parse(bytes); @@ -24,7 +20,7 @@ private static JsonElement Parse(byte[] bytes) [Trait("Type", "Unit")] public void Set_CreatesNestedSparsePath_OnlyTouchedLeaf() { - var result = SparseOverlayMutator.Set(Empty, "Nested.Count", JsonValue.Create(7), baseDom: null); + var result = SparseOverlayMutator.Set(Empty, "Nested.Count", JsonValue.Create(7)); var root = Parse(result); Assert.Equal(7, root.GetProperty("Nested").GetProperty("Count").GetInt32()); @@ -35,42 +31,36 @@ public void Set_CreatesNestedSparsePath_OnlyTouchedLeaf() [Fact] [Trait("Type", "Unit")] - public void Set_AlignsCasingToBase_CamelCaseBase() + public void Set_KeepsGivenCasing_NoAlignment() { - var baseDom = Base("{\"smtp\":{\"port\":25}}"); - - var result = SparseOverlayMutator.Set(Empty, "Smtp.Port", JsonValue.Create(587), baseDom); + // The overlay keeps whatever casing the caller used; aligning to a lower layer is now the pipeline + // merge's job (case-insensitive), not the mutator's. + var result = SparseOverlayMutator.Set(Empty, "Smtp.Port", JsonValue.Create(587)); var root = Parse(result); - // The override must land on the base's exact key casing, not create PascalCase siblings. - Assert.True(root.TryGetProperty("smtp", out var smtp)); - Assert.Equal(587, smtp.GetProperty("port").GetInt32()); - Assert.False(root.TryGetProperty("Smtp", out _)); + Assert.Equal(587, root.GetProperty("Smtp").GetProperty("Port").GetInt32()); } [Fact] [Trait("Type", "Unit")] - public void Set_DescendsBaseByBaseKey_NotDriftedOverlayKey() + public void Set_CaseInsensitiveMatch_UpdatesExistingKey_NoSibling() { - // The overlay already holds an intermediate key that is byte-different from the base's key - // (case-insensitively equal). The base descent must still follow the BASE's casing so the new - // leaf aligns to the base layer's deeper key, independent of the drifted overlay key's casing. - var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"host\":\"h\"}}"); - var baseDom = Base("{\"smtp\":{\"port\":25}}"); + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"Port\":25}}"); - var result = SparseOverlayMutator.Set(seeded, "Smtp.Port", JsonValue.Create(587), baseDom); + var result = SparseOverlayMutator.Set(seeded, "smtp.port", JsonValue.Create(587)); var root = Parse(result); - // Leaf aligned to the base's "port" casing — not the resolver default "Port". - Assert.Equal(587, root.GetProperty("Smtp").GetProperty("port").GetInt32()); - Assert.False(root.GetProperty("Smtp").TryGetProperty("Port", out _)); + // Existing key matched case-insensitively → its value updated, casing preserved, no PascalCase/lowercase sibling. + Assert.Equal(587, root.GetProperty("Smtp").GetProperty("Port").GetInt32()); + Assert.False(root.TryGetProperty("smtp", out _)); + Assert.False(root.GetProperty("Smtp").TryGetProperty("port", out _)); } [Fact] [Trait("Type", "Unit")] public void Set_ExplicitNull_WritesJsonNull() { - var result = SparseOverlayMutator.Set(Empty, "Host", valueNode: null, baseDom: null); + var result = SparseOverlayMutator.Set(Empty, "Host", null); var root = Parse(result); Assert.Equal(JsonValueKind.Null, root.GetProperty("Host").ValueKind); @@ -80,7 +70,7 @@ public void Set_ExplicitNull_WritesJsonNull() [Trait("Type", "Unit")] public void Set_DefaultValuedLeaf_IsPersisted() { - var result = SparseOverlayMutator.Set(Empty, "Port", JsonValue.Create(0), baseDom: null); + var result = SparseOverlayMutator.Set(Empty, "Port", JsonValue.Create(0)); var root = Parse(result); Assert.True(root.TryGetProperty("Port", out var port)); @@ -91,8 +81,8 @@ public void Set_DefaultValuedLeaf_IsPersisted() [Trait("Type", "Unit")] public void Set_SecondLeafUnderSameParent_KeepsBoth() { - var first = SparseOverlayMutator.Set(Empty, "Smtp.Port", JsonValue.Create(587), baseDom: null); - var second = SparseOverlayMutator.Set(first, "Smtp.Host", JsonValue.Create("h"), baseDom: null); + var first = SparseOverlayMutator.Set(Empty, "Smtp.Port", JsonValue.Create(587)); + var second = SparseOverlayMutator.Set(first, "Smtp.Host", JsonValue.Create("h")); var root = Parse(second); Assert.Equal(587, root.GetProperty("Smtp").GetProperty("Port").GetInt32()); diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStorePatchTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStorePatchTests.cs new file mode 100644 index 0000000..b27226b --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/WritableStore/WritableStorePatchTests.cs @@ -0,0 +1,186 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.WritableStore; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Cocoar.Configuration.Rules; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.WritableStore; + +[Trait("Type", "Unit")] +public sealed class WritableStorePatchTests : IDisposable +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + private readonly List _disposables = []; + + private (ServiceProvider Provider, IWritableStore Storage, ConfigManager Manager) Build( + string baseJson, IStoreBackend? backend = null) + { + backend ??= new InMemoryBackend(); + var file = TempFileHelper.Create(baseJson); + _disposables.Add(file); + + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromFile(file.FilePath).Required(), + rules.For().FromStore(backend), + })); + + var provider = services.BuildServiceProvider(); + _disposables.Add(provider); + + return (provider, provider.GetRequiredService>(), provider.GetRequiredService()); + } + + // ---------------------------------------------------------------- PatchAsync — batch + + [Fact] + public async Task PatchAsync_SetsMultipleProperties_Atomically() + { + var (_, storage, manager) = Build("{\"Host\":\"old.host\",\"Port\":25,\"UseSsl\":false}"); + + await storage.PatchAsync(b => b + .Set(x => x.Host, "new.host") + .Set(x => x.Port, 587) + .Set(x => x.UseSsl, true)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "Port updated"); + + var config = manager.GetConfig()!; + Assert.Equal("new.host", config.Host); + Assert.Equal(587, config.Port); + Assert.True(config.UseSsl); + } + + [Fact] + public async Task PatchAsync_SingleRecompute_ForWholeBatch() + { + var (provider, storage, manager) = Build("{\"Host\":\"h\",\"Port\":25,\"UseSsl\":false}"); + + var reactive = provider.GetRequiredService>(); + var emissions = 0; + using var sub = reactive.Subscribe(_ => Interlocked.Increment(ref emissions)); + var baseline = Volatile.Read(ref emissions); + + await storage.PatchAsync(b => b + .Set(x => x.Host, "new.host") + .Set(x => x.Port, 587) + .Set(x => x.UseSsl, true)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "batch applied"); + + // A 3-property batch must produce exactly one new emission, not three. + Assert.Equal(baseline + 1, Volatile.Read(ref emissions)); + } + + [Fact] + public async Task PatchAsync_SingleSet_Works() + { + var (_, storage, manager) = Build("{\"Host\":\"h\",\"Port\":25}"); + + await storage.PatchAsync(b => b.Set(x => x.Port, 465)); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 465, Timeout, description: "Port updated"); + + Assert.Equal(465, manager.GetConfig()!.Port); + Assert.Equal("h", manager.GetConfig()!.Host); // unchanged + } + + [Fact] + public async Task PatchAsync_WithReset_RemovesOverride() + { + var (_, storage, manager) = Build("{\"Host\":\"default.host\",\"Port\":25}"); + + await storage.PatchAsync(b => b.Set(x => x.Port, 587)); + await ActiveWaitHelpers.WaitUntilAsync(() => manager.GetConfig()!.Port == 587, Timeout, description: "set"); + + await storage.PatchAsync(b => b.Reset(x => x.Port)); + await ActiveWaitHelpers.WaitUntilAsync(() => manager.GetConfig()!.Port == 25, Timeout, description: "reset"); + + Assert.Equal(25, manager.GetConfig()!.Port); + } + + [Fact] + public async Task PatchAsync_MixedSetAndReset_InOneBatch() + { + var (_, storage, manager) = Build("{\"Host\":\"default.host\",\"Port\":25,\"UseSsl\":false}"); + + await storage.SetAsync(x => x.Host, "overridden.host"); + await ActiveWaitHelpers.WaitUntilAsync(() => manager.GetConfig()!.Host == "overridden.host", Timeout, description: "host set"); + + await storage.PatchAsync(b => b + .Reset(x => x.Host) // back to default + .Set(x => x.Port, 2525)); // override + await ActiveWaitHelpers.WaitUntilAsync(() => manager.GetConfig()!.Port == 2525, Timeout, description: "mixed"); + + var config = manager.GetConfig()!; + Assert.Equal("default.host", config.Host); // reset to inherited + Assert.Equal(2525, config.Port); + } + + [Fact] + public async Task SetAsync_StillWorks_DelegatesToPatch() + { + var (_, storage, manager) = Build("{\"Host\":\"h\",\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 993); + + await ActiveWaitHelpers.WaitUntilAsync(() => manager.GetConfig()!.Port == 993, Timeout, description: "set"); + Assert.Equal(993, manager.GetConfig()!.Port); + } + + // ---------------------------------------------------------------- PatchAsync — async overload + + [Fact] + public async Task PatchAsync_AsyncOverload_GathersValuesAsync() + { + var (_, storage, manager) = Build("{\"Host\":\"h\",\"Port\":25}"); + + await storage.PatchAsync(async b => + { + var port = await Task.FromResult(587); // stand-in for async work (e.g. encrypting a secret) + b.Set(x => x.Port, port).Set(x => x.Host, "async.host"); + }); + + await ActiveWaitHelpers.WaitUntilAsync(() => manager.GetConfig()!.Port == 587, Timeout, description: "async overload"); + + var config = manager.GetConfig()!; + Assert.Equal(587, config.Port); + Assert.Equal("async.host", config.Host); + } + + [Fact] + public async Task PatchAsync_ResetSecretPath_IsAllowed() + { + // Symmetry: a secret can be SET via the overlay, so it must also be resettable. Removing the override + // is safe (no plaintext is written), so Reset on a secret member must NOT throw NotSupportedException. + var file = TempFileHelper.Create("{\"Plain\":\"base\"}"); + _disposables.Add(file); + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromFile(file.FilePath).Required(), + rules.For().FromStore(new InMemoryBackend()), + })); + var provider = services.BuildServiceProvider(); + _disposables.Add(provider); + var store = provider.GetRequiredService>(); + + var patchEx = await Record.ExceptionAsync(() => store.PatchAsync(b => b.Reset(x => x.ApiKey!))); + Assert.Null(patchEx); + + var resetEx = await Record.ExceptionAsync(() => store.ResetAsync(x => x.ApiKey!)); + Assert.Null(resetEx); + } + + public void Dispose() + { + foreach (var d in _disposables) d.Dispose(); + } +} diff --git a/website/changelog.md b/website/changelog.md index 70a3ea6..abc84ee 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -1,5 +1,20 @@ # Changelog +## [Unreleased] + +### Added + +**WritableStore — batch writes (`PatchAsync`)** +- `IWritableStore.PatchAsync(b => b.Set(...).SetSecret(...).Reset(...))` applies any number of mutations as **one** atomic write and **one** recompute — a form save no longer fires one recompute (and one backend round-trip) per field. The single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` now delegate to it. +- Async overload `PatchAsync(async b => …)` for when gathering values is asynchronous (e.g. encrypting a `SecretEnvelope` inline). +- New `IWritableStorePatch` builder: `Set` (value, including an explicit `null`), `SetSecret` (pre-encrypted envelope), `Reset`. Presence-based semantics — present = set, absent = untouched, `Reset` = remove the override. + +### Fixed +- Resetting a secret-typed member (`ResetAsync(x => x.Secret)`, or `Reset` inside a patch) no longer throws `NotSupportedException` — removing an override exposes no plaintext, so it is now allowed (symmetry with `SetSecretAsync`). + +### 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 (System.Text.Json case-insensitive, like `IConfiguration`). A higher layer overrides a lower-layer key regardless of casing — no more case-variant sibling keys. Invisible to typed access; internal-only for most consumers. + ## [5.1.0] — 2026-05-31 ### Added @@ -12,7 +27,7 @@ - `DescribeAsync()` returns per-key provenance (`StoreEntry`: base, effective, `IsSet`) for management UIs - `.FromStore()` rule extension; file-based backend by default, pluggable `IStoreBackend` - `IWritableStore` / `IWritableStoreOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging -- Secret-typed members cannot be overridden via WritableStore (throws `NotSupportedException`) +- Secret-typed members: a *plaintext* override throws `NotSupportedException`; override them via `SetSecretAsync` with a pre-encrypted `SecretEnvelope` - `IProviderServiceRegistration` gained resolve-time factory registration support **Multi-Tenancy** (ADR-005) diff --git a/website/guide/providers/writable-store.md b/website/guide/providers/writable-store.md index d7110f4..da4953e 100644 --- a/website/guide/providers/writable-store.md +++ b/website/guide/providers/writable-store.md @@ -88,6 +88,53 @@ JsonNode? raw = await storage.Overlay.ReadOverlayAsync(); // the raw s `ReadAsync` returns only what the overlay holds — **not** the merged result. For the effective value use `IReactiveConfig.CurrentValue` or `IConfigurationAccessor.GetConfig()`. +## Batch writes — one save, one recompute + +When more than one value changes together (a form save, an import), batch them with `PatchAsync`. Every mutation is applied under a **single** atomic read-merge-write — one write to the backend, one recompute — instead of one per property: + +```csharp +await storage.PatchAsync(b => b + .Set(x => x.Host, "smtp.example.com") + .Set(x => x.Port, 587) + .Set(x => x.UseSsl, true) + .Reset(x => x.Timeout)); // mix sets and resets freely +``` + +A 20-field form save triggers **one** recompute and **one** backend write, not 20 — and subscribers of `IReactiveConfig` never observe a half-applied state. For a database-backed `IStoreBackend` this also collapses 20 round-trips into a single transaction. + +The single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` are thin shorthands over `PatchAsync` for the one-property case. + +### Write semantics — presence-based + +There is no "magic null": what you call is exactly what happens. + +| In the patch | Effect | +|---|---| +| `Set(x => x.Host, "v")` | sets the value | +| `Set(x => x.Host, null)` | sets an **explicit `null`** — only compiles where `null` is valid for the member (`string?`, `int?`, …) | +| *(Set not called)* | the property is **left untouched** | +| `Reset(x => x.Host)` | **removes** the override (restores inheritance) | + +This is the only model that lets you set `null` explicitly *and* delete an override — they are different operations. Mapping external input (an HTTP body, an `Optional` DTO's presence flags, …) onto these calls is **your** code's job; the library stays typed and never guesses. + +### Secrets in a batch + +Secret-typed members use `SetSecret` with a pre-encrypted [envelope](/guide/secrets/client-encryption): + +```csharp +await storage.PatchAsync(b => b + .Set(x => x.Port, 587) + .SetSecret(x => x.ApiKey, envelope)); +``` + +When gathering values is itself asynchronous (e.g. encrypting the envelope), use the async overload so you can `await` inside: + +```csharp +await storage.PatchAsync(async b => + b.Set(x => x.Port, 587) + .SetSecret(x => x.ApiKey, await EncryptAsync(apiKey))); +``` + ## Provenance for a management UI `DescribeAsync()` returns, per key, the base value, the effective value, and whether it is currently overridden — everything a "default vs. override, with reset" UI needs: @@ -109,19 +156,19 @@ foreach (var entry in await storage.DescribeAsync()) ## Raw overlay surface -For dynamic or non-expressible paths, use `IWritableStoreOverlay` (also resolvable directly from DI, or via `storage.Overlay`). Key paths are dotted; their segments must match the persisted JSON property names: +For dynamic or non-expressible paths, use `IWritableStoreOverlay` (also resolvable directly from DI, or via `storage.Overlay`). Key paths are dotted; their segments correspond to the JSON property names: ```csharp await storage.Overlay.SetAsync("Smtp.Port", JsonValue.Create(587)); await storage.Overlay.ResetAsync("Smtp.Port"); ``` -The typed facade aligns key casing to the lower layers for you; with the raw surface that responsibility is yours. Do **not** use the raw surface for secret paths. +Key-path segments match the lower layers **case-insensitively** (the pipeline merges layers case-insensitively), so an override lands on the existing key regardless of casing — no need to mirror the exact casing of the base. Do **not** use the raw surface for secret paths. ## Arrays and secrets - **Arrays are replaced wholesale.** `SetAsync(x => x.Hosts, list)` overrides the entire array — there is no element-level merge. Per-element selectors (`x => x.Hosts[2]`) are rejected. -- **Secrets are not overridable.** Members typed as `Secret` / `ISecret` throw `NotSupportedException` on the typed facade — an overlay write would replace the encrypted secret with a mask. Manage secrets via the Secrets CLI/provider. +- **Secrets need a pre-encrypted envelope.** A *plaintext* write of a `Secret` / `ISecret` member (via `Set` / `SetAsync`) throws `NotSupportedException` — it would persist the secret in the clear. To override a secret, use `SetSecret` (in a patch) or `SetSecretAsync` with a pre-encrypted [`SecretEnvelope`](/guide/secrets/client-encryption). Resetting a secret override **is** allowed — it only removes the key and exposes no plaintext. ## Writing your own endpoints @@ -142,6 +189,18 @@ app.MapPut("/admin/smtp/port", async ( }) .RequireAuthorization("AdminPolicy"); +// A full form save — many fields at once, one atomic write, one recompute: +app.MapPut("/admin/smtp", async (SmtpForm form, IWritableStore storage) => +{ + // validate/normalize `form` here, then map your DTO onto the typed patch: + await storage.PatchAsync(b => b + .Set(x => x.Host, form.Host) + .Set(x => x.Port, form.Port) + .Set(x => x.UseSsl, form.UseSsl)); + return Results.NoContent(); +}) +.RequireAuthorization("AdminPolicy"); + // Expose the provenance view for a management UI: app.MapGet("/admin/smtp", (IWritableStore storage, CancellationToken ct) => storage.DescribeAsync(ct)); @@ -172,10 +231,10 @@ rules.For().FromStore((accessor, current) => ``` IWritableStore.SetAsync(x => x.Port, 587) - → resolve "Port" to a dotted key path + align casing to the lower layers + → resolve "Port" to a dotted key path → atomically read-merge-write the sparse overlay leaf to the backend → signal the provider's change observable - → engine recompute (debounced) merges layers byte-for-byte + → engine recompute (debounced) merges layers byte-for-byte, case-insensitively → IReactiveConfig emits the new effective value ```