using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Configuration; /// /// Central config DB context. Schema matches docs/v2/config-db-schema.md exactly — /// any divergence is a defect caught by the SchemaComplianceTests introspection check. /// public sealed class OtOpcUaConfigDbContext(DbContextOptions options) : DbContext(options) { public DbSet ServerClusters => Set(); public DbSet ClusterNodes => Set(); public DbSet ClusterNodeCredentials => Set(); public DbSet ConfigGenerations => Set(); public DbSet Namespaces => Set(); public DbSet UnsAreas => Set(); public DbSet UnsLines => Set(); public DbSet DriverInstances => Set(); public DbSet Devices => Set(); public DbSet Equipment => Set(); public DbSet Tags => Set(); public DbSet PollGroups => Set(); public DbSet NodeAcls => Set(); public DbSet ClusterNodeGenerationStates => Set(); public DbSet ConfigAuditLogs => Set(); public DbSet ExternalIdReservations => Set(); public DbSet DriverHostStatuses => Set(); public DbSet DriverInstanceResilienceStatuses => Set(); public DbSet LdapGroupRoleMappings => Set(); public DbSet EquipmentImportBatches => Set(); public DbSet EquipmentImportRows => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); ConfigureServerCluster(modelBuilder); ConfigureClusterNode(modelBuilder); ConfigureClusterNodeCredential(modelBuilder); ConfigureConfigGeneration(modelBuilder); ConfigureNamespace(modelBuilder); ConfigureUnsArea(modelBuilder); ConfigureUnsLine(modelBuilder); ConfigureDriverInstance(modelBuilder); ConfigureDevice(modelBuilder); ConfigureEquipment(modelBuilder); ConfigureTag(modelBuilder); ConfigurePollGroup(modelBuilder); ConfigureNodeAcl(modelBuilder); ConfigureClusterNodeGenerationState(modelBuilder); ConfigureConfigAuditLog(modelBuilder); ConfigureExternalIdReservation(modelBuilder); ConfigureDriverHostStatus(modelBuilder); ConfigureDriverInstanceResilienceStatus(modelBuilder); ConfigureLdapGroupRoleMapping(modelBuilder); ConfigureEquipmentImportBatch(modelBuilder); } private static void ConfigureServerCluster(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("ServerCluster", t => { t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') " + "OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); }); e.HasKey(x => x.ClusterId); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(128); e.Property(x => x.Enterprise).HasMaxLength(32); e.Property(x => x.Site).HasMaxLength(32); e.Property(x => x.RedundancyMode).HasConversion().HasMaxLength(16); e.Property(x => x.Notes).HasMaxLength(1024); e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); e.Property(x => x.CreatedBy).HasMaxLength(128); e.Property(x => x.ModifiedAt).HasColumnType("datetime2(3)"); e.Property(x => x.ModifiedBy).HasMaxLength(128); e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_ServerCluster_Name"); e.HasIndex(x => x.Site).HasDatabaseName("IX_ServerCluster_Site"); }); } private static void ConfigureClusterNode(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("ClusterNode"); e.HasKey(x => x.NodeId); e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.RedundancyRole).HasConversion().HasMaxLength(16); e.Property(x => x.Host).HasMaxLength(255); e.Property(x => x.ApplicationUri).HasMaxLength(256); e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)"); e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)"); e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); e.Property(x => x.CreatedBy).HasMaxLength(128); e.HasOne(x => x.Cluster).WithMany(c => c.Nodes) .HasForeignKey(x => x.ClusterId) .OnDelete(DeleteBehavior.Restrict); // Fleet-wide unique per decision #86 e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri"); e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId"); // At most one Primary per cluster e.HasIndex(x => x.ClusterId).IsUnique() .HasFilter("[RedundancyRole] = 'Primary'") .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster"); }); } private static void ConfigureClusterNodeCredential(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("ClusterNodeCredential"); e.HasKey(x => x.CredentialId); e.Property(x => x.CredentialId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.Kind).HasConversion().HasMaxLength(32); e.Property(x => x.Value).HasMaxLength(512); e.Property(x => x.RotatedAt).HasColumnType("datetime2(3)"); e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); e.Property(x => x.CreatedBy).HasMaxLength(128); e.HasOne(x => x.Node).WithMany(n => n.Credentials) .HasForeignKey(x => x.NodeId) .OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.NodeId, x.Enabled }).HasDatabaseName("IX_ClusterNodeCredential_NodeId"); e.HasIndex(x => new { x.Kind, x.Value }).IsUnique() .HasFilter("[Enabled] = 1") .HasDatabaseName("UX_ClusterNodeCredential_Value"); }); } private static void ConfigureConfigGeneration(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("ConfigGeneration"); e.HasKey(x => x.GenerationId); e.Property(x => x.GenerationId).UseIdentityColumn(seed: 1, increment: 1); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.Status).HasConversion().HasMaxLength(16); e.Property(x => x.PublishedAt).HasColumnType("datetime2(3)"); e.Property(x => x.PublishedBy).HasMaxLength(128); e.Property(x => x.Notes).HasMaxLength(1024); e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); e.Property(x => x.CreatedBy).HasMaxLength(128); e.HasOne(x => x.Cluster).WithMany(c => c.Generations) .HasForeignKey(x => x.ClusterId) .OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.Parent).WithMany() .HasForeignKey(x => x.ParentGenerationId) .OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.ClusterId, x.Status, x.GenerationId }) .IsDescending(false, false, true) .IncludeProperties(x => x.PublishedAt) .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); // One Draft per cluster at a time e.HasIndex(x => x.ClusterId).IsUnique() .HasFilter("[Status] = 'Draft'") .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster"); }); } private static void ConfigureNamespace(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("Namespace"); e.HasKey(x => x.NamespaceRowId); e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.NamespaceId).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.Kind).HasConversion().HasMaxLength(32); e.Property(x => x.NamespaceUri).HasMaxLength(256); e.Property(x => x.Notes).HasMaxLength(1024); e.HasOne(x => x.Generation).WithMany() .HasForeignKey(x => x.GenerationId) .OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces) .HasForeignKey(x => x.ClusterId) .OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Kind }).IsUnique() .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); e.HasIndex(x => new { x.GenerationId, x.NamespaceUri }).IsUnique() .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).IsUnique() .HasDatabaseName("UX_Namespace_Generation_LogicalId"); e.HasIndex(x => new { x.GenerationId, x.NamespaceId, x.ClusterId }).IsUnique() .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster"); e.HasIndex(x => new { x.GenerationId, x.ClusterId }) .HasDatabaseName("IX_Namespace_Generation_Cluster"); }); } private static void ConfigureUnsArea(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("UnsArea"); e.HasKey(x => x.UnsAreaRowId); e.Property(x => x.UnsAreaRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.UnsAreaId).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(32); e.Property(x => x.Notes).HasMaxLength(512); e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_UnsArea_Generation_Cluster"); e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_LogicalId"); e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Name }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_ClusterName"); }); } private static void ConfigureUnsLine(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("UnsLine"); e.HasKey(x => x.UnsLineRowId); e.Property(x => x.UnsLineRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.UnsLineId).HasMaxLength(64); e.Property(x => x.UnsAreaId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(32); e.Property(x => x.Notes).HasMaxLength(512); e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).HasDatabaseName("IX_UnsLine_Generation_Area"); e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_LogicalId"); e.HasIndex(x => new { x.GenerationId, x.UnsAreaId, x.Name }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_AreaName"); }); } private static void ConfigureDriverInstance(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("DriverInstance", t => { t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); }); e.HasKey(x => x.DriverInstanceRowId); e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.NamespaceId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(128); e.Property(x => x.DriverType).HasMaxLength(32); e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)"); e.Property(x => x.ResilienceConfig).HasColumnType("nvarchar(max)"); e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_DriverInstance_Generation_Cluster"); e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).HasDatabaseName("IX_DriverInstance_Generation_Namespace"); e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).IsUnique().HasDatabaseName("UX_DriverInstance_Generation_LogicalId"); }); } private static void ConfigureDevice(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("Device", t => { t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); }); e.HasKey(x => x.DeviceRowId); e.Property(x => x.DeviceRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.DeviceId).HasMaxLength(64); e.Property(x => x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(128); e.Property(x => x.DeviceConfig).HasColumnType("nvarchar(max)"); e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Device_Generation_Driver"); e.HasIndex(x => new { x.GenerationId, x.DeviceId }).IsUnique().HasDatabaseName("UX_Device_Generation_LogicalId"); }); } private static void ConfigureEquipment(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("Equipment"); e.HasKey(x => x.EquipmentRowId); e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.EquipmentId).HasMaxLength(64); e.Property(x => x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.DeviceId).HasMaxLength(64); e.Property(x => x.UnsLineId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(32); e.Property(x => x.MachineCode).HasMaxLength(64); e.Property(x => x.ZTag).HasMaxLength(64); e.Property(x => x.SAPID).HasMaxLength(64); e.Property(x => x.Manufacturer).HasMaxLength(64); e.Property(x => x.Model).HasMaxLength(64); e.Property(x => x.SerialNumber).HasMaxLength(64); e.Property(x => x.HardwareRevision).HasMaxLength(32); e.Property(x => x.SoftwareRevision).HasMaxLength(32); e.Property(x => x.AssetLocation).HasMaxLength(256); e.Property(x => x.ManufacturerUri).HasMaxLength(512); e.Property(x => x.DeviceManualUri).HasMaxLength(512); e.Property(x => x.EquipmentClassRef).HasMaxLength(128); e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Equipment_Generation_Driver"); e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).HasDatabaseName("IX_Equipment_Generation_Line"); e.HasIndex(x => new { x.GenerationId, x.EquipmentId }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LogicalId"); e.HasIndex(x => new { x.GenerationId, x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LinePath"); e.HasIndex(x => new { x.GenerationId, x.EquipmentUuid }).IsUnique().HasDatabaseName("UX_Equipment_Generation_Uuid"); e.HasIndex(x => new { x.GenerationId, x.ZTag }).HasFilter("[ZTag] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_ZTag"); e.HasIndex(x => new { x.GenerationId, x.SAPID }).HasFilter("[SAPID] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_SAPID"); e.HasIndex(x => new { x.GenerationId, x.MachineCode }).HasDatabaseName("IX_Equipment_Generation_MachineCode"); }); } private static void ConfigureTag(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("Tag", t => { t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); }); e.HasKey(x => x.TagRowId); e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.TagId).HasMaxLength(64); e.Property(x => x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.DeviceId).HasMaxLength(64); e.Property(x => x.EquipmentId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(128); e.Property(x => x.FolderPath).HasMaxLength(512); e.Property(x => x.DataType).HasMaxLength(32); e.Property(x => x.AccessLevel).HasConversion().HasMaxLength(16); e.Property(x => x.PollGroupId).HasMaxLength(64); e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)"); e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Generation_Driver_Device"); e.HasIndex(x => new { x.GenerationId, x.EquipmentId }) .HasFilter("[EquipmentId] IS NOT NULL") .HasDatabaseName("IX_Tag_Generation_Equipment"); e.HasIndex(x => new { x.GenerationId, x.TagId }).IsUnique().HasDatabaseName("UX_Tag_Generation_LogicalId"); e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique() .HasFilter("[EquipmentId] IS NOT NULL") .HasDatabaseName("UX_Tag_Generation_EquipmentPath"); e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique() .HasFilter("[EquipmentId] IS NULL") .HasDatabaseName("UX_Tag_Generation_FolderPath"); }); } private static void ConfigurePollGroup(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("PollGroup", t => { t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); }); e.HasKey(x => x.PollGroupRowId); e.Property(x => x.PollGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.PollGroupId).HasMaxLength(64); e.Property(x => x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(128); e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_PollGroup_Generation_Driver"); e.HasIndex(x => new { x.GenerationId, x.PollGroupId }).IsUnique().HasDatabaseName("UX_PollGroup_Generation_LogicalId"); }); } private static void ConfigureNodeAcl(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("NodeAcl"); e.HasKey(x => x.NodeAclRowId); e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.NodeAclId).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.LdapGroup).HasMaxLength(256); e.Property(x => x.ScopeKind).HasConversion().HasMaxLength(16); e.Property(x => x.ScopeId).HasMaxLength(64); e.Property(x => x.PermissionFlags).HasConversion(); e.Property(x => x.Notes).HasMaxLength(512); e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_NodeAcl_Generation_Cluster"); e.HasIndex(x => new { x.GenerationId, x.LdapGroup }).HasDatabaseName("IX_NodeAcl_Generation_Group"); e.HasIndex(x => new { x.GenerationId, x.ScopeKind, x.ScopeId }) .HasFilter("[ScopeId] IS NOT NULL") .HasDatabaseName("IX_NodeAcl_Generation_Scope"); e.HasIndex(x => new { x.GenerationId, x.NodeAclId }).IsUnique().HasDatabaseName("UX_NodeAcl_Generation_LogicalId"); e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique() .HasDatabaseName("UX_NodeAcl_Generation_GroupScope"); }); } private static void ConfigureClusterNodeGenerationState(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("ClusterNodeGenerationState"); e.HasKey(x => x.NodeId); e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.LastAppliedAt).HasColumnType("datetime2(3)"); e.Property(x => x.LastAppliedStatus).HasConversion().HasMaxLength(16); e.Property(x => x.LastAppliedError).HasMaxLength(2048); e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)"); e.HasOne(x => x.Node).WithOne(n => n.GenerationState).HasForeignKey(x => x.NodeId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.CurrentGeneration).WithMany().HasForeignKey(x => x.CurrentGenerationId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => x.CurrentGenerationId).HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); }); } private static void ConfigureConfigAuditLog(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("ConfigAuditLog", t => { t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); }); e.HasKey(x => x.AuditId); e.Property(x => x.AuditId).UseIdentityColumn(seed: 1, increment: 1); e.Property(x => x.Timestamp).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); e.Property(x => x.Principal).HasMaxLength(128); e.Property(x => x.EventType).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)"); e.HasIndex(x => new { x.ClusterId, x.Timestamp }) .IsDescending(false, true) .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); e.HasIndex(x => x.GenerationId) .HasFilter("[GenerationId] IS NOT NULL") .HasDatabaseName("IX_ConfigAuditLog_Generation"); }); } private static void ConfigureExternalIdReservation(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("ExternalIdReservation"); e.HasKey(x => x.ReservationId); e.Property(x => x.ReservationId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.Kind).HasConversion().HasMaxLength(16); e.Property(x => x.Value).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.FirstPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); e.Property(x => x.FirstPublishedBy).HasMaxLength(128); e.Property(x => x.LastPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); e.Property(x => x.ReleasedAt).HasColumnType("datetime2(3)"); e.Property(x => x.ReleasedBy).HasMaxLength(128); e.Property(x => x.ReleaseReason).HasMaxLength(512); // Active reservations unique per (Kind, Value) — filtered index lets released rows coexist with a new reservation of the same value. // The UX_ filtered index covers active-reservation lookups; history queries over released rows // fall back to the table scan (released rows are rare + small). No separate non-unique (Kind, Value) // index is declared because EF Core merges duplicate column sets into a single index, which would // clobber the filtered-unique name. e.HasIndex(x => new { x.Kind, x.Value }).IsUnique() .HasFilter("[ReleasedAt] IS NULL") .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active"); e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment"); }); } private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("DriverHostStatus"); // Composite key — one row per (server node, driver instance, probe-reported host). // A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces // 6 rows because each server node owns its own runtime view; the composite key is // what lets both views coexist without shadowing each other. e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName }); e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.HostName).HasMaxLength(256); e.Property(x => x.State).HasConversion().HasMaxLength(16); e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)"); e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)"); e.Property(x => x.Detail).HasMaxLength(1024); // NodeId-only index drives the Admin UI's per-cluster drill-down (select all host // statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId). e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node"); // LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N). e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen"); }); } private static void ConfigureDriverInstanceResilienceStatus(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("DriverInstanceResilienceStatus"); e.HasKey(x => new { x.DriverInstanceId, x.HostName }); e.Property(x => x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.HostName).HasMaxLength(256); e.Property(x => x.LastCircuitBreakerOpenUtc).HasColumnType("datetime2(3)"); e.Property(x => x.LastRecycleUtc).HasColumnType("datetime2(3)"); e.Property(x => x.LastSampledUtc).HasColumnType("datetime2(3)"); // LastSampledUtc drives the Admin UI's stale-sample filter same way DriverHostStatus's // LastSeenUtc index does for connectivity rows. e.HasIndex(x => x.LastSampledUtc).HasDatabaseName("IX_DriverResilience_LastSampled"); }); } private static void ConfigureLdapGroupRoleMapping(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("LdapGroupRoleMapping"); e.HasKey(x => x.Id); e.Property(x => x.LdapGroup).HasMaxLength(512).IsRequired(); e.Property(x => x.Role).HasConversion().HasMaxLength(32); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)"); e.Property(x => x.Notes).HasMaxLength(512); // FK to ServerCluster when cluster-scoped; null for system-wide grants. e.HasOne(x => x.Cluster) .WithMany() .HasForeignKey(x => x.ClusterId) .OnDelete(DeleteBehavior.Cascade); // Uniqueness: one row per (LdapGroup, ClusterId). Null ClusterId is treated as its own // "bucket" so a system-wide row coexists with cluster-scoped rows for the same group. // SQL Server treats NULL as a distinct value in unique-index comparisons by default // since 2008 SP1 onwards under the session setting we use — tested in SchemaCompliance. e.HasIndex(x => new { x.LdapGroup, x.ClusterId }) .IsUnique() .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster"); // Hot-path lookup during cookie auth: "what grants does this user's set of LDAP // groups carry?". Fires on every sign-in so the index earns its keep. e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_LdapGroupRoleMapping_Group"); }); } private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.ToTable("EquipmentImportBatch"); e.HasKey(x => x.Id); e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.CreatedBy).HasMaxLength(128); e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)"); e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)"); // Admin preview modal filters by user; finalise / drop both hit this index. e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc }) .HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised"); }); modelBuilder.Entity(e => { e.ToTable("EquipmentImportRow"); e.HasKey(x => x.Id); e.Property(x => x.ZTag).HasMaxLength(128); e.Property(x => x.MachineCode).HasMaxLength(128); e.Property(x => x.SAPID).HasMaxLength(128); e.Property(x => x.EquipmentId).HasMaxLength(64); e.Property(x => x.EquipmentUuid).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(128); e.Property(x => x.UnsAreaName).HasMaxLength(64); e.Property(x => x.UnsLineName).HasMaxLength(64); e.Property(x => x.Manufacturer).HasMaxLength(256); e.Property(x => x.Model).HasMaxLength(256); e.Property(x => x.SerialNumber).HasMaxLength(256); e.Property(x => x.HardwareRevision).HasMaxLength(64); e.Property(x => x.SoftwareRevision).HasMaxLength(64); e.Property(x => x.YearOfConstruction).HasMaxLength(8); e.Property(x => x.AssetLocation).HasMaxLength(512); e.Property(x => x.ManufacturerUri).HasMaxLength(512); e.Property(x => x.DeviceManualUri).HasMaxLength(512); e.Property(x => x.RejectReason).HasMaxLength(512); e.HasOne(x => x.Batch) .WithMany(b => b.Rows) .HasForeignKey(x => x.BatchId) .OnDelete(DeleteBehavior.Cascade); e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch"); }); } }