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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ public interface IWritableStore<T> where T : class
/// </summary>
Task<IReadOnlyList<StoreEntry>> DescribeAsync(CancellationToken ct = default);

/// <summary>
/// Applies one or more property mutations atomically: one lock acquisition, one write to storage,
/// one recompute. Prefer this over multiple <see cref="SetAsync{TValue}"/> 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 <paramref name="configure"/> returns — there is no separate commit step.
/// </summary>
Task PatchAsync(Action<IWritableStorePatch<T>> configure, CancellationToken ct = default);

/// <summary>
/// Async-callback overload of <see cref="PatchAsync(Action{IWritableStorePatch{T}}, CancellationToken)"/>
/// for when gathering values is asynchronous (e.g. <c>await</c> encrypting a secret envelope inline).
/// The mutations are applied after the returned task completes.
/// </summary>
Task PatchAsync(Func<IWritableStorePatch<T>, Task> configureAsync, CancellationToken ct = default);

/// <summary>
/// The raw, key-path overlay surface for dynamic or non-expressible paths.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using Cocoar.Configuration.Secrets.SecretTypes;

namespace Cocoar.Configuration.WritableStore;

/// <summary>
/// Fluent builder for a batched, atomic store write. Collect one or more mutations via
/// <see cref="Set"/>, <see cref="SetSecret"/>, or <see cref="Reset"/>; they are all applied in call order,
/// in one lock acquisition — one write to storage, one recompute — when the <c>PatchAsync</c> callback returns.
/// There is no separate commit step.
/// <para>
/// Semantics: calling <see cref="Set"/> sets the value (including an explicit <see langword="null"/>); not
/// calling it leaves the property untouched; <see cref="Reset"/> removes the override entirely. Mapping any
/// external input (HTTP body, an <c>Optional&lt;T&gt;</c> DTO, …) onto these calls is the caller's concern.
/// </para>
/// </summary>
/// <typeparam name="T">The configuration type this patch targets.</typeparam>
public interface IWritableStorePatch<T> where T : class
{
/// <summary>Sets a single non-secret property.</summary>
/// <exception cref="NotSupportedException">The selector targets a secret-typed member or contains one — use <see cref="SetSecret"/>.</exception>
[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<T> Set<TValue>(Expression<Func<T, TValue>> selector, TValue value);

/// <summary>Sets a pre-encrypted secret envelope for a secret-typed member. Mirrors <c>IWritableStore&lt;T&gt;.SetSecretAsync</c>.</summary>
IWritableStorePatch<T> SetSecret<TSecret>(Expression<Func<T, ISecret<TSecret>>> selector, SecretEnvelope<TSecret> envelope);

/// <summary>Removes the override for a single property (secret members included), restoring inheritance from lower layers.</summary>
IWritableStorePatch<T> Reset<TValue>(Expression<Func<T, TValue>> selector);
}
2 changes: 1 addition & 1 deletion src/Cocoar.Configuration/Core/ConfigManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@
/// <inheritdoc cref="GetConfig{T}"/>
public object GetConfig(Type type) => _accessor.GetConfig(type);

/// <inheritdoc cref="TryGetConfig{T}(out T?)"/>

Check warning on line 252 in src/Cocoar.Configuration/Core/ConfigManager.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

XML comment has cref attribute 'TryGetConfig{T}(out T?)' that could not be resolved

Check warning on line 252 in src/Cocoar.Configuration/Core/ConfigManager.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

XML comment has cref attribute 'TryGetConfig{T}(out T?)' that could not be resolved
public bool TryGetConfig(Type type, out object? value) => _accessor.TryGetConfig(type, out value);

#pragma warning disable CS0618 // Type or member is obsolete
Expand Down Expand Up @@ -298,7 +298,7 @@

if (manager.LastJsonContribution is { } contribution)
{
MutableJsonMerge.Merge(merged, contribution);
MutableJsonMerge.Merge(merged, contribution, ConfigMergeOptions.CaseInsensitive);
}
}

Expand Down
14 changes: 14 additions & 0 deletions src/Cocoar.Configuration/Core/ConfigMergeOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Cocoar.Json.Mutable;

namespace Cocoar.Configuration.Core;

/// <summary>
/// Shared merge policy for the configuration pipeline: layer merging matches property names
/// <em>case-insensitively</em>. This is consistent with how the effective config is read back
/// (System.Text.Json case-insensitive, like <c>IConfiguration</c>), 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.
/// </summary>
internal static class ConfigMergeOptions
{
internal static readonly MutableJsonMergeOptions CaseInsensitive = new() { PropertyNameCaseInsensitive = true };
}
4 changes: 2 additions & 2 deletions src/Cocoar.Configuration/Core/ConfigurationEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@
}

var mergedConfig = GetOrCreateMergedConfig(mergedConfigs, ruleManager.TypeDefinition);
MutableJsonMerge.Merge(mergedConfig, lastContribution);
MutableJsonMerge.Merge(mergedConfig, lastContribution, ConfigMergeOptions.CaseInsensitive);

_state.UpdateConfiguration(ruleManager.TypeDefinition, mergedConfig);
}
Expand Down Expand Up @@ -500,7 +500,7 @@
// 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;
Expand All @@ -521,7 +521,7 @@
}

private void CreateChangeSubscriptions(
IReadOnlyList<IRuleManager> ruleManagers,

Check warning on line 524 in src/Cocoar.Configuration/Core/ConfigurationEngine.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Change type of parameter 'ruleManagers' from 'System.Collections.Generic.IReadOnlyList<Cocoar.Configuration.Rules.IRuleManager>' to 'System.Collections.Generic.List<Cocoar.Configuration.Rules.IRuleManager>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)
Action<int> recomputeFromIndexCallback,
int debounceMilliseconds)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Cocoar.Configuration/Core/TenantPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
/// provider option factories. For the global pipeline this is the owning <see cref="ConfigManager"/>
/// (byte-identical to before); for a tenant pipeline it is the tenant's own accessor.
/// </param>
internal void Initialize(IConfigurationAccessor recomputeAccessor, Action<int> scheduleRecompute)

Check warning on line 92 in src/Cocoar.Configuration/Core/TenantPipeline.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Parameter 'scheduleRecompute' has no matching param tag in the XML comment for 'TenantPipeline.Initialize(IConfigurationAccessor, Action<int>)' (but other parameters do)
{
Engine.InitializeAndCompute(
Rules, RuleManagers, ProviderRegistry, recomputeAccessor,
Expand Down Expand Up @@ -129,7 +129,7 @@

if (manager.LastJsonContribution is { } contribution)
{
MutableJsonMerge.Merge(merged, contribution);
MutableJsonMerge.Merge(merged, contribution, ConfigMergeOptions.CaseInsensitive);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,36 @@ namespace Cocoar.Configuration.Providers;
/// </summary>
internal static class OverlayPathResolver
{
/// <summary>Non-generic overload used by the batch-patch adapter when mutations are stored as <see cref="LambdaExpression"/>.</summary>
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<T, TValue>(Expression<Func<T, TValue>> 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<MemberInfo>();
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

Expand Down Expand Up @@ -148,9 +155,9 @@ private static bool ContainsSecret(Type? type, HashSet<Type> visited)
return false;
}

private static NotSupportedException Unsupported<T, TValue>(Expression<Func<T, TValue>> 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.");
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,8 @@ private static JsonSerializerOptions CreateReadOptions()
/// </summary>
internal static JsonNode? SerializeValue<TValue>(TValue value)
=> JsonSerializer.SerializeToNode(value, WriteOptions);

/// <summary>Non-generic overload for runtime-typed values (used by the batch-patch adapter).</summary>
internal static JsonNode? SerializeValue(object? value, Type valueType)
=> JsonSerializer.SerializeToNode(value, valueType, WriteOptions);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,148 +5,57 @@
namespace Cocoar.Configuration.Providers;

/// <summary>
/// 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 / <see cref="JsonNode"/> values and the
/// <see cref="MutableJsonPath"/> primitives: set a sparse leaf (creating intermediate objects) and remove a
/// leaf (pruning emptied ancestors). Keys are matched case-insensitively.
/// <para>
/// Key-casing alignment to the lower layers is intentionally <em>not</em> done here: the pipeline merge is
/// case-insensitive (<see cref="Core.ConfigMergeOptions"/>), so an overlay key overrides the base key
/// regardless of casing — the overlay's own casing no longer matters.
/// </para>
/// </summary>
internal static class SparseOverlayMutator
{
/// <summary>
/// Returns new overlay bytes with <paramref name="valueNode"/> set at <paramref name="keyPath"/>.
/// A <see langword="null"/> <paramref name="valueNode"/> writes an explicit JSON-null leaf.
/// </summary>
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);
}

/// <summary>
/// Returns new overlay bytes with the leaf at <paramref name="keyPath"/> removed and any ancestors that
/// became empty pruned. The boolean indicates whether anything was removed (idempotent no-op if absent).
/// </summary>
internal static (byte[] Bytes, bool Removed) Remove(byte[] currentBytes, string keyPath)
{
if (MutableJsonDocument.Parse(currentBytes) is not MutableJsonObject root)
{
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()));

/// <summary>
/// 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.
/// </summary>
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;
}
}
Loading
Loading