< Summary

Information
Class: AsiBackbone.EntityFrameworkCore.Audit.EfCoreAuditLedgerStore
Assembly: AsiBackbone.EntityFrameworkCore
File(s): /home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.EntityFrameworkCore/Audit/EfCoreAuditLedgerStore.cs
Line coverage
99%
Covered lines: 271
Uncovered lines: 1
Coverable lines: 272
Total lines: 454
Line coverage: 99.6%
Branch coverage
90%
Covered branches: 18
Total branches: 20
Branch coverage: 90%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
AppendAsync()83.33%6696.42%
FindByRecordIdAsync()100%22100%
FindByCorrelationIdAsync()100%11100%
FindByTraceIdAsync()100%11100%
FindByActorIdAsync()100%11100%
FindByRecordedUtcRangeAsync()100%22100%
LedgerRecords()100%11100%
ToEntity(...)100%11100%
ToReasonCodeEntities(...)100%11100%
ToMetadataEntities(...)100%11100%
ToRecords(...)100%11100%
ToRecord(...)100%11100%
DeserializeReasonCodes(...)75%44100%
DeserializeMetadata(...)100%66100%
.ctor(...)100%11100%
get_EventId()100%11100%
get_AuditResidueId()100%11100%
get_SchemaVersion()100%11100%
get_OccurredUtc()100%11100%
get_ActorId()100%11100%
get_ActorType()100%11100%
get_ActorDisplayName()100%11100%
get_OperationName()100%11100%
get_Outcome()100%11100%
get_ReasonCodes()100%11100%
get_CorrelationId()100%11100%
get_TraceId()100%11100%
get_SpanId()100%11100%
get_ParentSpanId()100%11100%
get_DecisionLatencyMs()100%11100%
get_ConstraintSetHash()100%11100%
get_ConstraintCount()100%11100%
get_RiskScore()100%11100%
get_PolicyScope()100%11100%
get_TenantHash()100%11100%
get_OrganizationHash()100%11100%
get_EmitterStatus()100%11100%
get_EmitterProvider()100%11100%
get_OutboxSequence()100%11100%
get_GatewayExecutionId()100%11100%
get_DecisionStage()100%11100%
get_PolicyVersion()100%11100%
get_PolicyHash()100%11100%
get_Metadata()100%11100%

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.EntityFrameworkCore/Audit/EfCoreAuditLedgerStore.cs

#LineLine coverage
 1using System.Collections.ObjectModel;
 2using System.Text.Json;
 3using AsiBackbone.Core.Actors;
 4using AsiBackbone.Core.Audit;
 5using AsiBackbone.Core.Results;
 6using AsiBackbone.EntityFrameworkCore.Persistence;
 7using Microsoft.EntityFrameworkCore;
 8using Microsoft.Extensions.Logging;
 9
 10namespace AsiBackbone.EntityFrameworkCore.Audit;
 11
 12/// <summary>
 13/// Entity Framework Core-backed audit ledger store that persists records through a host-owned <see cref="DbContext" />.
 14/// </summary>
 15/// <remarks>
 16/// This store is append-oriented and intentionally relies on the host application to expose the ASI Backbone entities f
 17/// its own <see cref="DbContext" /> and migrations. It does not create a package-owned context or select a database pro
 18/// </remarks>
 19public sealed class EfCoreAuditLedgerStore : IAsiBackboneAuditLedgerStore
 20{
 21    private const string AppendFailedReasonCode = "asi_backbone.audit_ledger.append_failed";
 22    private const string AppendFailedReasonMessage =
 23        "The audit ledger record could not be persisted by the configured EF Core store.";
 24
 225    private static readonly Action<ILogger, string, Exception?> LogAuditLedgerAppendFailed =
 226        LoggerMessage.Define<string>(
 227            LogLevel.Error,
 228            new EventId(1001, nameof(LogAuditLedgerAppendFailed)),
 229            "EF Core audit ledger append failed for record {AuditLedgerRecordId}.");
 30
 231    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
 32
 33    private readonly DbContext dbContext;
 34    private readonly ILogger<EfCoreAuditLedgerStore>? logger;
 35
 36    /// <summary>
 37    /// Initializes a new instance of the <see cref="EfCoreAuditLedgerStore" /> class.
 38    /// </summary>
 39    /// <param name="dbContext">The host-owned database context.</param>
 40    /// <param name="logger">The optional host-owned logger used for internal persistence diagnostics.</param>
 1641    public EfCoreAuditLedgerStore(
 1642        DbContext dbContext,
 1643        ILogger<EfCoreAuditLedgerStore>? logger = null)
 44    {
 1645        ArgumentNullException.ThrowIfNull(dbContext);
 46
 1647        this.dbContext = dbContext;
 1648        this.logger = logger;
 1649    }
 50
 51    /// <inheritdoc />
 52    public async ValueTask<OperationResult<AuditLedgerRecord>> AppendAsync(
 53        AuditLedgerRecord record,
 54        CancellationToken cancellationToken = default)
 55    {
 1656        ArgumentNullException.ThrowIfNull(record);
 1657        cancellationToken.ThrowIfCancellationRequested();
 58
 1659        AsiBackboneAuditLedgerRecordEntity entity = ToEntity(record);
 60
 1661        _ = await dbContext
 1662            .Set<AsiBackboneAuditLedgerRecordEntity>()
 1663            .AddAsync(entity, cancellationToken)
 1664            .ConfigureAwait(false);
 65
 5266        foreach (AsiBackboneAuditLedgerReasonCodeEntity reasonCode in ToReasonCodeEntities(entity.Id, record.ReasonCodes
 67        {
 1068            _ = await dbContext
 1069                .Set<AsiBackboneAuditLedgerReasonCodeEntity>()
 1070                .AddAsync(reasonCode, cancellationToken)
 1071                .ConfigureAwait(false);
 72        }
 73
 4874        foreach (AsiBackboneAuditLedgerMetadataEntity metadata in ToMetadataEntities(entity.Id, record.Metadata))
 75        {
 876            _ = await dbContext
 877                .Set<AsiBackboneAuditLedgerMetadataEntity>()
 878                .AddAsync(metadata, cancellationToken)
 879                .ConfigureAwait(false);
 80        }
 81
 82        try
 83        {
 1684            _ = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 1285        }
 486        catch (DbUpdateException ex)
 87        {
 488            dbContext.ChangeTracker.Clear();
 89
 490            if (logger is not null)
 91            {
 092                LogAuditLedgerAppendFailed(logger, record.RecordId, ex);
 93            }
 94
 495            return OperationResult.Failure<AuditLedgerRecord>(
 496                AppendFailedReasonCode,
 497                AppendFailedReasonMessage);
 98        }
 99
 12100        return OperationResult.Success(record);
 16101    }
 102
 103    /// <inheritdoc />
 104    public async ValueTask<AuditLedgerRecord?> FindByRecordIdAsync(
 105        string recordId,
 106        CancellationToken cancellationToken = default)
 107    {
 8108        ArgumentException.ThrowIfNullOrWhiteSpace(recordId);
 109
 8110        string normalizedRecordId = recordId.Trim();
 111
 8112        AsiBackboneAuditLedgerRecordEntity? entity = await LedgerRecords()
 8113            .Where(record => record.RecordId == normalizedRecordId)
 8114            .SingleOrDefaultAsync(cancellationToken)
 8115            .ConfigureAwait(false);
 116
 8117        return entity is null ? null : ToRecord(entity);
 8118    }
 119
 120    /// <inheritdoc />
 121    public async ValueTask<IReadOnlyList<AuditLedgerRecord>> FindByCorrelationIdAsync(
 122        string correlationId,
 123        CancellationToken cancellationToken = default)
 124    {
 2125        ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
 126
 2127        string normalizedCorrelationId = correlationId.Trim();
 128
 2129        List<AsiBackboneAuditLedgerRecordEntity> entities = await LedgerRecords()
 2130            .Where(record => record.CorrelationId == normalizedCorrelationId)
 2131            .OrderBy(record => record.RecordedUtc)
 2132            .ThenBy(record => record.RecordId)
 2133            .ToListAsync(cancellationToken)
 2134            .ConfigureAwait(false);
 135
 2136        return ToRecords(entities);
 2137    }
 138
 139    /// <inheritdoc />
 140    public async ValueTask<IReadOnlyList<AuditLedgerRecord>> FindByTraceIdAsync(
 141        string traceId,
 142        CancellationToken cancellationToken = default)
 143    {
 2144        ArgumentException.ThrowIfNullOrWhiteSpace(traceId);
 145
 2146        string normalizedTraceId = traceId.Trim();
 147
 2148        List<AsiBackboneAuditLedgerRecordEntity> entities = await LedgerRecords()
 2149            .Where(record => record.TraceId == normalizedTraceId)
 2150            .OrderBy(record => record.RecordedUtc)
 2151            .ThenBy(record => record.RecordId)
 2152            .ToListAsync(cancellationToken)
 2153            .ConfigureAwait(false);
 154
 2155        return ToRecords(entities);
 2156    }
 157
 158    /// <inheritdoc />
 159    public async ValueTask<IReadOnlyList<AuditLedgerRecord>> FindByActorIdAsync(
 160        string actorId,
 161        CancellationToken cancellationToken = default)
 162    {
 2163        ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
 164
 2165        string normalizedActorId = actorId.Trim();
 166
 2167        List<AsiBackboneAuditLedgerRecordEntity> entities = await LedgerRecords()
 2168            .Where(record => record.ActorId == normalizedActorId)
 2169            .OrderBy(record => record.RecordedUtc)
 2170            .ThenBy(record => record.RecordId)
 2171            .ToListAsync(cancellationToken)
 2172            .ConfigureAwait(false);
 173
 2174        return ToRecords(entities);
 2175    }
 176
 177    /// <inheritdoc />
 178    public async ValueTask<IReadOnlyList<AuditLedgerRecord>> FindByRecordedUtcRangeAsync(
 179        DateTimeOffset recordedFromUtc,
 180        DateTimeOffset recordedToUtc,
 181        CancellationToken cancellationToken = default)
 182    {
 4183        DateTimeOffset normalizedFromUtc = recordedFromUtc.ToUniversalTime();
 4184        DateTimeOffset normalizedToUtc = recordedToUtc.ToUniversalTime();
 185
 4186        if (normalizedFromUtc > normalizedToUtc)
 187        {
 2188            throw new ArgumentException(
 2189                "The recorded UTC range start must be less than or equal to the range end.",
 2190                nameof(recordedFromUtc));
 191        }
 192
 2193        List<AsiBackboneAuditLedgerRecordEntity> entities = await LedgerRecords()
 2194            .Where(record => record.RecordedUtc >= normalizedFromUtc && record.RecordedUtc <= normalizedToUtc)
 2195            .OrderBy(record => record.RecordedUtc)
 2196            .ThenBy(record => record.RecordId)
 2197            .ToListAsync(cancellationToken)
 2198            .ConfigureAwait(false);
 199
 2200        return ToRecords(entities);
 2201    }
 202
 203    private IQueryable<AsiBackboneAuditLedgerRecordEntity> LedgerRecords()
 204    {
 16205        return dbContext.Set<AsiBackboneAuditLedgerRecordEntity>().AsNoTracking();
 206    }
 207
 208    private static AsiBackboneAuditLedgerRecordEntity ToEntity(AuditLedgerRecord record)
 209    {
 16210        return new AsiBackboneAuditLedgerRecordEntity
 16211        {
 16212            RecordId = record.RecordId,
 16213            SchemaVersion = record.SchemaVersion,
 16214            EventId = record.EventId,
 16215            AuditResidueId = record.AuditResidueId,
 16216            OccurredUtc = record.OccurredUtc,
 16217            RecordedUtc = record.RecordedUtc,
 16218            ActorId = record.ActorId,
 16219            ActorType = record.ActorType,
 16220            ActorDisplayName = record.ActorDisplayName,
 16221            OperationName = record.OperationName,
 16222            Outcome = record.Outcome,
 16223            ReasonCodesJson = JsonSerializer.Serialize(record.ReasonCodes, JsonOptions),
 16224            CorrelationId = record.CorrelationId,
 16225            TraceId = record.TraceId,
 16226            SpanId = record.SpanId,
 16227            ParentSpanId = record.ParentSpanId,
 16228            DecisionLatencyMs = record.DecisionLatencyMs,
 16229            ConstraintSetHash = record.ConstraintSetHash,
 16230            ConstraintCount = record.ConstraintCount,
 16231            RiskScore = record.RiskScore,
 16232            PolicyScope = record.PolicyScope,
 16233            TenantHash = record.TenantHash,
 16234            OrganizationHash = record.OrganizationHash,
 16235            EmitterStatus = record.EmitterStatus,
 16236            EmitterProvider = record.EmitterProvider,
 16237            OutboxSequence = record.OutboxSequence,
 16238            GatewayExecutionId = record.GatewayExecutionId,
 16239            DecisionStage = record.DecisionStage,
 16240            PolicyVersion = record.PolicyVersion,
 16241            PolicyHash = record.PolicyHash,
 16242            HandshakeId = record.HandshakeId,
 16243            AcknowledgmentId = record.AcknowledgmentId,
 16244            CapabilityTokenId = record.CapabilityTokenId,
 16245            PreviousRecordHash = record.PreviousRecordHash,
 16246            RecordHash = record.RecordHash,
 16247            SigningHash = record.SigningHash,
 16248            SignatureKeyId = record.SignatureKeyId,
 16249            SignatureKeyVersion = record.SignatureKeyVersion,
 16250            SignatureAlgorithm = record.SignatureAlgorithm,
 16251            SignatureValue = record.SignatureValue,
 16252            SignatureProvider = record.SignatureProvider,
 16253            SignedUtc = record.SignedUtc,
 16254            MetadataJson = JsonSerializer.Serialize(record.Metadata, JsonOptions)
 16255        };
 256    }
 257
 258    private static AsiBackboneAuditLedgerReasonCodeEntity[] ToReasonCodeEntities(
 259        Guid auditLedgerRecordId,
 260        IReadOnlyList<string> reasonCodes)
 261    {
 16262        return [.. reasonCodes
 26263            .Select((reasonCode, index) => new AsiBackboneAuditLedgerReasonCodeEntity
 26264            {
 26265                AuditLedgerRecordId = auditLedgerRecordId,
 26266                Sequence = index,
 26267                ReasonCode = reasonCode
 26268            })];
 269    }
 270
 271    private static AsiBackboneAuditLedgerMetadataEntity[] ToMetadataEntities(
 272        Guid auditLedgerRecordId,
 273        IReadOnlyDictionary<string, string> metadata)
 274    {
 16275        return [.. metadata
 24276            .Select(item => new AsiBackboneAuditLedgerMetadataEntity
 24277            {
 24278                AuditLedgerRecordId = auditLedgerRecordId,
 24279                MetadataKey = item.Key,
 24280                MetadataValue = item.Value
 24281            })];
 282    }
 283
 284    private static AuditLedgerRecord[] ToRecords(IEnumerable<AsiBackboneAuditLedgerRecordEntity> entities)
 285    {
 8286        return [.. entities.Select(ToRecord)];
 287    }
 288
 289    private static AuditLedgerRecord ToRecord(AsiBackboneAuditLedgerRecordEntity entity)
 290    {
 20291        string[] reasonCodes = DeserializeReasonCodes(entity.ReasonCodesJson);
 20292        ReadOnlyDictionary<string, string> metadata = DeserializeMetadata(entity.MetadataJson);
 293
 20294        var residue = new EntityAuditResidue(
 20295            entity.EventId,
 20296            entity.AuditResidueId,
 20297            entity.SchemaVersion,
 20298            entity.OccurredUtc,
 20299            entity.ActorId,
 20300            entity.ActorType,
 20301            entity.ActorDisplayName,
 20302            entity.OperationName,
 20303            entity.Outcome,
 20304            Array.AsReadOnly(reasonCodes),
 20305            entity.CorrelationId,
 20306            entity.TraceId,
 20307            entity.SpanId,
 20308            entity.ParentSpanId,
 20309            entity.DecisionLatencyMs,
 20310            entity.ConstraintSetHash,
 20311            entity.ConstraintCount,
 20312            entity.RiskScore,
 20313            entity.PolicyScope,
 20314            entity.TenantHash,
 20315            entity.OrganizationHash,
 20316            entity.EmitterStatus,
 20317            entity.EmitterProvider,
 20318            entity.OutboxSequence,
 20319            entity.GatewayExecutionId,
 20320            entity.DecisionStage,
 20321            entity.PolicyVersion,
 20322            entity.PolicyHash,
 20323            metadata);
 324
 20325        return AuditLedgerRecord.FromResidue(
 20326            residue,
 20327            entity.RecordId,
 20328            entity.RecordedUtc,
 20329            entity.HandshakeId,
 20330            entity.AcknowledgmentId,
 20331            entity.CapabilityTokenId,
 20332            entity.PreviousRecordHash,
 20333            entity.RecordHash,
 20334            entity.SignatureKeyId,
 20335            entity.SignatureAlgorithm,
 20336            entity.SignatureValue,
 20337            signingHash: entity.SigningHash,
 20338            signatureKeyVersion: entity.SignatureKeyVersion,
 20339            signatureProvider: entity.SignatureProvider,
 20340            signedUtc: entity.SignedUtc,
 20341            schemaVersion: entity.SchemaVersion);
 342    }
 343
 344    private static string[] DeserializeReasonCodes(string? json)
 345    {
 20346        return string.IsNullOrWhiteSpace(json)
 20347            ? []
 20348            : JsonSerializer.Deserialize<string[]>(json, JsonOptions) ?? [];
 349    }
 350
 351    private static ReadOnlyDictionary<string, string> DeserializeMetadata(string? json)
 352    {
 20353        if (string.IsNullOrWhiteSpace(json))
 354        {
 2355            return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
 356        }
 357
 18358        Dictionary<string, string>? metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions)
 359
 18360        return metadata is null || metadata.Count == 0
 18361            ? new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal))
 18362            : new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
 363    }
 364
 20365    private sealed class EntityAuditResidue(
 20366        string eventId,
 20367        string? auditResidueId,
 20368        string schemaVersion,
 20369        DateTimeOffset occurredUtc,
 20370        string actorId,
 20371        AsiBackboneActorType actorType,
 20372        string? actorDisplayName,
 20373        string operationName,
 20374        string outcome,
 20375        IReadOnlyList<string> reasonCodes,
 20376        string? correlationId,
 20377        string? traceId,
 20378        string? spanId,
 20379        string? parentSpanId,
 20380        long? decisionLatencyMs,
 20381        string? constraintSetHash,
 20382        int? constraintCount,
 20383        double? riskScore,
 20384        string? policyScope,
 20385        string? tenantHash,
 20386        string? organizationHash,
 20387        string? emitterStatus,
 20388        string? emitterProvider,
 20389        long? outboxSequence,
 20390        string? gatewayExecutionId,
 20391        string? decisionStage,
 20392        string? policyVersion,
 20393        string? policyHash,
 20394        IReadOnlyDictionary<string, string> metadata) : IAsiBackboneAuditResidue
 395    {
 40396        public string EventId { get; } = eventId;
 397
 40398        public string? AuditResidueId { get; } = auditResidueId;
 399
 20400        public string SchemaVersion { get; } = schemaVersion;
 401
 40402        public DateTimeOffset OccurredUtc { get; } = occurredUtc;
 403
 40404        public string ActorId { get; } = actorId;
 405
 40406        public AsiBackboneActorType ActorType { get; } = actorType;
 407
 40408        public string? ActorDisplayName { get; } = actorDisplayName;
 409
 40410        public string OperationName { get; } = operationName;
 411
 40412        public string Outcome { get; } = outcome;
 413
 40414        public IReadOnlyList<string> ReasonCodes { get; } = reasonCodes;
 415
 40416        public string? CorrelationId { get; } = correlationId;
 417
 40418        public string? TraceId { get; } = traceId;
 419
 40420        public string? SpanId { get; } = spanId;
 421
 40422        public string? ParentSpanId { get; } = parentSpanId;
 423
 40424        public long? DecisionLatencyMs { get; } = decisionLatencyMs;
 425
 40426        public string? ConstraintSetHash { get; } = constraintSetHash;
 427
 40428        public int? ConstraintCount { get; } = constraintCount;
 429
 40430        public double? RiskScore { get; } = riskScore;
 431
 40432        public string? PolicyScope { get; } = policyScope;
 433
 40434        public string? TenantHash { get; } = tenantHash;
 435
 40436        public string? OrganizationHash { get; } = organizationHash;
 437
 40438        public string? EmitterStatus { get; } = emitterStatus;
 439
 40440        public string? EmitterProvider { get; } = emitterProvider;
 441
 40442        public long? OutboxSequence { get; } = outboxSequence;
 443
 40444        public string? GatewayExecutionId { get; } = gatewayExecutionId;
 445
 40446        public string? DecisionStage { get; } = decisionStage;
 447
 40448        public string? PolicyVersion { get; } = policyVersion;
 449
 40450        public string? PolicyHash { get; } = policyHash;
 451
 40452        public IReadOnlyDictionary<string, string> Metadata { get; } = metadata;
 453    }
 454}

Methods/Properties

.cctor()
.ctor(Microsoft.EntityFrameworkCore.DbContext,Microsoft.Extensions.Logging.ILogger`1<AsiBackbone.EntityFrameworkCore.Audit.EfCoreAuditLedgerStore>)
AppendAsync()
FindByRecordIdAsync()
FindByCorrelationIdAsync()
FindByTraceIdAsync()
FindByActorIdAsync()
FindByRecordedUtcRangeAsync()
LedgerRecords()
ToEntity(AsiBackbone.Core.Audit.AuditLedgerRecord)
ToReasonCodeEntities(System.Guid,System.Collections.Generic.IReadOnlyList`1<System.String>)
ToMetadataEntities(System.Guid,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.String>)
ToRecords(System.Collections.Generic.IEnumerable`1<AsiBackbone.EntityFrameworkCore.Persistence.AsiBackboneAuditLedgerRecordEntity>)
ToRecord(AsiBackbone.EntityFrameworkCore.Persistence.AsiBackboneAuditLedgerRecordEntity)
DeserializeReasonCodes(System.String)
DeserializeMetadata(System.String)
.ctor(System.String,System.String,System.String,System.DateTimeOffset,System.String,AsiBackbone.Core.Actors.AsiBackboneActorType,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.String,System.String,System.String,System.Nullable`1<System.Int64>,System.String,System.Nullable`1<System.Int32>,System.Nullable`1<System.Double>,System.String,System.String,System.String,System.String,System.String,System.Nullable`1<System.Int64>,System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.String>)
get_EventId()
get_AuditResidueId()
get_SchemaVersion()
get_OccurredUtc()
get_ActorId()
get_ActorType()
get_ActorDisplayName()
get_OperationName()
get_Outcome()
get_ReasonCodes()
get_CorrelationId()
get_TraceId()
get_SpanId()
get_ParentSpanId()
get_DecisionLatencyMs()
get_ConstraintSetHash()
get_ConstraintCount()
get_RiskScore()
get_PolicyScope()
get_TenantHash()
get_OrganizationHash()
get_EmitterStatus()
get_EmitterProvider()
get_OutboxSequence()
get_GatewayExecutionId()
get_DecisionStage()
get_PolicyVersion()
get_PolicyHash()
get_Metadata()