From f57f61deacd07f5a5d1f8db2098e04b7eab1d80e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 06:52:53 -0400 Subject: [PATCH] feat(audit): EventId + CorrelationId columns + filtered unique index (F3 + F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigAuditLog gains two nullable columns (EventId, CorrelationId) + a filtered unique index UX_ConfigAuditLog_EventId. EF migration 20260526105027_AddConfigAuditLogEventIdColumns is additive (nullable + filtered index = legacy rows backfill cleanly). AuditWriterActor now writes EventId + CorrelationId into the dedicated columns instead of synthesising a JSON wrapper into DetailsJson. Cross-restart dedup is now real: a retry of an already-flushed batch hits the unique index and SaveChanges throws; the existing catch drops the duplicate without losing the rest of the batch. WrapDetails helper deleted — F4 (its JSON hardening) becomes moot. AuditWriterActorTests.Details_wrapper_embeds_eventId_and_correlationId renamed + rewritten to assert against the columns. All 29 ControlPlane tests pass, all 95 v2 tests green. --- .../Entities/ConfigAuditLog.cs | 12 + ...ddConfigAuditLogEventIdColumns.Designer.cs | 1755 +++++++++++++++++ ...6105027_AddConfigAuditLogEventIdColumns.cs | 50 + .../OtOpcUaConfigDbContextModelSnapshot.cs | 11 + .../OtOpcUaConfigDbContext.cs | 10 + .../Audit/AuditWriterActor.cs | 23 +- .../AuditWriterActorTests.cs | 8 +- 7 files changed, 1850 insertions(+), 19 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.Designer.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs index 35eaa89..627d5ee 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs @@ -22,4 +22,16 @@ public sealed class ConfigAuditLog public long? GenerationId { get; set; } public string? DetailsJson { get; set; } + + /// + /// Stable per-event identifier from AuditEvent.EventId. Filtered unique index on + /// this column gives cross-restart idempotency for the batched AuditWriterActor: a flush + /// that retries after a process crash can re-send the same EventId without producing a + /// duplicate row. Nullable so pre-v2 rows backfill cleanly. + /// + public Guid? EventId { get; set; } + + /// Correlation ID from AuditEvent.CorrelationId so an audit row joins to its + /// originating request/workflow. Nullable for the same backfill reason as . + public Guid? CorrelationId { get; set; } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.Designer.cs new file mode 100644 index 0000000..96ba5ae --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.Designer.cs @@ -0,0 +1,1755 @@ +// +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.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260526105027_AddConfigAuditLogEventIdColumns")] + partial class AddConfigAuditLogEventIdColumns + { + /// + 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", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_ClusterNode_ClusterId"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigEdit", b => + { + b.Property("EditId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("DeploymentId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); + + b.ToTable("Deployment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Detail") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("StateChangedUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId", "DriverInstanceId", "HostName"); + + b.HasIndex("LastSeenUtc") + .HasDatabaseName("IX_DriverHostStatus_LastSeen"); + + b.HasIndex("NodeId") + .HasDatabaseName("IX_DriverHostStatus_Node"); + + b.ToTable("DriverHostStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); + + b.HasIndex("DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("NamespaceId") + .HasDatabaseName("IX_DriverInstance_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstanceResilienceStatus", b => + { + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BaselineFootprintBytes") + .HasColumnType("bigint"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CurrentBulkheadDepth") + .HasColumnType("int"); + + b.Property("CurrentFootprintBytes") + .HasColumnType("bigint"); + + b.Property("LastCircuitBreakerOpenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastRecycleUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastSampledUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("DriverInstanceId", "HostName"); + + b.HasIndex("LastSampledUtc") + .HasDatabaseName("IX_DriverResilience_LastSampled"); + + b.ToTable("DriverInstanceResilienceStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Equipment_Driver"); + + b.HasIndex("EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Uuid"); + + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); + + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); + + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FinalisedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("RowsAccepted") + .HasColumnType("int"); + + b.Property("RowsRejected") + .HasColumnType("int"); + + b.Property("RowsStaged") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy", "FinalisedAtUtc") + .HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised"); + + b.ToTable("EquipmentImportBatch", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssetLocation") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("BatchId") + .HasColumnType("uniqueidentifier"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HardwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("LineNumberInFile") + .HasColumnType("int"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Manufacturer") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RejectReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SAPID") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SerialNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SoftwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsAreaName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("ZTag") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .HasDatabaseName("IX_EquipmentImportRow_Batch"); + + b.ToTable("EquipmentImportRow", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_Namespace_Cluster"); + + b.HasIndex("NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_NamespaceUri"); + + b.HasIndex("ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Cluster_Kind"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_NodeAcl_Group"); + + b.HasIndex("NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); + + b.HasIndex("PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b => + { + b.Property("ScriptRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ScriptRowId"); + + b.HasIndex("ScriptId") + .IsUnique() + .HasDatabaseName("UX_Script_LogicalId") + .HasFilter("[ScriptId] IS NOT NULL"); + + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); + + b.ToTable("Script", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => + { + b.Property("ScriptedAlarmRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AlarmType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HistorizeToAveva") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PredicateScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Retain") + .HasColumnType("bit"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("ScriptedAlarmRowId"); + + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); + + b.HasIndex("ScriptedAlarmId") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") + .HasFilter("[ScriptedAlarmId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + + b.ToTable("ScriptedAlarm", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')"); + + t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b => + { + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AckedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConfirmedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("EnabledState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastAckComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastAckUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastAckUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastConfirmComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastConfirmUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastConfirmUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingExpiresUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("ScriptedAlarmId"); + + b.ToTable("ScriptedAlarmState", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_Tag_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Driver_Device"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); + + b.HasIndex("UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); + + b.HasIndex("UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => + { + b.Property("VirtualTagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ChangeTriggered") + .HasColumnType("bit"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Historize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TimerIntervalMs") + .HasColumnType("int"); + + b.Property("VirtualTagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("VirtualTagRowId"); + + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); + + b.HasIndex("VirtualTagId") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_LogicalId") + .HasFilter("[VirtualTagId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); + + b.ToTable("VirtualTag", null, t => + { + t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50"); + + t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch") + .WithMany("Rows") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") + .WithMany() + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Deployment"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs new file mode 100644 index 0000000..72bb343 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class AddConfigAuditLogEventIdColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CorrelationId", + table: "ConfigAuditLog", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "EventId", + table: "ConfigAuditLog", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateIndex( + name: "UX_ConfigAuditLog_EventId", + table: "ConfigAuditLog", + column: "EventId", + unique: true, + filter: "[EventId] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "UX_ConfigAuditLog_EventId", + table: "ConfigAuditLog"); + + migrationBuilder.DropColumn( + name: "CorrelationId", + table: "ConfigAuditLog"); + + migrationBuilder.DropColumn( + name: "EventId", + table: "ConfigAuditLog"); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index 4601a53..ac59e65 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -165,9 +165,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + b.Property("DetailsJson") .HasColumnType("nvarchar(max)"); + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + b.Property("EventType") .IsRequired() .HasMaxLength(64) @@ -192,6 +198,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("AuditId"); + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + b.HasIndex("GenerationId") .HasDatabaseName("IX_ConfigAuditLog_Generation") .HasFilter("[GenerationId] IS NOT NULL"); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index eef01c5..06ef21f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -413,6 +413,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.ClusterId).HasMaxLength(64); e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)"); + e.Property(x => x.EventId); + e.Property(x => x.CorrelationId); e.HasIndex(x => new { x.ClusterId, x.Timestamp }) .IsDescending(false, true) @@ -420,6 +422,14 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.GenerationId) .HasFilter("[GenerationId] IS NOT NULL") .HasDatabaseName("IX_ConfigAuditLog_Generation"); + // Filtered unique index gives cross-restart idempotency for the AuditWriterActor: + // a retry of an already-flushed batch will hit this constraint and the catch in + // FlushBuffer drops the duplicate insert. Nullable + filter so legacy backfill rows + // (EventId=NULL) don't collide. + e.HasIndex(x => x.EventId) + .IsUnique() + .HasFilter("[EventId] IS NOT NULL") + .HasDatabaseName("UX_ConfigAuditLog_EventId"); }); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs index af08463..4a9457e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs @@ -14,9 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit; /// - elapses with a non-empty buffer. /// - PreRestart / PostStop (supervisor swap or coordinated shutdown). /// -/// Dedup is in-buffer only — once a batch is flushed, the actor accepts a duplicate -/// as a new row. True cross-restart idempotency needs an -/// EventId column with a unique index on ConfigAuditLog; tracked as follow-up F3. +/// Dedup is two-layer: in-buffer (the below collapses +/// duplicate EventIds before flush) and at the database via the filtered unique index +/// UX_ConfigAuditLog_EventId (cross-restart safety — a retry of an already-flushed +/// batch hits the constraint and we drop the duplicate insert without losing the rest of +/// the batch). /// public sealed class AuditWriterActor : ReceiveActor, IWithTimers { @@ -70,7 +72,9 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers Principal = evt.Actor, EventType = $"{evt.Category}:{evt.Action}", NodeId = evt.SourceNode.Value, - DetailsJson = WrapDetails(evt), + DetailsJson = evt.DetailsJson, + EventId = evt.EventId, + CorrelationId = evt.CorrelationId.Value, }); } db.SaveChanges(); @@ -82,17 +86,6 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers } } - /// - /// Wraps caller-supplied details with the EventId + CorrelationId so audit consumers can - /// reconstruct the original message. Until ConfigAuditLog gains a first-class EventId column - /// (follow-up F3), this is the only place these correlation IDs are persisted. - /// - private static string WrapDetails(AuditEvent evt) - { - var details = evt.DetailsJson ?? "null"; - return $"{{\"eventId\":\"{evt.EventId:N}\",\"correlationId\":\"{evt.CorrelationId.Value:N}\",\"details\":{details}}}"; - } - protected override void PreRestart(Exception reason, object message) { FlushBuffer(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs index a838217..6d9230e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs @@ -78,7 +78,7 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase } [Fact] - public void Details_wrapper_embeds_eventId_and_correlationId() + public void EventId_and_CorrelationId_are_persisted_to_dedicated_columns() { var dbFactory = NewInMemoryDbFactory(); var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory)); @@ -92,9 +92,9 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase using var db = dbFactory.CreateDbContext(); var row = db.ConfigAuditLogs.Single(); - row.DetailsJson.ShouldNotBeNull(); - row.DetailsJson.ShouldContain(eventId.ToString("N")); - row.DetailsJson.ShouldContain("\"correlationId\":"); + row.EventId.ShouldBe(eventId); + row.CorrelationId.ShouldNotBeNull(); + row.DetailsJson.ShouldBe("{\"field\":\"value\"}"); row.EventType.ShouldBe("Config:Edit"); row.NodeId.ShouldBe("node-a"); }