diff --git a/src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs b/src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs index 9febc073..3b386b7b 100644 --- a/src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs +++ b/src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs @@ -1,16 +1,23 @@ -using System.Text.Json; -using Npgsql; +using GenWave.MediaLibrary.Station; namespace GenWave.Host.Seeding; /// -/// backed directly by station.settings on the Station -/// connection — the same table +/// backed by against +/// the same station.settings table /// writes to, but reached through a separate, narrower seam so the marker key can never be /// allowlisted by accident (F27.10). +/// +/// gh-#406 slice 4: the raw station.settings row I/O this class used to open directly via +/// NpgsqlConnection now lives in — this class builds +/// that repository internally from the same it always took (no DI +/// wiring change; SafeLoopSeedServiceCollectionExtensions still constructs this type exactly +/// as before) and keeps only the marker-key scoping that is genuinely this store's own concern. /// public sealed class SafeLoopSeedMarkerStore(string connectionString) : ISafeLoopSeedMarkerStore { + readonly StationSettingsRepository repository = new(connectionString); + /// /// The marker key. Lives outside the Station:* config namespace (so it can never collide /// with a bound options section) and is absent from @@ -20,40 +27,15 @@ public sealed class SafeLoopSeedMarkerStore(string connectionString) : ISafeLoop public const string Key = "Internal:BootSeed:SafeLoopCompletedAt"; /// - public async Task ExistsAsync(CancellationToken ct) - { - await using var conn = new NpgsqlConnection(connectionString); - await conn.OpenAsync(ct); - - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM station.settings WHERE key = @key"; - cmd.Parameters.AddWithValue("key", Key); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - return await reader.ReadAsync(ct); - } + public Task ExistsAsync(CancellationToken ct) => repository.ExistsAsync(Key, ct); /// - public async Task MarkCompletedAsync(CancellationToken ct) + public Task MarkCompletedAsync(CancellationToken ct) { // The value carries a UTC timestamp for operator diagnosability (visible only via a direct - // psql query — never through the settings API); its content is otherwise unused. - var json = JsonSerializer.Serialize(DateTimeOffset.UtcNow); - - await using var conn = new NpgsqlConnection(connectionString); - await conn.OpenAsync(ct); - - await using var cmd = conn.CreateCommand(); - cmd.CommandText = - """ - INSERT INTO station.settings (key, value, updated_at) - VALUES (@key, @value::jsonb, now()) - ON CONFLICT (key) DO UPDATE - SET value = EXCLUDED.value, - updated_at = EXCLUDED.updated_at - """; - cmd.Parameters.AddWithValue("key", Key); - cmd.Parameters.AddWithValue("value", json); - await cmd.ExecuteNonQueryAsync(ct); + // psql query — never through the settings API); its content is otherwise unused. This is the + // same upsert WriteAsync already performs for every other settings key — no new repository + // SQL needed for the write side (gh-#406 slice 4 only added ExistsAsync). + return repository.WriteAsync(Key, DateTimeOffset.UtcNow, ct); } } diff --git a/src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs b/src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs index 58cfa66f..0daf6561 100644 --- a/src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs +++ b/src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs @@ -81,4 +81,25 @@ public async Task> ReadAllAsync(Cancellation return result; } + + /// + /// True if a row for exists in station.settings — added for gh-#406 + /// slice 4: GenWave.Host.Seeding.SafeLoopSeedMarkerStore's one-shot boot-seed marker check + /// (F27.10) needs a single-key existence probe, not the full unfiltered + /// scan. Any failure (including a ) propagates to the caller — + /// same posture as , degrade policy is a caller concern, not this + /// repository's. + /// + public async Task ExistsAsync(string key, CancellationToken ct) + { + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(ct); + + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM station.settings WHERE key = @key"; + cmd.Parameters.AddWithValue("key", key); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct); + } } diff --git a/tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs b/tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs index 74a2a85f..cae99b65 100644 --- a/tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs +++ b/tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs @@ -40,11 +40,5 @@ internal static class ExemptionBaseline "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."), - new ArchitectureExemption( - LawId.L2, - "GenWave.Host.Seeding.SafeLoopSeedMarkerStore", - "2026-08-07", - "Reads/writes the boot-seed marker directly via NpgsqlConnection on the station.settings " + - "table (F27.10). Pre-existing; not trivial to fix in this diff — follow-up gh-#406."), }; } diff --git a/tests/GenWave.MediaLibrary.Tests/Specs/Story042_StationSettingsRepository.cs b/tests/GenWave.MediaLibrary.Tests/Specs/Story042_StationSettingsRepository.cs index 85bcc2ab..c6d5a09a 100644 --- a/tests/GenWave.MediaLibrary.Tests/Specs/Story042_StationSettingsRepository.cs +++ b/tests/GenWave.MediaLibrary.Tests/Specs/Story042_StationSettingsRepository.cs @@ -8,6 +8,10 @@ // allowlist-filtered/degrade-on-DB-down behavior stays GenWave.Host.Tests' own coverage // (Story042_StationSettingsOverlayProvider.cs, FeatureStationSettingsOverlayProvider) — this // repository deliberately returns every row unfiltered and lets failures propagate. +// +// 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. using System.Text.Json; using Dapper; @@ -246,4 +250,57 @@ await Assert.ThrowsAsync(() => conn.ExecuteAsync( "insert into station.settings (key, value) values (null, '\"x\"'::jsonb)")); } } + + // --------------------------------------------------------------------- + // HAPPY PATH — ExistsAsync (gh-#406 slice 4) + // --------------------------------------------------------------------- + + [Collection(DatabaseCollection.Name)] + [Trait("Category", "Integration")] + public sealed class ScenarioExists(DatabaseFixture db) + { + [Fact] + public async Task AMissingKeyReportsFalse() + { + // Given no row for this key... + await db.ResetSettingsAsync(); + var repo = Repo(db); + + // When its existence is probed... + var exists = await repo.ExistsAsync("Internal:BootSeed:SafeLoopCompletedAt", CancellationToken.None); + + // Then it reports absent. + Assert.False(exists); + } + + [Fact] + public async Task AWrittenKeyReportsTrue() + { + // Given a row written under this key... + await db.ResetSettingsAsync(); + var repo = Repo(db); + await repo.WriteAsync("Internal:BootSeed:SafeLoopCompletedAt", DateTimeOffset.UtcNow, CancellationToken.None); + + // When its existence is probed... + var exists = await repo.ExistsAsync("Internal:BootSeed:SafeLoopCompletedAt", CancellationToken.None); + + // Then it reports present. + Assert.True(exists); + } + + [Fact] + public async Task ItOnlyReportsTheExactKeyProbedNotOtherRows() + { + // Given a row written under a different key... + await db.ResetSettingsAsync(); + var repo = Repo(db); + await repo.WriteAsync("Loudness:TargetLufs", -14.0, CancellationToken.None); + + // When a different key's existence is probed... + var exists = await repo.ExistsAsync("Internal:BootSeed:SafeLoopCompletedAt", CancellationToken.None); + + // Then it reports absent. + Assert.False(exists); + } + } }