docs: add XML doc comments across src + Sister Projects section in CLAUDE.md

Bulk CommentChecker pass: fills in <param>/<inheritdoc> tags on public
APIs across all 23 src/ projects so the doc-coverage gate is green. Also
adds a Sister Projects section to CLAUDE.md pointing at the MxAccess
Gateway and OtOpcUa sibling repos, and gitignores local credential
captures (*login*.txt) and the wonder-app-vd03 deploy/ artifacts.
This commit is contained in:
Joseph Doherty
2026-05-28 01:55:24 -04:00
parent 6731845473
commit 1eb6e972b0
381 changed files with 5788 additions and 532 deletions
@@ -28,20 +28,16 @@ public class AuditLogRepository : IAuditLogRepository
private readonly ScadaLinkDbContext _context;
private readonly ILogger<AuditLogRepository> _logger;
/// <summary>Initializes a new instance of the AuditLogRepository class.</summary>
/// <param name="context">The database context.</param>
/// <param name="logger">Optional logger instance.</param>
public AuditLogRepository(ScadaLinkDbContext context, ILogger<AuditLogRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<AuditLogRepository>.Instance;
}
/// <summary>
/// Issues a single <c>IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…)</c>
/// via <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>.
/// Bypasses the EF change tracker so the row never enters a tracked state and
/// the enum-as-string conversion is done explicitly in C# (the columns are
/// declared <c>varchar(32)</c> via <c>HasConversion&lt;string&gt;()</c> in
/// <see cref="ScadaLink.ConfigurationDatabase.Configurations.AuditLogEntityTypeConfiguration"/>).
/// </summary>
/// <inheritdoc />
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt is null)
@@ -93,14 +89,7 @@ VALUES
}
}
/// <summary>
/// Builds an <c>AsNoTracking</c> queryable over <see cref="AuditEvent"/>, applies
/// every non-null filter predicate, and pages by keyset on
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>. The keyset clause is expressed
/// directly (<c>occurred &lt; after || (occurred == after &amp;&amp; eventId.CompareTo(afterId) &lt; 0)</c>)
/// — EF Core 10 translates <see cref="Guid.CompareTo(Guid)"/> against SQL Server's
/// <c>uniqueidentifier</c> sort order.
/// </summary>
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
{
@@ -199,30 +188,7 @@ VALUES
.ToListAsync(ct);
}
/// <summary>
/// M6-T4 production implementation of the drop-and-rebuild dance documented
/// on <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/>.
/// </summary>
/// <remarks>
/// <para>
/// The staging table name is GUID-suffixed so concurrent purge attempts on
/// different boundaries cannot collide. The staging schema is byte-identical
/// to the live <c>AuditLog</c> table (same column types, lengths,
/// nullability, and clustered-key shape) — SQL Server's
/// <c>ALTER TABLE … SWITCH PARTITION</c> rejects any drift. Keep this CREATE
/// in sync with both the migration that ships the live table
/// (<c>20260520142214_AddAuditLogTable</c>) and
/// <c>AuditLogEntityTypeConfiguration</c>.
/// </para>
/// <para>
/// All five steps run inside an explicit transaction so the SWITCH +
/// staging-DROP are atomic from the perspective of a consumer reading via
/// snapshot isolation; the CATCH rolls back and runs an idempotent
/// "rebuild UX_AuditLog_EventId if it doesn't exist" so a partial failure
/// never leaves the live table without its idempotency-supporting unique
/// index.
/// </para>
/// </remarks>
/// <inheritdoc />
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
// GUID-suffixed staging name: prevents collision with any concurrent
@@ -371,27 +337,7 @@ VALUES
return rowsDeleted;
}
/// <summary>
/// Returns the set of <c>pf_AuditLog_Month</c> boundaries whose partition's
/// <c>MAX(OccurredAtUtc)</c> is strictly older than <paramref name="threshold"/>.
/// Boundaries with empty partitions are excluded — purging an empty
/// partition is wasted I/O.
/// </summary>
/// <remarks>
/// <para>
/// The CTE pulls every boundary value defined by the partition function and
/// joins it (via <c>$PARTITION.pf_AuditLog_Month</c>) to the live AuditLog
/// to compute per-partition <c>MAX(OccurredAtUtc)</c>. The outer filter
/// keeps only those whose MAX is non-NULL (partition has rows) AND strictly
/// less than the threshold (every row is past retention).
/// </para>
/// <para>
/// Note: the query scans the live <c>OccurredAtUtc</c> column to compute
/// the MAX per partition. With <c>IX_AuditLog_OccurredAtUtc</c> on the
/// partition-aligned scheme this is a single index seek per partition; for
/// 24 partitions and a daily purge cadence the cost is negligible.
/// </para>
/// </remarks>
/// <inheritdoc />
public async Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold,
CancellationToken ct = default)
@@ -451,31 +397,7 @@ VALUES
return results;
}
/// <summary>
/// M7-T13 Bundle E — Health-dashboard Audit KPI tiles aggregate query.
/// Single round-trip
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE WHEN Status IN (...) THEN 1 ELSE 0 END) AS Errors</c>)
/// over the trailing <paramref name="window"/> anchored at
/// <paramref name="nowUtc"/>. Returns a snapshot with
/// <see cref="AuditLogKpiSnapshot.BacklogTotal"/> left at zero — the service
/// layer composes that in from
/// <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>.
/// </summary>
/// <remarks>
/// <para>
/// Why one query, not two: keeping the numerator + denominator in the same
/// scan means the error rate the UI displays is computed from a consistent
/// snapshot. With two separate queries a row could be inserted between
/// them, inflating the denominator past the numerator (or vice-versa) and
/// briefly producing a misleading percentage.
/// </para>
/// <para>
/// "Error" rows are <c>Failed</c>, <c>Parked</c>, or <c>Discarded</c> — see
/// <see cref="IAuditLogRepository.GetKpiSnapshotAsync"/> for the rationale.
/// We pass the three discriminator strings as separate parameters rather
/// than building an IN-list to keep the prepared statement cache-friendly.
/// </para>
/// </remarks>
/// <inheritdoc />
public async Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window,
DateTime? nowUtc = null,
@@ -573,37 +495,7 @@ VALUES
// practice.
private const int ExecutionChainMaxDepth = 32;
/// <summary>
/// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain
/// containing <paramref name="executionId"/>, regardless of entry point.
/// </summary>
/// <remarks>
/// <para>
/// Two phases. <b>Walk up:</b> an iterative
/// <c>SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL</c>
/// climbs from the supplied node to the root — the last execution id with no
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
/// down:</b> a recursive CTE over a DISTINCT
/// <c>(ExecutionId, ParentExecutionId)</c> edge set, seeded at the root edge
/// and joining <c>edge.ParentExecutionId = chain.ExecutionId</c> to
/// enumerate every descendant. Recursing over edges rather than raw rows
/// keeps the recursion one path wide per execution. It is bounded by
/// <c>OPTION (MAXRECURSION ...)</c> at <see cref="ExecutionChainMaxDepth"/>
/// — corrupt cyclic data raises a <see cref="SqlException"/> (msg 530)
/// rather than spinning.
/// </para>
/// <para>
/// The chain's full execution-id set is every edge's <c>ExecutionId</c>
/// unioned with its non-null <c>ParentExecutionId</c>, so an execution
/// referenced only as a parent — a "stub" that emitted no rows of its own,
/// and therefore owns no edge of its own — is still included. The final
/// projection LEFT JOINs that id set back to <c>AuditLog</c> and
/// <c>GROUP BY</c>s, so a stub yields a node with <c>RowCount = 0</c> and
/// empty/null aggregates. The query is SELECT-only
/// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
/// </para>
/// </remarks>
/// <inheritdoc />
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default)
@@ -768,14 +660,7 @@ VALUES
}
}
/// <summary>
/// Distinct non-null <c>SourceNode</c> values for the Audit Log page's
/// Node filter dropdown. EF Core translates this to
/// <c>SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL ORDER BY SourceNode</c>
/// — a single index-less scan, but the column is bounded (one entry per
/// node in the cluster, currently &lt;10) and the Central UI caches the
/// result for ~60s, so a periodic scan is acceptable.
/// </summary>
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
return await _context.Set<AuditEvent>()