diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs new file mode 100644 index 0000000..7b15962 --- /dev/null +++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -0,0 +1,56 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.Commons.Interfaces.Repositories; + +/// +/// Append-only data access for the central AuditLog table (Audit Log #23). +/// +/// +/// +/// The append-only invariant is enforced both at the SQL level (the +/// scadalink_audit_writer 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 (). +/// +/// +/// Ingest is idempotent on EventId: is +/// first-write-wins, so retrying telemetry and reconciliation pulls can both feed +/// the same writer without producing duplicates. +/// +/// +public interface IAuditLogRepository +{ + /// + /// Inserts if no row with the same + /// exists; otherwise silently leaves the + /// stored row untouched (first-write-wins). Bypasses the EF change tracker + /// so the row never enters a tracked state. + /// + Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default); + + /// + /// Returns up to rows matching + /// , ordered by (OccurredAtUtc DESC, EventId DESC). + /// Use keyset paging by passing the last returned row's + /// OccurredAtUtc + EventId back via + /// + + /// to fetch the next page. + /// + Task> QueryAsync( + AuditLogQueryFilter filter, + AuditLogPaging paging, + CancellationToken ct = default); + + /// + /// Switches out (purges) the monthly partition whose lower bound is + /// . The honest M1 implementation throws + /// : the UX_AuditLog_EventId unique + /// index is non-partition-aligned (lives on [PRIMARY], not on + /// ps_AuditLog_Month), so SQL Server rejects + /// ALTER TABLE … SWITCH PARTITION until the drop-and-rebuild dance + /// shipped by the M6 purge actor is in place. + /// + Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default); +} diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs new file mode 100644 index 0000000..7feeffd --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs @@ -0,0 +1,13 @@ +namespace ScadaLink.Commons.Types.Audit; + +/// +/// Keyset paging cursor for . +/// The repository orders by (OccurredAtUtc DESC, EventId DESC); callers pass +/// the last row of the previous page back as + +/// to fetch the next page. Both must be non-null together, +/// or both null (first page). +/// +public sealed record AuditLogPaging( + int PageSize, + DateTime? AfterOccurredAtUtc = null, + Guid? AfterEventId = null); diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs new file mode 100644 index 0000000..4e2001a --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs @@ -0,0 +1,21 @@ +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Types.Audit; + +/// +/// Filter predicate for . +/// Any field left null means "do not constrain on that column". Time bounds +/// are half-open in the spec sense — is inclusive and +/// is inclusive of the upper bound; the repository SQL uses +/// >= / <= respectively. All filter fields are AND-combined. +/// +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);