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
52 changes: 17 additions & 35 deletions src/GenWave.Host/Seeding/SafeLoopSeedMarkerStore.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
using System.Text.Json;
using Npgsql;
using GenWave.MediaLibrary.Station;

namespace GenWave.Host.Seeding;

/// <summary>
/// <see cref="ISafeLoopSeedMarkerStore"/> backed directly by <c>station.settings</c> on the Station
/// connection — the same table <see cref="GenWave.Host.Configuration.StationSettingsStore"/>
/// <see cref="ISafeLoopSeedMarkerStore"/> backed by <see cref="StationSettingsRepository"/> against
/// the same <c>station.settings</c> table <see cref="GenWave.Host.Configuration.StationSettingsStore"/>
/// 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 <c>station.settings</c> row I/O this class used to open directly via
/// <c>NpgsqlConnection</c> now lives in <see cref="StationSettingsRepository"/> — this class builds
/// that repository internally from the same <see cref="connectionString"/> it always took (no DI
/// wiring change; <c>SafeLoopSeedServiceCollectionExtensions</c> still constructs this type exactly
/// as before) and keeps only the marker-key scoping that is genuinely this store's own concern.
/// </summary>
public sealed class SafeLoopSeedMarkerStore(string connectionString) : ISafeLoopSeedMarkerStore
{
readonly StationSettingsRepository repository = new(connectionString);

/// <summary>
/// The marker key. Lives outside the <c>Station:*</c> config namespace (so it can never collide
/// with a bound options section) and is absent from
Expand All @@ -20,40 +27,15 @@ public sealed class SafeLoopSeedMarkerStore(string connectionString) : ISafeLoop
public const string Key = "Internal:BootSeed:SafeLoopCompletedAt";

/// <inheritdoc/>
public async Task<bool> 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<bool> ExistsAsync(CancellationToken ct) => repository.ExistsAsync(Key, ct);

/// <inheritdoc/>
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);
}
}
21 changes: 21 additions & 0 deletions src/GenWave.MediaLibrary/Station/StationSettingsRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,25 @@ public async Task<IReadOnlyDictionary<string, string>> ReadAllAsync(Cancellation

return result;
}

/// <summary>
/// True if a row for <paramref name="key"/> exists in <c>station.settings</c> — added for gh-#406
/// slice 4: <c>GenWave.Host.Seeding.SafeLoopSeedMarkerStore</c>'s one-shot boot-seed marker check
/// (F27.10) needs a single-key existence probe, not the full unfiltered <see cref="ReadAllAsync"/>
/// scan. Any failure (including a <see cref="Npgsql.NpgsqlException"/>) propagates to the caller —
/// same posture as <see cref="ReadAllAsync"/>, degrade policy is a caller concern, not this
/// repository's.
/// </summary>
public async Task<bool> 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);
}
}
6 changes: 0 additions & 6 deletions tests/GenWave.Architecture.Tests/Support/ExemptionBaseline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -246,4 +250,57 @@ await Assert.ThrowsAsync<PostgresException>(() => 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);
}
}
}
Loading