feat(configdb): ISiteCallAuditRepository + EF impl, monotonic upsert (#22, #23 M3)

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.
This commit is contained in:
Joseph Doherty
2026-05-20 14:10:24 -04:00
parent 6667f345fa
commit bedfa6b8f3
6 changed files with 705 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Keyset paging cursor for
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.ISiteCallAuditRepository.QueryAsync"/>.
/// The repository orders by <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c> — newest
/// calls first, with the strong-typed id breaking ties when two calls share an exact
/// <c>CreatedAtUtc</c>. Callers pass the last row of the previous page back as
/// <see cref="AfterCreatedAtUtc"/> + <see cref="AfterId"/> to fetch the next page.
/// Both must be non-null together, or both null (first page).
/// </summary>
public sealed record SiteCallPaging(
int PageSize,
DateTime? AfterCreatedAtUtc = null,
TrackedOperationId? AfterId = null);

View File

@@ -0,0 +1,21 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.ISiteCallAuditRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
/// <c>&gt;=</c> / <c>&lt;=</c> respectively. All filter fields are AND-combined.
/// </summary>
/// <remarks>
/// Channel / Status / SourceSite / Target are matched as exact strings — the
/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
/// page exposes them as drop-down filters, not free-text search.
/// </remarks>
public sealed record SiteCallQueryFilter(
string? Channel = null,
string? SourceSite = null,
string? Status = null,
string? Target = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null);