From 68a6bd1720e3fadedeea6e771febffc189a68ffd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 14:06:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(audit)!:=20ScadaBridge=20C5=20=E2=80=94=20?= =?UTF-8?q?collapse=20central=20dbo.AuditLog=20to=2010=20canonical=20cols?= =?UTF-8?q?=20+=20persisted=20computed=20cols;=20CollapseAuditLogToCanonic?= =?UTF-8?q?al=20migration;=20repo=20writes=20canonical=20directly=20(Task?= =?UTF-8?q?=202.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuditLogEntityTypeConfiguration.cs | 203 +- .../Entities/AuditLogRow.cs | 174 +- ...46_CollapseAuditLogToCanonical.Designer.cs | 1724 +++++++++++++++++ ...60602174346_CollapseAuditLogToCanonical.cs | 244 +++ .../ScadaBridgeDbContextModelSnapshot.cs | 262 ++- .../Repositories/AuditLogRepository.cs | 169 +- .../ScadaBridgeDbContext.cs | 37 + .../Integration/PartitionPurgeTests.cs | 27 +- .../AuditLogEntityTypeConfigurationTests.cs | 170 +- .../AddAuditLogTableMigrationTests.cs | 7 +- .../Repositories/AuditLogRepositoryTests.cs | 9 +- .../SqliteTestHelper.cs | 6 + 12 files changed, 2592 insertions(+), 440 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602174346_CollapseAuditLogToCanonical.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602174346_CollapseAuditLogToCanonical.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index 348ef385..217686ad 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -6,11 +6,12 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations; /// -/// Maps the persistence shape to the central AuditLog -/// table described in alog.md §4. Column lengths/types and the named indexes are -/// fixed by that specification — keep this in sync with the doc. C3 (Task 2.5) kept -/// the table unchanged; the canonical record is mapped onto this row at the repository -/// boundary via AuditRowProjection. +/// Maps the C5 (Task 2.5) persistence shape to the central +/// dbo.AuditLog table: the 10 canonical ZB.MOM.WW.Audit.AuditEvent columns +/// (writable) plus six read-only, server-side persisted computed columns derived +/// from DetailsJson via JSON_VALUE. The computed-column SQL and the index +/// set here mirror the CollapseAuditLogToCanonical migration's +/// dbo.AuditLog_v2 DDL byte-for-byte — keep them in sync. /// public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration { @@ -35,88 +36,146 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null); + // The computed columns derive enum-named strings from DetailsJson (e.g. + // JSON_VALUE(...,'$.kind') == "CachedResolve"), exactly the value + // HasConversion() expects on read. The repository never writes these + // (they are server-computed), but the string<->enum converter is still + // required so EF materialises them as the strongly-typed enum a LINQ + // predicate like `e.Kind == AuditKind.CachedResolve` translates against. + /// Applies the EF Core type configuration for to the model builder. /// The entity type builder to configure. public void Configure(EntityTypeBuilder builder) { builder.ToTable("AuditLog"); - // Enforce DateTimeKind.Utc on every *Utc-suffixed DateTime column. See - // the UtcConverter remarks above for the rationale. - builder.Property(e => e.OccurredAtUtc).HasConversion(UtcConverter); - builder.Property(e => e.IngestedAtUtc).HasConversion(NullableUtcConverter); + // ── Canonical columns (writable) ───────────────────────────────────── + // + // Column SQL TYPES are intentionally left to EF's relational conventions + // (driven by HasMaxLength / IsUnicode / the CLR type) rather than pinned + // with HasColumnType, so the SAME configuration maps to SQL Server + // (varchar(n) / nvarchar(n) / uniqueidentifier / datetime2) in production + // AND to the SQLite test provider (TEXT) without a `(max)`/`uniqueidentifier` + // literal leaking into SQLite DDL. The migration's raw DDL pins the exact + // SQL Server types; EF's conventions agree with them (verified clean via + // `has-pending-model-changes`). - // Composite PK includes OccurredAtUtc — required by the monthly partition scheme - // (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still - // needs to be globally unique for InsertIfNotExistsAsync idempotency, so a - // separate unique index is declared on EventId alone. - builder.HasKey(e => new { e.EventId, e.OccurredAtUtc }); - - builder.HasIndex(e => e.EventId) - .IsUnique() - .HasDatabaseName("UX_AuditLog_EventId"); - - // Enum-as-string columns: bounded varchar(32) ASCII. - builder.Property(e => e.Channel) - .HasConversion() - .HasMaxLength(32) - .IsUnicode(false) - .IsRequired(); - - builder.Property(e => e.Kind) - .HasConversion() - .HasMaxLength(32) - .IsUnicode(false) - .IsRequired(); - - builder.Property(e => e.Status) - .HasConversion() - .HasMaxLength(32) - .IsUnicode(false) - .IsRequired(); - - builder.Property(e => e.ForwardState) - .HasConversion() - .HasMaxLength(32) - .IsUnicode(false); - - // Ascii identifier columns — never carry user-supplied unicode. - builder.Property(e => e.SourceSiteId) - .HasMaxLength(64) - .IsUnicode(false); - - builder.Property(e => e.SourceInstanceId) - .HasMaxLength(128) - .IsUnicode(false); - - builder.Property(e => e.SourceScript) - .HasMaxLength(128) - .IsUnicode(false); + // Enforce DateTimeKind.Utc on the OccurredAtUtc column. See the + // UtcConverter remarks above for the rationale. + builder.Property(e => e.OccurredAtUtc) + .HasConversion(UtcConverter); builder.Property(e => e.Actor) - .HasMaxLength(128) - .IsUnicode(false); + .HasMaxLength(256); + + builder.Property(e => e.Action) + .HasMaxLength(64) + .IsUnicode(false) + .IsRequired(); + + builder.Property(e => e.Outcome) + .HasConversion() + .HasMaxLength(16) + .IsUnicode(false) + .IsRequired(); + + // Channel rides in the canonical Category column (Category = channel name + // for ScadaBridge). Stored as the enum's name in varchar(32); the + // string<->enum converter lets `e.Channel == AuditChannel.X` translate to + // `[Category] = 'X'` server-side. + builder.Property(e => e.Channel) + .HasColumnName("Category") + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); builder.Property(e => e.Target) - .HasMaxLength(256) - .IsUnicode(false); + .HasMaxLength(256); // SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the // cluster member that produced the row (e.g. "node-a", "central-a"). NULL is // valid for reconciled rows from a retired node and for direct-write rows // produced before this feature shipped. ASCII — varchar(64), no unicode. builder.Property(e => e.SourceNode) - .HasColumnType("varchar(64)") .HasMaxLength(64) .IsUnicode(false); - // Bounded unicode message column. - builder.Property(e => e.ErrorMessage) - .HasMaxLength(1024); + // DetailsJson: unbounded → nvarchar(max) on SQL Server, TEXT on SQLite. + // (No HasMaxLength / HasColumnType — let conventions pick per provider.) - // ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max). + // ── Persisted computed columns (read-only; derived from DetailsJson) ── + // + // Each is `… AS PERSISTED`; EF must never attempt to write them + // (ValueGeneratedOnAddOrUpdate + metadata-only mapping). The SQL strings + // here MUST match the migration's dbo.AuditLog_v2 DDL exactly so + // `dotnet ef migrations has-pending-model-changes` stays clean. The SQLite + // test context strips the computed-column SQL (JSON_VALUE is unknown to + // SQLite) so EnsureCreated still works. - // Indexes — names locked to alog.md §4 for reconciliation/migration discoverability. + builder.Property(e => e.Kind) + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false) + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", stored: true) + .ValueGeneratedOnAddOrUpdate() + .IsRequired(); + + builder.Property(e => e.Status) + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false) + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", stored: true) + .ValueGeneratedOnAddOrUpdate() + .IsRequired(); + + builder.Property(e => e.SourceSiteId) + .HasMaxLength(64) + .IsUnicode(false) + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", stored: true) + .ValueGeneratedOnAddOrUpdate(); + + builder.Property(e => e.ExecutionId) + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", stored: true) + .ValueGeneratedOnAddOrUpdate(); + + builder.Property(e => e.ParentExecutionId) + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", stored: true) + .ValueGeneratedOnAddOrUpdate(); + + // IngestedAtUtc rides in DetailsJson as an ISO-8601-with-offset string + // (always +00:00 — the codec normalises to UTC). SWITCHOFFSET(...,0) + // normalises any offset to UTC before the datetime2 cast, so the column + // is the UTC wall-clock regardless. The datetimeoffset cast / SWITCHOFFSET + // is NON-DETERMINISTic to SQL Server, so this computed column is NOT + // persisted (stored:false) — a PERSISTED non-deterministic column is + // rejected at CREATE. It is not indexed, so non-persistence costs nothing. + // Routed through the nullable UTC converter so the materialised value + // carries Kind=Utc. + builder.Property(e => e.IngestedAtUtc) + .HasColumnType("datetime2(7)") + .HasConversion(NullableUtcConverter) + .HasComputedColumnSql( + "CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", + stored: false) + .ValueGeneratedOnAddOrUpdate(); + + // ── Keys + indexes ─────────────────────────────────────────────────── + + // Composite PK includes OccurredAtUtc — required by the monthly partition scheme + // (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still + // needs to be globally unique for InsertIfNotExistsAsync idempotency, so a + // separate (non-aligned) unique index is declared on EventId alone. + builder.HasKey(e => new { e.EventId, e.OccurredAtUtc }); + + builder.HasIndex(e => e.EventId) + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + // Index names are locked for reconciliation/migration discoverability. The + // column SETS migrate to the canonical/computed shape (alog.md §4 semantics + // preserved): Channel→Category, Site/Node/Execution/ParentExecution now read + // off the computed columns. builder.HasIndex(e => e.OccurredAtUtc) .IsDescending(true) .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); @@ -129,22 +188,22 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.ExecutionId) - .HasFilter("[ExecutionId] IS NOT NULL") .HasDatabaseName("IX_AuditLog_Execution"); builder.HasIndex(e => e.ParentExecutionId) - .HasFilter("[ParentExecutionId] IS NOT NULL") .HasDatabaseName("IX_AuditLog_ParentExecution"); - // SourceNode composite index (Audit Log #23, SourceNode-stamping): backs - // per-node Central UI / health-dashboard queries (e.g. "rows produced by - // central-a, newest first"). Created via raw SQL in the migration so it lands - // on the ps_AuditLog_Month(OccurredAtUtc) partition scheme like every other - // IX_AuditLog_* index — keeps the partition-switch purge path intact. builder.HasIndex(e => new { e.SourceNode, e.OccurredAtUtc }) .HasDatabaseName("IX_AuditLog_Node_Occurred"); + // IX_AuditLog_Channel_Status_Occurred name preserved; columns are now the + // canonical Category (= channel) + computed Status + OccurredAtUtc. builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc }) .IsDescending(false, false, true) .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs index 7589d2d3..c4d8c345 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Entities/AuditLogRow.cs @@ -1,20 +1,41 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities; /// -/// Transitional EF Core persistence shape for the central dbo.AuditLog table -/// (Audit Log #23). This is the 24-column row formerly modelled by -/// ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent; in C3 (Task 2.5) -/// the canonical ZB.MOM.WW.Audit.AuditEvent became the type at every seam, -/// emit site, DTO boundary, and redactor, and this row type was relocated here as a -/// storage-only entity so the existing table keeps working unchanged. +/// EF Core persistence shape for the central dbo.AuditLog table after the +/// C5 collapse (Audit Log #23, Task 2.5). The table is now the 10 canonical +/// ZB.MOM.WW.Audit.AuditEvent fields stored DIRECTLY plus a set of +/// read-only, server-side persisted computed columns derived from +/// (JSON_VALUEPERSISTED) so the +/// reporting queries stay indexable without re-parsing JSON. /// /// /// -/// The repository maps canonical ⇄ this row at the persistence boundary via -/// ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.AuditRowProjection. C5 replaces -/// this shim + table with the real DetailsJson-backed schema. +/// C5 (Task 2.5). The transitional 24-typed-column shim is retired. The +/// repository writes the 10 canonical columns directly (no Decompose) and +/// the computed columns auto-derive at INSERT; reads build the canonical +/// AuditEvent straight off the canonical columns (no Recompose). +/// +/// +/// Canonical columns (writable): , +/// , , , +/// , (the canonical Category +/// column — for ScadaBridge, Category = channel name), , +/// , , +/// . +/// +/// +/// Persisted computed columns (read-only): , +/// , , , +/// (the five spec'd queryability columns), plus +/// (central ingest timestamp, also a DetailsJson +/// field). These are populated by SQL Server from ; EF +/// never writes them. Their getters expose them as typed +/// (enum / / ) properties so the +/// existing LINQ filter/aggregate queries keep their meaning; the value +/// converters that turn enum names ⇄ varchar match the JSON_VALUE string output. /// /// /// All *Utc-suffixed properties are invariantly UTC @@ -27,6 +48,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities; /// public sealed record AuditLogRow { + // ── Canonical columns (the 10 ZB.MOM.WW.Audit.AuditEvent fields) ────────── + /// Idempotency key; uniquely identifies one audit lifecycle event. public Guid EventId { get; init; } @@ -38,7 +61,75 @@ public sealed record AuditLogRow } private readonly DateTime _occurredAtUtc; - /// UTC timestamp when the row was ingested at central; null on the site hot-path. + /// Authenticated actor for inbound paths (API key name, user, etc.); null/empty for system/anon. + public string? Actor { get; init; } + + /// Canonical action verb — "{channel}.{kind}" (e.g. ApiOutbound.ApiCall). + public string Action { get; init; } = string.Empty; + + /// Normalized canonical outcome (Success / Failure / Denied). + public AuditOutcome Outcome { get; init; } + + /// + /// Trust-boundary channel the audited action crossed. Stored in the canonical + /// Category column (for ScadaBridge the canonical Category IS the channel + /// name); exposed here as the strongly-typed enum. + /// + public AuditChannel Channel { get; init; } + + /// Target of the action: external system name, db connection name, list name, or inbound method. + public string? Target { get; init; } + + /// The cluster node on which the event was emitted. + public string? SourceNode { get; init; } + + /// Correlation id linking related audit rows (e.g. the cached-op lifecycle). + public Guid? CorrelationId { get; init; } + + /// Canonical JSON extension bag carrying every ScadaBridge domain field. + public string? DetailsJson { get; init; } + + // ── Persisted computed columns (read-only; derived from DetailsJson) ────── + + /// + /// Specific event kind. Computed column JSON_VALUE(DetailsJson,'$.kind') + /// PERSISTED; read-only (the DB derives it on INSERT). + /// + public AuditKind Kind { get; init; } + + /// + /// Lifecycle status. Computed column JSON_VALUE(DetailsJson,'$.status') + /// PERSISTED; read-only. + /// + public AuditStatus Status { get; init; } + + /// + /// Site id where the action originated; null for central-direct events. Computed + /// column JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED; read-only. + /// + public string? SourceSiteId { get; init; } + + /// + /// Id of the originating script execution / inbound request. Computed column + /// CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) + /// PERSISTED; read-only. + /// + public Guid? ExecutionId { get; init; } + + /// + /// ExecutionId of the execution that spawned this run; null for top-level runs. + /// Computed column + /// CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) + /// PERSISTED; read-only. + /// + public Guid? ParentExecutionId { get; init; } + + /// + /// UTC timestamp when the central AuditLog store ingested this row; null until + /// central stamps it. Computed column over + /// JSON_VALUE(DetailsJson,'$.ingestedAtUtc') (normalized to UTC datetime2) + /// PERSISTED; read-only. + /// public DateTime? IngestedAtUtc { get => _ingestedAtUtc; @@ -47,67 +138,4 @@ public sealed record AuditLogRow : null; } private readonly DateTime? _ingestedAtUtc; - - /// Trust-boundary channel the audited action crossed. - public AuditChannel Channel { get; init; } - - /// Specific event kind within the channel. - public AuditKind Kind { get; init; } - - /// Correlation id linking related audit rows (e.g. the cached-op lifecycle). - public Guid? CorrelationId { get; init; } - - /// Id of the originating script execution / inbound request. - public Guid? ExecutionId { get; init; } - - /// ExecutionId of the execution that spawned this run; null for top-level runs. - public Guid? ParentExecutionId { get; init; } - - /// Site id where the action originated; null for central-direct events. - public string? SourceSiteId { get; init; } - - /// The cluster node on which the event was emitted. - public string? SourceNode { get; init; } - - /// Instance id where the action originated, when applicable. - public string? SourceInstanceId { get; init; } - - /// Script that initiated the action, when applicable. - public string? SourceScript { get; init; } - - /// Authenticated actor for inbound paths (API key name, user, etc.). - public string? Actor { get; init; } - - /// Target of the action: external system name, db connection name, list name, or inbound method. - public string? Target { get; init; } - - /// Lifecycle status of this row. - public AuditStatus Status { get; init; } - - /// HTTP status code where applicable. - public int? HttpStatus { get; init; } - - /// Duration of the audited action in milliseconds, when measurable. - public int? DurationMs { get; init; } - - /// Human-readable error summary on failure rows. - public string? ErrorMessage { get; init; } - - /// Verbose error detail (stack/exception) on failure rows. - public string? ErrorDetail { get; init; } - - /// Truncated/redacted request summary; capped per AuditLogOptions. - public string? RequestSummary { get; init; } - - /// Truncated/redacted response summary; capped per AuditLogOptions. - public string? ResponseSummary { get; init; } - - /// True when Request/Response summaries were truncated to the payload cap. - public bool PayloadTruncated { get; init; } - - /// Free-form JSON extension column for channel-specific extras. - public string? Extra { get; init; } - - /// Site-local forwarding state; null on central rows. - public AuditForwardState? ForwardState { get; init; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602174346_CollapseAuditLogToCanonical.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602174346_CollapseAuditLogToCanonical.Designer.cs new file mode 100644 index 00000000..bc8aea5e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602174346_CollapseAuditLogToCanonical.Designer.cs @@ -0,0 +1,1724 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260602174346_CollapseAuditLogToCanonical")] + partial class CollapseAuditLogToCanonical + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasColumnName("Category"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true); + + b.Property("IngestedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2(7)") + .HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ParentExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602174346_CollapseAuditLogToCanonical.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602174346_CollapseAuditLogToCanonical.cs new file mode 100644 index 00000000..d1ecac0e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602174346_CollapseAuditLogToCanonical.cs @@ -0,0 +1,244 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + /// C5 of the ScadaBridge audit re-architecture (Task 2.5): collapses the central + /// dbo.AuditLog table from the transitional 24 typed columns to the 10 + /// canonical ZB.MOM.WW.Audit.AuditEvent columns plus six read-only, + /// server-side persisted computed columns derived from DetailsJson + /// (JSON_VALUEPERSISTED) that back the indexed reporting queries. + /// + /// + /// + /// Why new-table + copy (not in-place ALTER). An in-place collapse is + /// infeasible: the partition-aligned indexes are keyed on columns that are being + /// dropped; the purge path (SwitchOutPartitionAsync) hard-codes a + /// byte-identical staging column list; and dropping nvarchar(max) columns + /// per partition is expensive. Instead this migration builds a fresh + /// dbo.AuditLog_v2 on the SAME preserved partition scheme + /// (ps_AuditLog_Month(OccurredAtUtc)), copies every row with a one-way + /// projection of the old typed columns into canonical columns + DetailsJson, + /// drops the old table, renames v2 into place, and re-grants the append-only roles. + /// + /// + /// Down() is a documented ONE-WAY. The projection of ~17 typed columns into + /// a single JSON bag is lossy to reverse byte-for-byte (e.g. the codec's + /// null-omission + key order, the Action/Category/Outcome derivation), so the + /// reverse is NOT implemented. throws + /// with guidance to restore from backup. + /// + /// + /// Partition / SWITCH / computed-column interaction. The new table and its + /// non-clustered indexes are created directly on ps_AuditLog_Month so the + /// partition-switch purge keeps working; the non-aligned UX_AuditLog_EventId + /// stays on [PRIMARY] exactly as before. The persisted computed columns are + /// part of the table's storage, so the staging table used by + /// SwitchOutPartitionAsync MUST declare them with identical expressions — + /// see that method (kept in sync with the v2 DDL below). + /// + /// + public partial class CollapseAuditLogToCanonical : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1) Create dbo.AuditLog_v2 on the PRESERVED partition scheme. Ten + // canonical columns first (ordinal-stable), then the six persisted + // computed columns. The clustered PK is composite {EventId, + // OccurredAtUtc} (partition-aligned). The computed-column expressions + // here are the single source of truth that AuditLogEntityTypeConfiguration + // and SwitchOutPartitionAsync's staging table must mirror exactly. + migrationBuilder.Sql(@" +CREATE TABLE dbo.AuditLog_v2 ( + EventId uniqueidentifier NOT NULL, + OccurredAtUtc datetime2(7) NOT NULL, + Actor nvarchar(256) NULL, + Action varchar(64) NOT NULL, + Outcome varchar(16) NOT NULL, + Category varchar(32) NOT NULL, + Target nvarchar(256) NULL, + SourceNode varchar(64) NULL, + CorrelationId uniqueidentifier NULL, + DetailsJson nvarchar(max) NULL, + Kind AS JSON_VALUE(DetailsJson,'$.kind') PERSISTED, + Status AS JSON_VALUE(DetailsJson,'$.status') PERSISTED, + SourceSiteId AS JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED, + ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED, + ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED, + -- IngestedAtUtc is NOT persisted: the datetimeoffset cast / SWITCHOFFSET is + -- non-deterministic and SQL Server rejects a PERSISTED non-deterministic + -- computed column. It is not indexed, so non-persistence costs nothing. + IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)), + CONSTRAINT PK_AuditLog_v2 PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) + ON ps_AuditLog_Month(OccurredAtUtc) +) ON ps_AuditLog_Month(OccurredAtUtc);"); + + // 2) Data copy: project the old 24-column rows into the canonical shape. + // - Action = Channel + '.' + Kind (matches AuditFieldBuilders.BuildAction) + // - Category = Channel (matches AuditFieldBuilders.BuildCategory) + // - Outcome = AuditOutcomeProjector.Project(Status, Kind): + // Kind='InboundAuthFailure' -> 'Denied' (wins over any status); + // Status in ('Failed','Parked','Discarded') -> 'Failure'; else 'Success'. + // - Actor : empty string maps to NULL (canonical Actor is non-null, + // but the old column stored NULL for system/anon — keep NULL). + // - DetailsJson: every domain field re-serialised as a single JSON object + // with camelCase keys matching AuditDetailsCodec. FOR JSON + // PATH, WITHOUT_ARRAY_WRAPPER OMITS null keys by default + // (no INCLUDE_NULL_VALUES), matching the codec's + // JsonIgnoreCondition.WhenWritingNull; channel/kind/status + // are NOT NULL in the legacy table so they are always present + // and emit the enum-name strings the computed Kind/Status + // (and Channel-as-Category) derive from. payloadTruncated is a + // non-null bit so it is always written, matching the codec + // (which always writes the bool). + // The six computed columns auto-derive from DetailsJson on INSERT. + migrationBuilder.Sql(@" +INSERT INTO dbo.AuditLog_v2 + (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson) +SELECT + a.EventId, + a.OccurredAtUtc, + NULLIF(a.Actor, '') AS Actor, + a.Channel + '.' + a.Kind AS Action, + CASE + WHEN a.Kind = 'InboundAuthFailure' THEN 'Denied' + WHEN a.Status IN ('Failed','Parked','Discarded') THEN 'Failure' + ELSE 'Success' + END AS Outcome, + a.Channel AS Category, + a.Target, + a.SourceNode, + a.CorrelationId, + ( + SELECT + a.Channel AS channel, + a.Kind AS kind, + a.Status AS status, + a.ExecutionId AS executionId, + a.ParentExecutionId AS parentExecutionId, + a.SourceSiteId AS sourceSiteId, + a.SourceInstanceId AS sourceInstanceId, + a.SourceScript AS sourceScript, + a.HttpStatus AS httpStatus, + a.DurationMs AS durationMs, + a.ErrorMessage AS errorMessage, + a.ErrorDetail AS errorDetail, + a.RequestSummary AS requestSummary, + a.ResponseSummary AS responseSummary, + CAST(a.PayloadTruncated AS bit) AS payloadTruncated, + a.Extra AS extra, + CASE WHEN a.IngestedAtUtc IS NULL THEN NULL + ELSE CONVERT(varchar(33), + CAST(a.IngestedAtUtc AS datetimeoffset(7)) AT TIME ZONE 'UTC', + 126) + END AS ingestedAtUtc + FOR JSON PATH, WITHOUT_ARRAY_WRAPPER + ) AS DetailsJson +FROM dbo.AuditLog a;"); + + // 3) Drop the old table and rename v2 into place. The table rename uses + // the 'schema.table' form; the PK constraint rename uses the BARE + // constraint name with @objtype='OBJECT' (a constraint is a schema- + // scoped object, NOT table-qualified — a 'dbo.AuditLog.PK_…' form is + // rejected as ambiguous). The non-clustered indexes were dropped with + // the old table; they are recreated by name in step 4 (no index rename + // needed because the v2 table was created without them). + migrationBuilder.Sql(@" +DROP TABLE dbo.AuditLog; +EXEC sp_rename 'dbo.AuditLog_v2', 'AuditLog'; +EXEC sp_rename 'PK_AuditLog_v2', 'PK_AuditLog', 'OBJECT';"); + + // 4) Recreate the reconciliation/query indexes on the new shape, names + // preserved (alog.md §4 semantics): Channel→Category, Site/Node read + // off the canonical/computed columns. All non-clustered indexes are + // partition-aligned on ps_AuditLog_Month(OccurredAtUtc) so the + // partition-switch purge keeps touching a single partition. The + // UX_AuditLog_EventId unique index is INTENTIONALLY non-aligned (on + // [PRIMARY]) to give single-column EventId uniqueness for + // InsertIfNotExistsAsync idempotency. + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc +ON dbo.AuditLog (OccurredAtUtc DESC) +ON ps_AuditLog_Month(OccurredAtUtc); + +CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred +ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC) +ON ps_AuditLog_Month(OccurredAtUtc); + +CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId +ON dbo.AuditLog (CorrelationId) +WHERE CorrelationId IS NOT NULL +ON ps_AuditLog_Month(OccurredAtUtc); + +CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred +ON dbo.AuditLog (Category ASC, Status ASC, OccurredAtUtc DESC) +ON ps_AuditLog_Month(OccurredAtUtc); + +CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred +ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC) +WHERE Target IS NOT NULL +ON ps_AuditLog_Month(OccurredAtUtc); + +-- IX_AuditLog_Execution / IX_AuditLog_ParentExecution are NOT filtered: SQL +-- Server forbids a filtered-index WHERE predicate from referencing a computed +-- column (ExecutionId / ParentExecutionId are persisted computed columns). An +-- unfiltered index still backs the equality lookups GetExecutionTreeAsync uses; +-- it just also indexes the NULL rows. (The pre-C5 typed columns allowed the +-- IS NOT NULL filter; the computed-column constraint does not.) +CREATE NONCLUSTERED INDEX IX_AuditLog_Execution +ON dbo.AuditLog (ExecutionId) +ON ps_AuditLog_Month(OccurredAtUtc); + +CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution +ON dbo.AuditLog (ParentExecutionId) +ON ps_AuditLog_Month(OccurredAtUtc); + +CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred +ON dbo.AuditLog (SourceNode ASC, OccurredAtUtc ASC) +ON ps_AuditLog_Month(OccurredAtUtc); + +CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId +ON dbo.AuditLog (EventId) +ON [PRIMARY];"); + + // 5) Re-grant the append-only roles on the renamed table. The grants were + // object-scoped to the old (now-dropped) table, so they must be re-issued + // against the new one. Idempotent role creation guards a fresh DB. The + // DENY UPDATE / DENY DELETE on the writer role is deliberate — a future + // db_datawriter membership cannot quietly re-enable mutation (DENY > GRANT). + migrationBuilder.Sql(@" +IF DATABASE_PRINCIPAL_ID('scadabridge_audit_writer') IS NULL + EXEC sp_executesql N'CREATE ROLE scadabridge_audit_writer'; +GRANT INSERT ON dbo.AuditLog TO scadabridge_audit_writer; +GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_writer; +DENY UPDATE ON dbo.AuditLog TO scadabridge_audit_writer; +DENY DELETE ON dbo.AuditLog TO scadabridge_audit_writer; + +IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NULL + EXEC sp_executesql N'CREATE ROLE scadabridge_audit_purger'; +GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_purger; +GRANT ALTER ON SCHEMA::dbo TO scadabridge_audit_purger;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // ONE-WAY MIGRATION. Collapsing the 24 typed columns into the canonical + // shape + a single DetailsJson bag is lossy to reverse byte-for-byte: the + // Action/Category/Outcome derivation discards the original Channel/Kind/ + // Status split in a way only DetailsJson can reconstruct, and the codec's + // null-omission + key-order contract cannot be reproduced by a generic + // reverse projection. Reversing in place would also have to rebuild the + // partition-aligned indexes on the dropped typed columns. If a rollback is + // required, restore the central database from a pre-migration backup. + throw new System.NotSupportedException( + "CollapseAuditLogToCanonical is a one-way migration (Task 2.5 C5): the " + + "central dbo.AuditLog collapse projects the legacy typed columns into a " + + "lossy canonical/DetailsJson shape that cannot be reversed automatically. " + + "Restore the database from a pre-migration backup to roll back."); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 5fa42528..3e9fabe4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -41,145 +41,6 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations b.ToTable("DataProtectionKeys"); }); - modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => - { - b.Property("EventId") - .HasColumnType("uniqueidentifier"); - - b.Property("OccurredAtUtc") - .HasColumnType("datetime2"); - - b.Property("Actor") - .HasMaxLength(128) - .IsUnicode(false) - .HasColumnType("varchar(128)"); - - b.Property("Channel") - .IsRequired() - .HasMaxLength(32) - .IsUnicode(false) - .HasColumnType("varchar(32)"); - - b.Property("CorrelationId") - .HasColumnType("uniqueidentifier"); - - b.Property("DurationMs") - .HasColumnType("int"); - - b.Property("ErrorDetail") - .HasColumnType("nvarchar(max)"); - - b.Property("ErrorMessage") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); - - b.Property("ExecutionId") - .HasColumnType("uniqueidentifier"); - - b.Property("Extra") - .HasColumnType("nvarchar(max)"); - - b.Property("ForwardState") - .HasMaxLength(32) - .IsUnicode(false) - .HasColumnType("varchar(32)"); - - b.Property("HttpStatus") - .HasColumnType("int"); - - b.Property("IngestedAtUtc") - .HasColumnType("datetime2"); - - b.Property("Kind") - .IsRequired() - .HasMaxLength(32) - .IsUnicode(false) - .HasColumnType("varchar(32)"); - - b.Property("ParentExecutionId") - .HasColumnType("uniqueidentifier"); - - b.Property("PayloadTruncated") - .HasColumnType("bit"); - - b.Property("RequestSummary") - .HasColumnType("nvarchar(max)"); - - b.Property("ResponseSummary") - .HasColumnType("nvarchar(max)"); - - b.Property("SourceInstanceId") - .HasMaxLength(128) - .IsUnicode(false) - .HasColumnType("varchar(128)"); - - b.Property("SourceNode") - .HasMaxLength(64) - .IsUnicode(false) - .HasColumnType("varchar(64)"); - - b.Property("SourceScript") - .HasMaxLength(128) - .IsUnicode(false) - .HasColumnType("varchar(128)"); - - b.Property("SourceSiteId") - .HasMaxLength(64) - .IsUnicode(false) - .HasColumnType("varchar(64)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .IsUnicode(false) - .HasColumnType("varchar(32)"); - - b.Property("Target") - .HasMaxLength(256) - .IsUnicode(false) - .HasColumnType("varchar(256)"); - - b.HasKey("EventId", "OccurredAtUtc"); - - b.HasIndex("CorrelationId") - .HasDatabaseName("IX_AuditLog_CorrelationId") - .HasFilter("[CorrelationId] IS NOT NULL"); - - b.HasIndex("EventId") - .IsUnique() - .HasDatabaseName("UX_AuditLog_EventId"); - - b.HasIndex("ExecutionId") - .HasDatabaseName("IX_AuditLog_Execution") - .HasFilter("[ExecutionId] IS NOT NULL"); - - b.HasIndex("OccurredAtUtc") - .IsDescending() - .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); - - b.HasIndex("ParentExecutionId") - .HasDatabaseName("IX_AuditLog_ParentExecution") - .HasFilter("[ParentExecutionId] IS NOT NULL"); - - b.HasIndex("SourceNode", "OccurredAtUtc") - .HasDatabaseName("IX_AuditLog_Node_Occurred"); - - b.HasIndex("SourceSiteId", "OccurredAtUtc") - .IsDescending(false, true) - .HasDatabaseName("IX_AuditLog_Site_Occurred"); - - b.HasIndex("Target", "OccurredAtUtc") - .IsDescending(false, true) - .HasDatabaseName("IX_AuditLog_Target_Occurred") - .HasFilter("[Target] IS NOT NULL"); - - b.HasIndex("Channel", "Status", "OccurredAtUtc") - .IsDescending(false, false, true) - .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); - - b.ToTable("AuditLog", (string)null); - }); - modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => { b.Property("Id") @@ -1490,6 +1351,129 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations b.ToTable("TemplateScripts"); }); + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasColumnName("Category"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true); + + b.Property("IngestedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2(7)") + .HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ParentExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => { b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 7842f243..bafd9a94 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -46,36 +46,35 @@ public class AuditLogRepository : IAuditLogRepository throw new ArgumentNullException(nameof(evt)); } - // C3 transitional shim: the canonical record carries the ScadaBridge domain - // fields inside DetailsJson — decompose it into the typed 24-column values the - // existing dbo.AuditLog table expects. Central rows leave ForwardState null - // (it is a site-storage-only concern, never on a central row). - var r = AuditRowProjection.Decompose(evt); + // C5 (Task 2.5): write the 10 canonical columns DIRECTLY — no Decompose. + // The five queryability columns (Kind/Status/SourceSiteId/ExecutionId/ + // ParentExecutionId) plus IngestedAtUtc are PERSISTED computed columns on + // dbo.AuditLog; SQL Server derives them from DetailsJson at INSERT, so they + // are intentionally absent from this column list (writing a computed column + // is an error). The canonical OccurredAtUtc is UTC by construction; store a + // Kind=Utc DateTime so downstream UTC/local conversions are safe. + var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc); - // Enum columns are stored as varchar(32) (HasConversion()), so do - // the conversion in C# rather than relying on parameter type inference — - // SqlClient would otherwise bind enums as int by default. - var channel = r.Channel.ToString(); - var kind = r.Kind.ToString(); - var status = r.Status.ToString(); - string? forwardState = null; + // Canonical Actor is a required non-null string; an empty Actor maps to a + // NULL column (legacy/central rows stored null for system/anon). + string? actor = string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor; + + // Outcome / Category are varchar columns (Outcome via HasConversion; + // Category carries the channel name). Bind as strings rather than relying on + // parameter type inference. + var outcome = evt.Outcome.ToString(); + string? category = evt.Category; // FormattableString interpolation parameterises every value (no concatenation), // so this is safe against injection even for the string columns. try { await _context.Database.ExecuteSqlInterpolatedAsync( - $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {r.EventId}) + $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId}) INSERT INTO dbo.AuditLog - (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId, - SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, - HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, - ResponseSummary, PayloadTruncated, Extra, ForwardState) + (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson) VALUES - ({r.EventId}, {r.OccurredAtUtc}, {r.IngestedAtUtc}, {channel}, {kind}, {r.CorrelationId}, {r.ExecutionId}, {r.ParentExecutionId}, - {r.SourceSiteId}, {r.SourceNode}, {r.SourceInstanceId}, {r.SourceScript}, {r.Actor}, {r.Target}, {status}, - {r.HttpStatus}, {r.DurationMs}, {r.ErrorMessage}, {r.ErrorDetail}, {r.RequestSummary}, - {r.ResponseSummary}, {r.PayloadTruncated}, {r.Extra}, {forwardState});", + ({evt.EventId}, {occurred}, {actor}, {evt.Action}, {outcome}, {category}, {evt.Target}, {evt.SourceNode}, {evt.CorrelationId}, {evt.DetailsJson});", ct); } catch (SqlException ex) when ( @@ -92,7 +91,7 @@ VALUES ex, "InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.", ex.Number, - r.EventId); + evt.EventId); } } @@ -110,9 +109,11 @@ VALUES throw new ArgumentNullException(nameof(paging)); } - // C3 transitional shim: the typed-column filter predicates query the - // AuditLogRow persistence shape as before (C6 retargets how the filter is - // applied); the materialized rows are recomposed into canonical records. + // C5 (Task 2.5): the filter predicates bind to the canonical columns and the + // persisted computed columns directly — Channel→Category, Kind/Status/ + // SourceSiteId/ExecutionId/ParentExecutionId are computed columns. The + // materialized rows are projected to the canonical record by reading the 10 + // canonical columns (no 24-column Recompose). var query = _context.Set().AsNoTracking(); // Multi-value dimensions: a null OR empty list means "no constraint" @@ -201,36 +202,29 @@ VALUES } /// - /// C3 transitional shim: recompose a canonical from a - /// materialized read back from dbo.AuditLog. - /// ForwardState is dropped (central rows never carry it; it is not a - /// canonical / DetailsJson field). + /// C5 (Task 2.5): build the canonical DIRECTLY from the + /// 10 canonical columns of a materialized read back from + /// dbo.AuditLog — no 24-column Recompose, because the table now holds + /// the canonical shape (every ScadaBridge domain field already lives in + /// DetailsJson). The persisted computed columns are read helpers only and + /// are not part of the canonical record. is the + /// canonical Category column (Category = channel name for ScadaBridge). /// private static AuditEvent RowToCanonical(AuditLogRow row) - => AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues( - EventId: row.EventId, - OccurredAtUtc: row.OccurredAtUtc, - IngestedAtUtc: row.IngestedAtUtc, - Channel: row.Channel, - Kind: row.Kind, - Status: row.Status, - CorrelationId: row.CorrelationId, - ExecutionId: row.ExecutionId, - ParentExecutionId: row.ParentExecutionId, - SourceSiteId: row.SourceSiteId, - SourceNode: row.SourceNode, - SourceInstanceId: row.SourceInstanceId, - SourceScript: row.SourceScript, - Actor: row.Actor, - Target: row.Target, - HttpStatus: row.HttpStatus, - DurationMs: row.DurationMs, - ErrorMessage: row.ErrorMessage, - ErrorDetail: row.ErrorDetail, - RequestSummary: row.RequestSummary, - ResponseSummary: row.ResponseSummary, - PayloadTruncated: row.PayloadTruncated, - Extra: row.Extra)); + => new() + { + EventId = row.EventId, + OccurredAtUtc = new DateTimeOffset( + DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc)), + Actor = row.Actor ?? string.Empty, + Action = row.Action, + Outcome = row.Outcome, + Category = row.Channel.ToString(), + Target = row.Target, + SourceNode = row.SourceNode, + CorrelationId = row.CorrelationId, + DetailsJson = row.DetailsJson, + }; /// public async Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) @@ -270,38 +264,29 @@ VALUES DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog; -- 2. Staging table on [PRIMARY] (non-partitioned) with column shapes - -- byte-identical to dbo.AuditLog. Any drift here causes SWITCH to - -- reject the operation with msg 4904/4915. + -- byte-identical to the C5 dbo.AuditLog — INCLUDING the persisted + -- computed columns, whose definitions must match EXACTLY (same + -- expression text + PERSISTED) or ALTER TABLE ... SWITCH PARTITION + -- rejects the operation with msg 4904/4948. The ordinal order also + -- matches dbo.AuditLog_v2 (the CollapseAuditLogToCanonical migration): + -- 10 canonical columns first, then the 6 computed columns. CREATE TABLE dbo.[{stagingTableName}] ( EventId uniqueidentifier NOT NULL, OccurredAtUtc datetime2(7) NOT NULL, - IngestedAtUtc datetime2(7) NULL, - Channel varchar(32) NOT NULL, - Kind varchar(32) NOT NULL, - CorrelationId uniqueidentifier NULL, - SourceSiteId varchar(64) NULL, - SourceInstanceId varchar(128) NULL, - SourceScript varchar(128) NULL, - Actor varchar(128) NULL, - Target varchar(256) NULL, - Status varchar(32) NOT NULL, - HttpStatus int NULL, - DurationMs int NULL, - ErrorMessage nvarchar(1024) NULL, - ErrorDetail nvarchar(max) NULL, - RequestSummary nvarchar(max) NULL, - ResponseSummary nvarchar(max) NULL, - PayloadTruncated bit NOT NULL, - Extra nvarchar(max) NULL, - ForwardState varchar(32) NULL, - -- ExecutionId, ParentExecutionId, and SourceNode are last (in this - -- ordinal order) because each was added to the live AuditLog table - -- by a later ALTER TABLE ADD migration; the staging table must - -- match the live table column shape ordinal-for-ordinal or - -- ALTER TABLE ... SWITCH PARTITION fails (msg 4904/4915). - ExecutionId uniqueidentifier NULL, - ParentExecutionId uniqueidentifier NULL, + Actor nvarchar(256) NULL, + Action varchar(64) NOT NULL, + Outcome varchar(16) NOT NULL, + Category varchar(32) NOT NULL, + Target nvarchar(256) NULL, SourceNode varchar(64) NULL, + CorrelationId uniqueidentifier NULL, + DetailsJson nvarchar(max) NULL, + Kind AS JSON_VALUE(DetailsJson,'$.kind') PERSISTED, + Status AS JSON_VALUE(DetailsJson,'$.status') PERSISTED, + SourceSiteId AS JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED, + ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED, + ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED, + IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)), CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) ) ON [PRIMARY]; @@ -648,24 +633,28 @@ VALUES SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL ) - -- ParentExecutionId / SourceSiteId / SourceInstanceId are - -- derived via MIN: every audit row of one execution carries - -- the SAME ParentExecutionId (and source identity) — it is - -- stamped once per script run / inbound request — so MIN - -- simply picks that one shared value, it is not collapsing a - -- genuine disagreement across rows. + -- C5 (Task 2.5): ExecutionId / ParentExecutionId / SourceSiteId + -- are persisted computed columns (same names); Channel is now the + -- canonical Category column (Category = channel name, so the + -- Channels aggregate still yields channel names); SourceInstanceId + -- is no longer a column — read it from DetailsJson via JSON_VALUE. + -- ParentExecutionId / SourceSiteId / SourceInstanceId are derived + -- via MIN: every audit row of one execution carries the SAME value + -- (stamped once per script run / inbound request) — MIN simply + -- picks that one shared value, not collapsing a genuine + -- disagreement across rows. SELECT ids.ExecutionId AS [ExecutionId], MIN(a.ParentExecutionId) AS [ParentExecutionId], COUNT(a.EventId) AS [RowCount], - (SELECT STRING_AGG(d.Channel, ',') - FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2 + (SELECT STRING_AGG(d.Category, ',') + FROM (SELECT DISTINCT a2.Category FROM dbo.AuditLog a2 WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels], (SELECT STRING_AGG(d.Status, ',') FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2 WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses], MIN(a.SourceSiteId) AS [SourceSiteId], - MIN(a.SourceInstanceId) AS [SourceInstanceId], + MIN(JSON_VALUE(a.DetailsJson,'$.sourceInstanceId')) AS [SourceInstanceId], MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc], MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc] FROM ChainIds ids diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs index 0adb6e55..581d58cf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs @@ -154,6 +154,43 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaBridgeDbContext).Assembly); ApplySecretColumnEncryption(modelBuilder); + + NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(modelBuilder); + } + + /// + /// C5 (Task 2.5): the central dbo.AuditLog persisted computed columns use + /// SQL Server's JSON_VALUE expression, which only SQL Server can evaluate. + /// On a non-SQL-Server provider (the SQLite test contexts) emitting that SQL in a + /// CREATE TABLE fails ("no such function: JSON_VALUE"). Strip the + /// computed-column SQL for any non-SQL-Server provider so those columns degrade to + /// plain (always-null on the test provider) nullable columns; SQL Server keeps the + /// real … AS JSON_VALUE(...) PERSISTED definitions. This is a model-shape + /// adaptation only — it never runs under the design-time SQL Server provider, so the + /// migration / model-snapshot remain the canonical SQL Server shape. + /// + private void NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(ModelBuilder modelBuilder) + { + // Database.IsSqlServer() reads the configured provider — true for production and + // for the design-time factory (UseSqlServer), false for the SQLite test contexts. + if (Database.IsSqlServer()) + { + return; + } + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.GetComputedColumnSql() is not null) + { + property.SetComputedColumnSql(null); + property.SetColumnType(null); + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Save); + } + } + } } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs index 4cb7abc9..f26cc40e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs @@ -70,17 +70,16 @@ public class PartitionPurgeTests : TestKit, IClassFixture string siteId) { await using var cmd = conn.CreateCommand(); + // C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson; + // the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in + // DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive. + // Action = "{channel}.{kind}", Category = channel name, Outcome = Success. cmd.CommandText = @" INSERT INTO dbo.AuditLog - (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, - HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, - ResponseSummary, PayloadTruncated, Extra, ForwardState) + (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson) VALUES - (@EventId, @OccurredAtUtc, @IngestedAtUtc, 'ApiOutbound', 'ApiCall', NULL, - @SourceSiteId, NULL, NULL, NULL, NULL, 'Delivered', - NULL, NULL, NULL, NULL, NULL, - NULL, 0, NULL, NULL);"; + (@EventId, @OccurredAtUtc, NULL, 'ApiOutbound.ApiCall', 'Success', 'ApiOutbound', NULL, NULL, NULL, + @DetailsJson);"; cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId; // SqlDbType.DateTime2 with explicit Scale 7 matches the // OccurredAtUtc column shape (datetime2(7)) and avoids the implicit @@ -93,10 +92,14 @@ VALUES var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2); occurredParam.Scale = 7; occurredParam.Value = occurredAtUtc; - var ingestedParam = cmd.Parameters.Add("@IngestedAtUtc", System.Data.SqlDbType.DateTime2); - ingestedParam.Scale = 7; - ingestedParam.Value = DateTime.UtcNow; - cmd.Parameters.Add("@SourceSiteId", System.Data.SqlDbType.VarChar, 64).Value = siteId; + // DetailsJson carries the camelCase domain fields (matching AuditDetailsCodec): + // channel/kind/status drive the computed Kind/Status columns; sourceSiteId drives + // the computed SourceSiteId column the verify queries scope on. payloadTruncated + // is always present (the codec always writes the bool). + var detailsJson = + "{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"," + + "\"sourceSiteId\":\"" + siteId + "\",\"payloadTruncated\":false}"; + cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson; await cmd.ExecuteNonQueryAsync(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index 7d00b49a..d5446b79 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -3,15 +3,20 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities; +using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations; /// -/// Schema-level tests for (#23 M1 Bundle B). -/// Verifies that maps to the AuditLog table with the -/// PK, property set, column types/lengths, and five named indexes specified in alog.md §4. -/// Inspects EF model metadata via the existing in-memory SQLite test context — no -/// database round-trips required. +/// Schema-level tests for the C5 (Task 2.5) . +/// After the central dbo.AuditLog collapse the table is the 10 canonical +/// ZB.MOM.WW.Audit.AuditEvent columns plus six read-only persisted computed +/// columns derived from DetailsJson. Verifies the PK, the canonical/computed +/// property split, the Channel→Category column mapping, the computed-column SQL, and the +/// named indexes — via EF model metadata on the in-memory SQLite test context (the SQLite +/// test context strips the JSON_VALUE computed SQL so EnsureCreated still succeeds; these +/// metadata assertions read the production-shaped model before that strip is observable +/// for column-name/key/index facts). /// public class AuditLogEntityTypeConfigurationTests : IDisposable { @@ -33,7 +38,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable { // Composite PK {EventId, OccurredAtUtc} is required by the partitioned // AuditLog table — the clustered key must include the partition column - // (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C). + // (OccurredAtUtc) so each row can be located in its partition. var entity = _context.Model.FindEntityType(typeof(AuditLogRow)); Assert.NotNull(entity); @@ -50,8 +55,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups() { // EventId remains globally unique (the idempotency key for - // InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that - // is independent of the composite PK. + // InsertIfNotExistsAsync) via a dedicated unique index independent of the + // composite PK. var entity = _context.Model.FindEntityType(typeof(AuditLogRow)); Assert.NotNull(entity); @@ -75,10 +80,65 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .Where(p => !p.IsShadowProperty()) .ToList(); - // AuditLogRow record exposes 24 init-only properties (alog.md §4 plus the - // additive ExecutionId universal correlation column, its ParentExecutionId - // sibling, and the SourceNode-stamping column). - Assert.Equal(24, properties.Count); + // C5: 10 canonical columns (EventId, OccurredAtUtc, Actor, Action, Outcome, + // Channel[=Category], Target, SourceNode, CorrelationId, DetailsJson) + 6 + // persisted computed columns (Kind, Status, SourceSiteId, ExecutionId, + // ParentExecutionId, IngestedAtUtc) = 16. + Assert.Equal(16, properties.Count); + } + + [Fact] + public void Configure_ChannelMapsToCanonicalCategoryColumn() + { + // The Channel enum property is stored in the canonical Category column + // (Category = channel name for ScadaBridge). + var entity = _context.Model.FindEntityType(typeof(AuditLogRow)); + Assert.NotNull(entity); + + var channel = entity!.FindProperty(nameof(AuditLogRow.Channel)); + Assert.NotNull(channel); + Assert.Equal("Category", channel!.GetColumnName()); + } + + [Theory] + [InlineData(nameof(AuditLogRow.Kind), "JSON_VALUE(DetailsJson,'$.kind')")] + [InlineData(nameof(AuditLogRow.Status), "JSON_VALUE(DetailsJson,'$.status')")] + [InlineData(nameof(AuditLogRow.SourceSiteId), "JSON_VALUE(DetailsJson,'$.sourceSiteId')")] + [InlineData(nameof(AuditLogRow.ExecutionId), "CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)")] + [InlineData(nameof(AuditLogRow.ParentExecutionId), "CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)")] + public void Configure_ComputedColumns_HaveExpectedPersistedSql(string propertyName, string expectedSql) + { + // Note: the SQLite test context strips computed-column SQL so EnsureCreated + // works. Re-build a SQL Server model here to read the production computed SQL. + using var sqlServerContext = CreateSqlServerModelContext(); + var entity = sqlServerContext.Model.FindEntityType(typeof(AuditLogRow)); + Assert.NotNull(entity); + + var property = entity!.FindProperty(propertyName); + Assert.NotNull(property); + Assert.Equal(expectedSql, property!.GetComputedColumnSql()); + // Persisted (stored) computed column. + Assert.True(property.GetIsStored()); + // Read-only — EF never writes it. + Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated); + } + + [Fact] + public void Configure_IngestedAtUtc_IsNonPersistedComputedColumn() + { + using var sqlServerContext = CreateSqlServerModelContext(); + var entity = sqlServerContext.Model.FindEntityType(typeof(AuditLogRow)); + Assert.NotNull(entity); + + var property = entity!.FindProperty(nameof(AuditLogRow.IngestedAtUtc)); + Assert.NotNull(property); + Assert.Equal( + "CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", + property!.GetComputedColumnSql()); + // NON-persisted: the datetimeoffset/SWITCHOFFSET cast is non-deterministic, so + // SQL Server rejects a PERSISTED column. It is not indexed, so this is fine. + Assert.False(property.GetIsStored() ?? true); + Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated); } [Fact] @@ -92,12 +152,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .OrderBy(n => n, StringComparer.Ordinal) .ToList(); - // Five reconciliation/query indexes from alog.md §4, plus the EventId unique - // index introduced alongside the composite PK (Bundle C), plus the additive - // IX_AuditLog_Execution index supporting ExecutionId lookups, the - // IX_AuditLog_ParentExecution index supporting ParentExecutionId lookups, - // and the IX_AuditLog_Node_Occurred composite supporting per-node queries - // (SourceNode-stamping). + // The index NAMES are preserved across the C5 collapse (the column sets move to + // the canonical/computed shape but the discoverable names do not change). var expected = new[] { "IX_AuditLog_Channel_Status_Occurred", @@ -115,10 +171,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable } [Theory] - [InlineData(nameof(AuditLogRow.Channel))] [InlineData(nameof(AuditLogRow.Kind))] [InlineData(nameof(AuditLogRow.Status))] - [InlineData(nameof(AuditLogRow.ForwardState))] + [InlineData(nameof(AuditLogRow.Channel))] public void Configure_EnumColumns_StoredAsVarchar32(string propertyName) { var entity = _context.Model.FindEntityType(typeof(AuditLogRow)); @@ -133,56 +188,58 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable Assert.False(property.IsUnicode() ?? true); } + [Fact] + public void Configure_Outcome_StoredAsVarchar16() + { + var entity = _context.Model.FindEntityType(typeof(AuditLogRow)); + Assert.NotNull(entity); + + var property = entity!.FindProperty(nameof(AuditLogRow.Outcome)); + Assert.NotNull(property); + Assert.Equal(typeof(string), property!.GetProviderClrType() ?? property.ClrType); + Assert.Equal(16, property.GetMaxLength()); + Assert.False(property.IsUnicode() ?? true); + } + [Fact] public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc() { - // Insert an AuditLogRow with an Unspecified-Kind DateTime, then re-read - // it in a fresh context. The UtcConverter on the OccurredAtUtc / - // IngestedAtUtc columns must re-tag the round-tripped value as - // DateTimeKind.Utc. Without the converter the SQLite (and on production - // SQL Server, datetime2) provider would yield Kind=Unspecified — see - // ConfigurationDatabase-018/020 and Commons-019. + // Insert an AuditLogRow with an Unspecified-Kind DateTime, then re-read it in + // a fresh context. The UtcConverter on OccurredAtUtc must re-tag the + // round-tripped value as DateTimeKind.Utc. Only the canonical columns are + // written here — the computed columns are read-only (and stripped to plain, + // always-null columns on the SQLite test model). var unspecifiedOccurred = new DateTime(2026, 5, 28, 10, 30, 0, DateTimeKind.Unspecified); - var unspecifiedIngested = new DateTime(2026, 5, 28, 10, 31, 0, DateTimeKind.Unspecified); var eventId = Guid.NewGuid(); - var siteId = "test-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var corr = Guid.NewGuid(); var evt = new AuditLogRow { EventId = eventId, - // The AuditLogRow record's init-setter (Commons-019 resolution) - // re-tags Unspecified values as Utc on assignment, so the value EF - // ultimately writes already has Kind=Utc. The converter's job is - // to keep the Kind tag on the READ path, which the assertions - // below exercise. OccurredAtUtc = unspecifiedOccurred, - IngestedAtUtc = unspecifiedIngested, + Actor = "system", + Action = "ApiOutbound.ApiCall", + Outcome = AuditOutcome.Success, Channel = AuditChannel.ApiOutbound, - Kind = AuditKind.ApiCall, - Status = AuditStatus.Delivered, - SourceSiteId = siteId, + CorrelationId = corr, + DetailsJson = "{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"}", }; _context.Set().Add(evt); await _context.SaveChangesAsync(); - // Detach the tracked entity and re-read in a fresh query so we exercise - // the actual hydrate path, not the change-tracker cache. + // Detach and re-read so we exercise the hydrate path, not the change tracker. _context.ChangeTracker.Clear(); var loaded = await _context.Set() .AsNoTracking() - .Where(e => e.SourceSiteId == siteId) + .Where(e => e.EventId == eventId) .SingleAsync(); Assert.Equal(DateTimeKind.Utc, loaded.OccurredAtUtc.Kind); - Assert.NotNull(loaded.IngestedAtUtc); - Assert.Equal(DateTimeKind.Utc, loaded.IngestedAtUtc!.Value.Kind); - - // The timestamp ticks must round-trip unchanged — the converter only - // touches the Kind flag, not the wall-clock value. + // The timestamp ticks must round-trip unchanged — the converter only touches + // the Kind flag, not the wall-clock value. Assert.Equal(unspecifiedOccurred.Ticks, loaded.OccurredAtUtc.Ticks); - Assert.Equal(unspecifiedIngested.Ticks, loaded.IngestedAtUtc.Value.Ticks); } [Fact] @@ -190,8 +247,6 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable { // Model-metadata cross-check on the converter wiring — guards against a // future config refactor accidentally removing the HasConversion calls. - // The converter type itself is internal to the configuration, so we - // just assert SOME converter is present on each *Utc DateTime column. var entity = _context.Model.FindEntityType(typeof(AuditLogRow)); Assert.NotNull(entity); @@ -218,12 +273,27 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred"); Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter()); + // ExecutionId / ParentExecutionId are computed columns — SQL Server forbids a + // filtered-index predicate on a computed column, so these indexes are UNFILTERED. var executionIdx = entity.GetIndexes() .Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution"); - Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter()); + Assert.Null(executionIdx.GetFilter()); var parentExecutionIdx = entity.GetIndexes() .Single(i => i.GetDatabaseName() == "IX_AuditLog_ParentExecution"); - Assert.Equal("[ParentExecutionId] IS NOT NULL", parentExecutionIdx.GetFilter()); + Assert.Null(parentExecutionIdx.GetFilter()); + } + + /// + /// Builds a model-only DbContext on the SQL Server provider so the production + /// computed-column SQL (which the SQLite test context strips) can be asserted. + /// No database connection is opened — only the model is materialised. + /// + private static ScadaBridgeDbContext CreateSqlServerModelContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=unused;Database=unused;TrustServerCertificate=true") + .Options; + return new ScadaBridgeDbContext(options); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs index f4c364a9..68483fee 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs @@ -211,9 +211,14 @@ public class AddAuditLogTableMigrationTests : IClassFixture .ToListAsync(); Assert.Single(loaded); - Assert.Equal("first", loaded[0].ErrorMessage); + // C5 (Task 2.5): ErrorMessage rides in DetailsJson now — decode it to assert + // first-write-wins kept the original payload. + Assert.Equal("first", AuditDetailsCodec.Deserialize(loaded[0].DetailsJson).ErrorMessage); } [SkippableFact] @@ -726,8 +728,9 @@ public class AuditLogRepositoryTests : IClassFixture Assert.Single(rows); Assert.Equal(preExisting.EventId, rows[0].EventId); - // First-write-wins: the original ErrorMessage (null) survives. - Assert.Null(rows[0].ErrorMessage); + // First-write-wins: the original ErrorMessage (null) survives. C5 (Task 2.5): + // ErrorMessage rides in DetailsJson — decode it to assert. + Assert.Null(AuditDetailsCodec.Deserialize(rows[0].DetailsJson).ErrorMessage); } [SkippableFact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SqliteTestHelper.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SqliteTestHelper.cs index deeaa3ff..8c8647f5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SqliteTestHelper.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SqliteTestHelper.cs @@ -38,6 +38,12 @@ public class SqliteTestDbContext : ScadaBridgeDbContext .ValueGeneratedNever(); }); + // Note (C5 / Task 2.5): the central dbo.AuditLog persisted computed columns + // (JSON_VALUE-based) are neutralized for non-SQL-Server providers inside + // ScadaBridgeDbContext.OnModelCreating itself, so every SQLite test context — + // including ones that construct ScadaBridgeDbContext directly rather than via + // this helper — gets the strip. Nothing to do here. + // Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works var converter = new ValueConverter( v => v.UtcDateTime.ToString("o"),