feat(commons): add IAuditLogRepository + AuditLogQueryFilter + AuditLogPaging (#23)
Append-only data-access surface for the central AuditLog table — three methods: InsertIfNotExistsAsync (first-write-wins on EventId), QueryAsync (filter + keyset paging on (OccurredAtUtc desc, EventId desc)), and SwitchOutPartitionAsync (M1 honest contract — throws NotSupported until M6 lands the non-aligned-index drop/rebuild dance for the partition switch). No Update, no row-delete; bulk purge is partition-only. Bundle D of the Audit Log #23 M1 Foundation plan.
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append-only data access for the central <c>AuditLog</c> table (Audit Log #23).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The append-only invariant is enforced both at the SQL level (the
|
||||||
|
/// <c>scadalink_audit_writer</c> role has only INSERT + SELECT — UPDATE and DELETE
|
||||||
|
/// are not granted) and at the API level: this interface deliberately exposes no
|
||||||
|
/// Update and no single-row Delete. Bulk purge is performed exclusively via
|
||||||
|
/// monthly partition switch-out (<see cref="SwitchOutPartitionAsync"/>).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ingest is idempotent on <c>EventId</c>: <see cref="InsertIfNotExistsAsync"/> is
|
||||||
|
/// first-write-wins, so retrying telemetry and reconciliation pulls can both feed
|
||||||
|
/// the same writer without producing duplicates.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IAuditLogRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts <paramref name="evt"/> if no row with the same
|
||||||
|
/// <see cref="AuditEvent.EventId"/> exists; otherwise silently leaves the
|
||||||
|
/// stored row untouched (first-write-wins). Bypasses the EF change tracker
|
||||||
|
/// so the row never enters a tracked state.
|
||||||
|
/// </summary>
|
||||||
|
Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns up to <see cref="AuditLogPaging.PageSize"/> rows matching
|
||||||
|
/// <paramref name="filter"/>, ordered by <c>(OccurredAtUtc DESC, EventId DESC)</c>.
|
||||||
|
/// Use keyset paging by passing the last returned row's
|
||||||
|
/// <c>OccurredAtUtc</c> + <c>EventId</c> back via
|
||||||
|
/// <see cref="AuditLogPaging.AfterOccurredAtUtc"/> +
|
||||||
|
/// <see cref="AuditLogPaging.AfterEventId"/> to fetch the next page.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
|
AuditLogQueryFilter filter,
|
||||||
|
AuditLogPaging paging,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switches out (purges) the monthly partition whose lower bound is
|
||||||
|
/// <paramref name="monthBoundary"/>. The honest M1 implementation throws
|
||||||
|
/// <see cref="NotSupportedException"/>: the <c>UX_AuditLog_EventId</c> unique
|
||||||
|
/// index is non-partition-aligned (lives on <c>[PRIMARY]</c>, not on
|
||||||
|
/// <c>ps_AuditLog_Month</c>), so SQL Server rejects
|
||||||
|
/// <c>ALTER TABLE … SWITCH PARTITION</c> until the drop-and-rebuild dance
|
||||||
|
/// shipped by the M6 purge actor is in place.
|
||||||
|
/// </summary>
|
||||||
|
Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);
|
||||||
|
}
|
||||||
13
src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs
Normal file
13
src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keyset paging cursor for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
||||||
|
/// The repository orders by <c>(OccurredAtUtc DESC, EventId DESC)</c>; callers pass
|
||||||
|
/// the last row of the previous page back as <see cref="AfterOccurredAtUtc"/> +
|
||||||
|
/// <see cref="AfterEventId"/> to fetch the next page. Both must be non-null together,
|
||||||
|
/// or both null (first page).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuditLogPaging(
|
||||||
|
int PageSize,
|
||||||
|
DateTime? AfterOccurredAtUtc = null,
|
||||||
|
Guid? AfterEventId = null);
|
||||||
21
src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
Normal file
21
src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.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>>=</c> / <c><=</c> respectively. All filter fields are AND-combined.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuditLogQueryFilter(
|
||||||
|
AuditChannel? Channel = null,
|
||||||
|
AuditKind? Kind = null,
|
||||||
|
AuditStatus? Status = null,
|
||||||
|
string? SourceSiteId = null,
|
||||||
|
string? Target = null,
|
||||||
|
string? Actor = null,
|
||||||
|
Guid? CorrelationId = null,
|
||||||
|
DateTime? FromUtc = null,
|
||||||
|
DateTime? ToUtc = null);
|
||||||
Reference in New Issue
Block a user