bedfa6b8f3
Bundle B3 of Audit Log #23 M3: data-access layer for the central SiteCalls table introduced in B1+B2. UpsertAsync is insert-if-not-exists then monotonic-status update so out-of-order telemetry, duplicate gRPC packets, and reconciliation pulls all converge on the same row without rolling state backward. - src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs: UpsertAsync (monotonic), GetAsync, QueryAsync, PurgeTerminalAsync. - src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs + SiteCallPaging.cs: filter (Channel/SourceSite/Status/Target/time range) and keyset paging cursor on (CreatedAtUtc DESC, TrackedOperationId DESC), mirrored on M1's AuditLog* equivalents. - src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs: raw-SQL InsertIfNotExists + conditional UPDATE with inline CASE rank compare (Submitted=0, Forwarded=1, Attempted/Skipped=2, terminal=3 — terminal statuses are mutually exclusive so e.g. Delivered cannot overwrite Parked). Duplicate-key violations (SQL 2601/2627) are swallowed at Debug, identical to AuditLogRepository's race-fix. QueryAsync uses FromSqlInterpolated because EF Core 10 cannot translate string.Compare against the value-converted TrackedOperationId column inside an expression tree. - ServiceCollectionExtensions wires the repository (scoped, after IAuditLogRepository). - 12 integration tests in tests/ScadaLink.ConfigurationDatabase.Tests/ Repositories/ (MsSqlMigrationFixture + [SkippableFact]): fresh insert, monotonic advance, older-status no-op, same-status no-op, terminal-over-terminal no-op, 50-way concurrent-insert race produces exactly one row, Get known/unknown, filter by site, keyset paging no overlap, purge terminal-and-old, purge keeps non-terminal-and-recent.
86 lines
4.5 KiB
C#
86 lines
4.5 KiB
C#
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
|
using ScadaLink.ConfigurationDatabase.Services;
|
|
|
|
namespace ScadaLink.ConfigurationDatabase;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>
|
|
/// Registers the ScadaLinkDbContext with the provided SQL Server connection string.
|
|
/// </summary>
|
|
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
|
|
{
|
|
// The DbContext is constructed via the (options, IDataProtectionProvider) overload so
|
|
// secret-bearing configuration columns are encrypted at rest. AddDataProtection below
|
|
// registers IDataProtectionProvider as a singleton; resolving it here does not recurse
|
|
// because key-ring loading is lazy (first Protect/Unprotect), not triggered by
|
|
// CreateProtector during model building.
|
|
services.AddDbContext<ScadaLinkDbContext>((serviceProvider, options) =>
|
|
{
|
|
options.UseSqlServer(connectionString)
|
|
.ConfigureWarnings(w => w.Ignore(
|
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
|
});
|
|
|
|
// AddDbContext registers ScadaLinkDbContext via EF's activator, which only injects
|
|
// DbContextOptions. Override that registration (last registration wins for resolution)
|
|
// with a factory that also supplies the IDataProtectionProvider, so the encrypting
|
|
// value converter for secret columns is always wired up at runtime.
|
|
services.AddScoped(serviceProvider =>
|
|
{
|
|
var options = serviceProvider.GetRequiredService<DbContextOptions<ScadaLinkDbContext>>();
|
|
var protectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
|
|
return new ScadaLinkDbContext(options, protectionProvider);
|
|
});
|
|
|
|
services.AddScoped<ISecurityRepository, SecurityRepository>();
|
|
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
|
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
|
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
|
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
|
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
|
|
|
services.AddDataProtection()
|
|
.PersistKeysToDbContext<ScadaLinkDbContext>();
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Obsolete parameterless overload. This previously registered nothing, which meant a
|
|
/// central node wired up with it failed late and opaquely — the first repository
|
|
/// resolution threw a DI exception far from the actual misconfiguration. Use
|
|
/// <see cref="AddConfigurationDatabase(IServiceCollection, string)"/> and pass the
|
|
/// configured connection string.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// Always thrown. The connection string is required; there is no valid no-op registration.
|
|
/// </exception>
|
|
[Obsolete(
|
|
"AddConfigurationDatabase() with no connection string registers nothing and is not a " +
|
|
"valid configuration. Call AddConfigurationDatabase(connectionString) instead.",
|
|
error: true)]
|
|
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services)
|
|
{
|
|
// Defence-in-depth: even if a caller suppresses the compile-time obsolete error,
|
|
// fail fast at wire-up time rather than silently registering nothing and surfacing
|
|
// an opaque DI resolution failure much later.
|
|
throw new InvalidOperationException(
|
|
"AddConfigurationDatabase() requires a connection string. Call " +
|
|
"AddConfigurationDatabase(connectionString) with the configured " +
|
|
"'ScadaLink:Database:ConfigurationDb' value.");
|
|
}
|
|
}
|