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
@@ -1,7 +1,8 @@
using System.Data.Common;
using System.Text.Json;
using GenWave.MediaLibrary.Station;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
using Npgsql;

namespace GenWave.Host.Configuration;

Expand All @@ -16,10 +17,18 @@ namespace GenWave.Host.Configuration;
/// Call <see cref="Reload"/> (or expose via <see cref="IStationSettingsStore.WriteAsync"/>) to
/// raise the change token and trigger <see cref="Microsoft.Extensions.Options.IOptionsMonitor{T}"/>
/// re-binding without an API restart.
///
/// gh-#406 slice 5: the raw <c>station.settings</c> row I/O this class used to open directly via
/// <c>NpgsqlConnection</c> now lives in <see cref="StationSettingsRepository"/>
/// (<see cref="StationSettingsRepository.ReadAllForBoot"/> documents why that method — not
/// <see cref="StationSettingsRepository.ReadAllAsync"/> — is the one this class calls). Every other
/// behavior below (allowlist filtering, array expansion, the degrade-to-empty-overlay posture,
/// stderr diagnostics) stays exactly as it was.
/// </summary>
public class StationSettingsConfigurationProvider : IConfigurationProvider, IDisposable
{
readonly string connectionString;
readonly StationSettingsRepository repository;

// The mutable data bag surfaced to IConfiguration.
IDictionary<string, string?> data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
Expand All @@ -30,6 +39,14 @@ public class StationSettingsConfigurationProvider : IConfigurationProvider, IDis
public StationSettingsConfigurationProvider(string connectionString)
{
this.connectionString = connectionString;

// Constructed directly here, not resolved through DI: Load() (below) is called by the
// configuration system while Microsoft.Extensions.Configuration.IConfigurationBuilder itself
// is being built, before WebApplicationBuilder.Build() ever creates a container capable of
// constructing — let alone injecting — a repository. StationSettingsRepository's
// plain-connection-string ctor exists precisely for this pre-DI boot path (see that class's
// own remarks, gh-#406 slice 3/4/5).
repository = new StationSettingsRepository(connectionString);
}

// ── IConfigurationProvider ─────────────────────────────────────────────
Expand Down Expand Up @@ -72,10 +89,10 @@ public virtual void Load()
// Treat the same as a DB that is temporarily unreachable: the overlay is empty and
// env/appsettings defaults apply. This guard prevents the Npgsql library from throwing
// InvalidOperationException ("ConnectionString not initialized") before attempting a
// TCP connection — an exception type that the NpgsqlException catch below does not cover.
// TCP connection — an exception type that the DbException catch below does not cover.
if (string.IsNullOrWhiteSpace(connectionString))
{
// Match the NpgsqlException catch below: surface the degradation so an accidentally
// Match the DbException catch below: surface the degradation so an accidentally
// empty Station connection string in a real deploy is observable, not silent.
Console.Error.WriteLine("[station-settings] no Station connection string; using config defaults");
data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
Expand All @@ -85,32 +102,32 @@ public virtual void Load()
var loaded = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
try
{
using var conn = new NpgsqlConnection(connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT key, value FROM station.settings";
using var reader = cmd.ExecuteReader();
while (reader.Read())
var rows = repository.ReadAllForBoot();
foreach (var (key, jsonValue) in rows)
{
var key = reader.GetString(0);
if (!StationSettingsAllowlist.ByKey.ContainsKey(key))
continue; // paranoid guard: skip anything not on the allowlist

var jsonValue = reader.GetString(1);
var scalar = ExtractScalar(jsonValue);
if (scalar is not null)
loaded[key] = scalar;
else
ExtractArrayItems(loaded, key, jsonValue);
}
}
catch (NpgsqlException ex)
catch (DbException ex)
{
// No station schema yet, wrong password, DB down — none of these should prevent boot.
// Defaults from env/appsettings continue to apply; the overlay is empty until the
// DB is reachable and station.settings is populated.
// A logger is not injectable here (provider builds before DI), so we surface the
// failure via stderr so operators can diagnose without exposing connection secrets.
//
// Catches System.Data.Common.DbException — the provider-neutral ADO.NET base type
// Npgsql.NpgsqlException itself derives from — rather than NpgsqlException directly:
// this class carries no Npgsql reference at all now that StationSettingsRepository owns
// the Postgres specifics (gh-#406 slice 5), and DbException catches every failure the
// original catch did (and nothing broader).
Console.Error.WriteLine($"[station-settings] overlay load failed; using config defaults: {ex.Message}");
}

Expand Down
31 changes: 31 additions & 0 deletions src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,35 @@ public async Task<bool> ExistsAsync(string key, CancellationToken ct)
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct);
}

/// <summary>
/// SYNCHRONOUS read of every row in <c>station.settings</c> — the one deliberate sync exception
/// in this otherwise async-only repository, added for gh-#406 slice 5.
/// <c>GenWave.Host.Configuration.StationSettingsConfigurationProvider.Load()</c> implements
/// <see cref="Microsoft.Extensions.Configuration.IConfigurationProvider.Load"/>, a synchronous
/// contract member the configuration system calls while
/// <see cref="Microsoft.Extensions.Configuration.IConfigurationBuilder"/> itself is still being
/// built — the same pre-DI boot path this class's plain-connection-string ctor exists for — and
/// has no async entry point available there to await <see cref="ReadAllAsync"/> from. SQL is
/// byte-identical to <see cref="ReadAllAsync"/>'s; only the sync/async shape differs. Any failure
/// (including a <see cref="Npgsql.NpgsqlException"/>) propagates to the caller — same posture as
/// <see cref="ReadAllAsync"/>/<see cref="ExistsAsync"/>, degrade policy is a caller concern, not
/// this repository's.
/// </summary>
public IReadOnlyDictionary<string, string> ReadAllForBoot()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

using var conn = new NpgsqlConnection(connectionString);
conn.Open();

using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT key, value FROM station.settings";

using var reader = cmd.ExecuteReader();
while (reader.Read())
result[reader.GetString(0)] = reader.GetString(1);

return result;
}
}
24 changes: 7 additions & 17 deletions tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ namespace GenWave.Architecture.Tests.Support;

/// <summary>
/// The F105.2 adoption baseline (PLAN T211): every pre-existing law violation found when this
/// suite went live, named, dated, and reasoned. Two of the seven L2 entries are the law's own
/// designed exemption (constructing <c>NpgsqlDataSource</c> is composition-root wiring, not
/// querying — ARCHITECTURE.md "Architecture governance"); the other five are pre-existing debt that
/// was not trivial to fix in this diff (moving working, already-tested Host code into
/// MediaLibrary's repository layer is a real refactor, not a using-swap) — tracked as gh-#406.
/// A violation whose member is not on this list still fails (STORY-290 AC6) — see
/// <see cref="DependencyLawAssert"/>.
/// suite went live, named, dated, and reasoned. The two entries here are the law's own designed
/// exemption (constructing <c>NpgsqlDataSource</c> is composition-root wiring, not querying —
/// ARCHITECTURE.md "Architecture governance"); the five 2026-08-07 debt rows that once sat
/// alongside them were burned down via gh-#406 (2026-08-13) — every one of those types now reaches
/// Postgres through a <c>GenWave.MediaLibrary.Station</c> repository instead of opening a raw
/// <c>NpgsqlConnection</c> itself. A violation whose member is not on this list still fails
/// (STORY-290 AC6) — see <see cref="DependencyLawAssert"/>.
/// </summary>
internal static class ExemptionBaseline
{
Expand All @@ -30,15 +30,5 @@ internal static class ExemptionBaseline
"MediaLibrary's own module composition root (AddMediaLibrary): builds the library_svc " +
"NpgsqlDataSource and sets Dapper's static DefaultTypeMap — wiring/global config, never a " +
"query, same wiring-not-querying exemption as the Host composition root."),

// ── Pre-existing debt: genuine querying/exception-coupling outside the repository layer,
// found at T211 adoption, not trivial to fix in this diff (follow-up filed as gh-#406).
new ArchitectureExemption(
LawId.L2,
"GenWave.Host.Configuration.StationSettingsConfigurationProvider",
"2026-08-07",
"IConfigurationProvider.Load() queries station.settings directly via NpgsqlConnection at " +
"boot, before the DI container (and any MediaLibrary repository) exists to inject. " +
"Pre-existing (STORY-042); not trivial to fix in this diff — follow-up gh-#406."),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
// gh-#406 slice 4 added ExistsAsync (single-key existence probe) for
// GenWave.Host.Seeding.SafeLoopSeedMarkerStore's boot-seed marker check (F27.10) — its own
// coverage is the FeatureExistsAsync section below.
//
// gh-#406 slice 5 added ReadAllForBoot (the sync exception, SQL byte-identical to ReadAllAsync)
// for GenWave.Host.Configuration.StationSettingsConfigurationProvider.Load(), the synchronous
// IConfigurationProvider contract member — its own coverage is the ScenarioReadAllForBoot section
// below.

using System.Text.Json;
using Dapper;
Expand Down Expand Up @@ -303,4 +308,58 @@ public async Task ItOnlyReportsTheExactKeyProbedNotOtherRows()
Assert.False(exists);
}
}

// ---------------------------------------------------------------------
// HAPPY PATH — ReadAllForBoot, the sync exception (gh-#406 slice 5)
// ---------------------------------------------------------------------

[Collection(DatabaseCollection.Name)]
[Trait("Category", "Integration")]
public sealed class ScenarioReadAllForBoot(DatabaseFixture db)
{
[Fact]
public async Task AnEmptyTableReadsAsAnEmptyDictionary()
{
// Given no rows at all...
await db.ResetSettingsAsync();
var repo = Repo(db);

// When every row is read synchronously...
var rows = repo.ReadAllForBoot();

// Then nothing comes back.
Assert.Empty(rows);
}

[Fact]
public async Task AWrittenValueIsReadBackSynchronously()
{
// Given a value written through the async side...
await db.ResetSettingsAsync();
var repo = Repo(db);
await repo.WriteAsync("Loudness:TargetLufs", -14.0, CancellationToken.None);

// When every row is read synchronously...
var rows = repo.ReadAllForBoot();

// Then the written value comes back exactly, same shape ReadAllAsync would return.
Assert.Equal(JsonSerializer.Serialize(-14.0), rows["Loudness:TargetLufs"]);
}

[Fact]
public async Task KeysAreLookedUpCaseInsensitivelyJustLikeReadAllAsync()
{
// Given a row stored under its canonical casing...
await db.ResetSettingsAsync();
await InsertRawRowAsync(db, "Station:Theme", "\"midnight\"");
var repo = Repo(db);

// When every row is read synchronously...
var rows = repo.ReadAllForBoot();

// Then a differently-cased lookup still finds it.
Assert.True(rows.TryGetValue("station:theme", out var value));
Assert.Equal("\"midnight\"", value);
}
}
}
Loading