diff --git a/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs new file mode 100644 index 0000000..f83f3ab --- /dev/null +++ b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs @@ -0,0 +1,60 @@ +using ScadaLink.Commons.Types; + +namespace ScadaLink.Commons.Entities.Audit; + +/// +/// Central operational state row for a cached call (Site Call Audit #22, Audit Log #23 M3). +/// One row per in the SiteCalls table — append-once +/// then monotonic status update. Status transitions are forward-only +/// (Submitted → Forwarded → Attempted → Delivered|Failed|Parked|Discarded); an +/// out-of-order or duplicate upsert is a silent no-op so duplicate gRPC packets and +/// reconciliation pulls can both feed the same writer without rolling state back. +/// +/// +/// Sites remain the source of truth — this row is the eventually-consistent mirror the +/// Central UI's Site Calls page reads. Unlike the partitioned AuditLog table this +/// entity backs operational (mutable) state on a standard non-partitioned table on +/// [PRIMARY]; no DB-role restriction applies. +/// +public sealed record SiteCall +{ + /// Strong-typed idempotency key shared with every audit row for the operation. + public required TrackedOperationId TrackedOperationId { get; init; } + + /// Trust-boundary channel — "ApiOutbound" or "DbOutbound". + public required string Channel { get; init; } + + /// Human-readable target (e.g. "ERP.GetOrder"). + public required string Target { get; init; } + + /// Site id that submitted the cached call. + public required string SourceSite { get; init; } + + /// + /// Lifecycle status — string form of + /// . Monotonic: later rank + /// wins, earlier rank is a no-op. + /// + public required string Status { get; init; } + + /// Number of dispatch attempts so far; 0 prior to first attempt. + public required int RetryCount { get; init; } + + /// Most recent error message; null when no failures have occurred. + public string? LastError { get; init; } + + /// Most recent HTTP status code (API calls only); null otherwise. + public int? HttpStatus { get; init; } + + /// UTC timestamp the cached call was first submitted at the site. + public required DateTime CreatedAtUtc { get; init; } + + /// UTC timestamp of the latest status mutation at the site. + public required DateTime UpdatedAtUtc { get; init; } + + /// UTC timestamp the row reached a terminal status; null while still active. + public DateTime? TerminalAtUtc { get; init; } + + /// UTC timestamp central ingested (or last refreshed) this row. + public required DateTime IngestedAtUtc { get; init; } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs new file mode 100644 index 0000000..78b7ccc --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types; + +namespace ScadaLink.ConfigurationDatabase.Configurations; + +/// +/// Maps the record to the central SiteCalls table +/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). Operational state — NOT audit — +/// so the table is non-partitioned, standard [PRIMARY] filegroup, no DB-role +/// restriction. Two named indexes back the Central UI's "from this site" and +/// "in this status" queries. +/// +public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SiteCalls"); + + // PK is the strong-typed TrackedOperationId. Stored as varchar(36) by converting + // through the canonical "D"-format GUID string. Going through the string surface + // (rather than uniqueidentifier) keeps the column shape identical to how the id + // is serialised on the wire (gRPC strings, SQLite TEXT on the site) — one + // consistent format everywhere makes operational debugging far easier than + // mixing a uniqueidentifier central column with TEXT site columns. + builder.HasKey(s => s.TrackedOperationId); + + builder.Property(s => s.TrackedOperationId) + .HasConversion( + id => id.Value.ToString("D"), + s => new TrackedOperationId(Guid.Parse(s))) + .HasMaxLength(36) + .IsUnicode(false) + .IsRequired(); + + // Enum-as-string columns: bounded varchar, ASCII. + builder.Property(s => s.Channel) + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); + + builder.Property(s => s.Status) + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); + + builder.Property(s => s.SourceSite) + .HasMaxLength(64) + .IsUnicode(false) + .IsRequired(); + + builder.Property(s => s.Target) + .HasMaxLength(256) + .IsUnicode(false) + .IsRequired(); + + // Bounded unicode message column. + builder.Property(s => s.LastError) + .HasMaxLength(1024); + + // Indexes — names locked for reconciliation/migration discoverability. + // Source_Created backs "calls from this site" (Central UI Site Calls page, + // filter by SourceSite, newest first). + builder.HasIndex(s => new { s.SourceSite, s.CreatedAtUtc }) + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + // Status_Updated backs "calls in this status" (e.g. parked rows awaiting + // operator action, newest UpdatedAtUtc first). + builder.HasIndex(s => new { s.Status, s.UpdatedAtUtc }) + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs index f25118f..4e35590 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs @@ -85,6 +85,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext // Audit public DbSet AuditLogEntries => Set(); public DbSet AuditLogs => Set(); + public DbSet SiteCalls => Set(); // Data Protection Keys (for shared ASP.NET Data Protection across nodes) public DbSet DataProtectionKeys => Set(); diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs new file mode 100644 index 0000000..ed0720b --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/SiteCallEntityTypeConfigurationTests.cs @@ -0,0 +1,109 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Configurations; + +namespace ScadaLink.ConfigurationDatabase.Tests.Configurations; + +/// +/// Schema-level tests for (#22 / #23 M3 Bundle B). +/// Verifies the record maps to the SiteCalls table with the +/// expected primary key, value conversion on TrackedOperationId, and the two named +/// indexes that back the "calls from this site" and "calls in this status" Central UI queries. +/// Mirrors the AuditLog Bundle B test pattern — inspects EF model metadata via the existing +/// in-memory SQLite test context, no database round-trips required. +/// +public class SiteCallEntityTypeConfigurationTests : IDisposable +{ + private readonly ScadaLinkDbContext _context; + + public SiteCallEntityTypeConfigurationTests() + { + _context = SqliteTestHelper.CreateInMemoryContext(); + } + + public void Dispose() + { + _context.Database.CloseConnection(); + _context.Dispose(); + } + + [Fact] + public void Configure_MapsToSiteCallsTable() + { + var entity = _context.Model.FindEntityType(typeof(SiteCall)); + + Assert.NotNull(entity); + Assert.Equal("SiteCalls", entity!.GetTableName()); + } + + [Fact] + public void Configure_PrimaryKey_TrackedOperationId() + { + var entity = _context.Model.FindEntityType(typeof(SiteCall)); + Assert.NotNull(entity); + + var pk = entity!.FindPrimaryKey(); + Assert.NotNull(pk); + + var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray(); + Assert.Equal(new[] { nameof(SiteCall.TrackedOperationId) }, pkPropertyNames); + } + + [Fact] + public void Configure_HasIndexes_NamedAndOrdered() + { + var entity = _context.Model.FindEntityType(typeof(SiteCall)); + Assert.NotNull(entity); + + var indexes = entity!.GetIndexes().ToList(); + + // IX_SiteCalls_Source_Created: (SourceSite ASC, CreatedAtUtc DESC). + var sourceCreated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Source_Created"); + Assert.NotNull(sourceCreated); + var sourceCreatedProps = sourceCreated!.Properties.Select(p => p.Name).ToArray(); + Assert.Equal(new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.CreatedAtUtc) }, sourceCreatedProps); + + // IX_SiteCalls_Status_Updated: (Status ASC, UpdatedAtUtc DESC). + var statusUpdated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Status_Updated"); + Assert.NotNull(statusUpdated); + var statusUpdatedProps = statusUpdated!.Properties.Select(p => p.Name).ToArray(); + Assert.Equal(new[] { nameof(SiteCall.Status), nameof(SiteCall.UpdatedAtUtc) }, statusUpdatedProps); + } + + [Fact] + public void Configure_TrackedOperationId_ConvertedToString_Length36() + { + var entity = _context.Model.FindEntityType(typeof(SiteCall)); + Assert.NotNull(entity); + + var property = entity!.FindProperty(nameof(SiteCall.TrackedOperationId)); + Assert.NotNull(property); + + // Stored as varchar(36) (TrackedOperationId.ToString("D") is always 36 chars). + // The value-conversion target type is exposed via GetProviderClrType when set, or + // discovered indirectly through the configured converter; either way the on-wire + // CLR type is string. + var providerClrType = property!.GetProviderClrType() ?? property.GetValueConverter()?.ProviderClrType; + Assert.Equal(typeof(string), providerClrType); + Assert.Equal(36, property.GetMaxLength()); + Assert.False(property.IsUnicode() ?? true); + } + + [Theory] + [InlineData(nameof(SiteCall.Channel), 32)] + [InlineData(nameof(SiteCall.SourceSite), 64)] + [InlineData(nameof(SiteCall.Status), 32)] + [InlineData(nameof(SiteCall.Target), 256)] + public void Configure_AsciiBoundedColumns(string propertyName, int expectedMaxLength) + { + var entity = _context.Model.FindEntityType(typeof(SiteCall)); + Assert.NotNull(entity); + + var property = entity!.FindProperty(propertyName); + Assert.NotNull(property); + + Assert.Equal(expectedMaxLength, property!.GetMaxLength()); + Assert.False(property.IsUnicode() ?? true); + } +}