From 3a590a0cb79b47e0d4c28f94a572d2aa95b3e97b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 14:33:11 -0400 Subject: [PATCH] refactor(mesh-phase4): retire EfAlarmConditionStateStore + drop dead ScriptedAlarmState table (Task 9) Scripted-alarm condition state lives in LocalDb (Phase 4); the ConfigDb-backed Ef store + ScriptedAlarmState table are now dead. Removes the test-harness Ef fallback (a DB-backed node with no store now skips the alarm host), deletes the store + entity + model config, and drops the table via migration. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Entities/ScriptedAlarmState.cs | 65 - ...50_DropScriptedAlarmStateTable.Designer.cs | 1639 +++++++++++++++++ ...60723183050_DropScriptedAlarmStateTable.cs | 47 + .../OtOpcUaConfigDbContextModelSnapshot.cs | 68 - .../OtOpcUaConfigDbContext.cs | 31 - .../Drivers/DriverHostActor.cs | 59 +- .../EfAlarmConditionStateStore.cs | 240 --- .../ServiceCollectionExtensions.cs | 6 +- .../SchemaComplianceTests.cs | 6 +- .../ScriptingEntitiesTests.cs | 53 +- .../Drivers/DriverHostActorDbLessTests.cs | 10 +- .../EfAlarmConditionStateStoreTests.cs | 244 --- 12 files changed, 1727 insertions(+), 741 deletions(-) delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260723183050_DropScriptedAlarmStateTable.Designer.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260723183050_DropScriptedAlarmStateTable.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs deleted file mode 100644 index 49a60311..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; - -/// -/// Persistent runtime state for each scripted alarm. Survives process restart so -/// operators don't re-ack and ack history survives for -/// GxP / 21 CFR Part 11 compliance. Keyed on ScriptedAlarmId logically (not -/// per-generation) because ack state follows the alarm's stable identity across -/// generations — a Modified alarm keeps its ack history. -/// -/// -/// -/// ActiveState is deliberately NOT persisted — it rederives from the current -/// predicate evaluation on startup. Only operator-supplied state (, -/// , ) + audit trail persist. -/// -/// -/// is an append-only JSON array of {user, utc, text} -/// tuples — one per operator comment. Core.ScriptedAlarms' AlarmConditionState.Comments -/// serializes directly into this column. -/// -/// -public sealed class ScriptedAlarmState -{ - /// Logical FK — matches . One row per alarm identity. - public required string ScriptedAlarmId { get; set; } - - /// Enabled/Disabled. Persists across restart. - public required string EnabledState { get; set; } = "Enabled"; - - /// Unacknowledged / Acknowledged. - public required string AckedState { get; set; } = "Unacknowledged"; - - /// Unconfirmed / Confirmed. - public required string ConfirmedState { get; set; } = "Unconfirmed"; - - /// Unshelved / OneShotShelved / TimedShelved. - public required string ShelvingState { get; set; } = "Unshelved"; - - /// When a TimedShelve expires — null if not shelved or OneShotShelved. - public DateTime? ShelvingExpiresUtc { get; set; } - - /// User who last acknowledged. Null if never acked. - public string? LastAckUser { get; set; } - - /// Operator-supplied ack comment. Null if no comment or never acked. - public string? LastAckComment { get; set; } - - /// Gets or sets the UTC timestamp of the last acknowledgment. - public DateTime? LastAckUtc { get; set; } - - /// User who last confirmed. - public string? LastConfirmUser { get; set; } - - /// Gets or sets the operator-supplied confirm comment. Null if no comment or never confirmed. - public string? LastConfirmComment { get; set; } - - /// Gets or sets the UTC timestamp of the last confirmation. - public DateTime? LastConfirmUtc { get; set; } - - /// JSON array of operator comments, append-only (GxP audit). - public string CommentsJson { get; set; } = "[]"; - - /// Row write timestamp — tracks last state change. - public DateTime UpdatedAtUtc { get; set; } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260723183050_DropScriptedAlarmStateTable.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260723183050_DropScriptedAlarmStateTable.Designer.cs new file mode 100644 index 00000000..3b3b8461 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260723183050_DropScriptedAlarmStateTable.Designer.cs @@ -0,0 +1,1639 @@ +// +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("20260723183050_DropScriptedAlarmStateTable")] + partial class DropScriptedAlarmStateTable + { + /// + 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("AkkaPort") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(4053); + + 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("GrpcPort") + .HasColumnType("int"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("MaintenanceMode") + .HasColumnType("bit"); + + 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("Outcome") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + 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.HasIndex("DriverInstanceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Device_Driver_Name"); + + 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("RawFolderId") + .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("RawFolderId") + .HasDatabaseName("IX_DriverInstance_RawFolder"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_ClusterRoot_Name") + .HasFilter("[RawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "RawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Folder_Name") + .HasFilter("[RawFolderId] IS NOT NULL"); + + 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("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + 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("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.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.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.RawFolder", b => + { + b.Property("RawFolderRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentRawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("RawFolderRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_RawFolder_Cluster"); + + b.HasIndex("RawFolderId") + .IsUnique() + .HasDatabaseName("UX_RawFolder_LogicalId") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_ClusterRoot_Name") + .HasFilter("[ParentRawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "ParentRawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_Parent_Name") + .HasFilter("[ParentRawFolderId] IS NOT NULL"); + + b.ToTable("RawFolder", (string)null); + }); + + 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.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") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + 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("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Device_Name") + .HasFilter("[TagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "TagGroupId") + .HasDatabaseName("IX_Tag_Device"); + + b.HasIndex("DeviceId", "TagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Group_Name") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b => + { + b.Property("TagGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentTagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("TagGroupRowId"); + + b.HasIndex("DeviceId") + .HasDatabaseName("IX_TagGroup_Device"); + + b.HasIndex("TagGroupId") + .IsUnique() + .HasDatabaseName("UX_TagGroup_LogicalId") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_DeviceRoot_Name") + .HasFilter("[ParentTagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "ParentTagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_Parent_Name") + .HasFilter("[ParentTagGroupId] IS NOT NULL"); + + b.ToTable("TagGroup", (string)null); + }); + + 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.UnsTagReference", b => + { + b.Property("UnsTagReferenceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DisplayNameOverride") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsTagReferenceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsTagReferenceRowId"); + + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_UnsTagReference_Equipment"); + + b.HasIndex("TagId") + .HasDatabaseName("IX_UnsTagReference_Tag"); + + b.HasIndex("UnsTagReferenceId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_LogicalId") + .HasFilter("[UnsTagReferenceId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "TagId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_Equip_Tag"); + + b.ToTable("UnsTagReference", (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.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.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.RawFolder", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", null) + .WithMany("RawFolders") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + 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.ServerCluster", b => + { + b.Navigation("Nodes"); + + b.Navigation("RawFolders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260723183050_DropScriptedAlarmStateTable.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260723183050_DropScriptedAlarmStateTable.cs new file mode 100644 index 00000000..b2fb90ae --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260723183050_DropScriptedAlarmStateTable.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class DropScriptedAlarmStateTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ScriptedAlarmState"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ScriptedAlarmState", + columns: table => new + { + ScriptedAlarmId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + AckedState = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + CommentsJson = table.Column(type: "nvarchar(max)", nullable: false), + ConfirmedState = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + EnabledState = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + LastAckComment = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + LastAckUser = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + LastAckUtc = table.Column(type: "datetime2(3)", nullable: true), + LastConfirmComment = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + LastConfirmUser = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + LastConfirmUtc = table.Column(type: "datetime2(3)", nullable: true), + ShelvingExpiresUtc = table.Column(type: "datetime2(3)", nullable: true), + ShelvingState = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + UpdatedAtUtc = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId); + table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1"); + }); + } + } +} 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 42ebaf07..f10f9910 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -1113,74 +1113,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations }); }); - 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") diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index 6c380a3c..eed29d95 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -56,8 +56,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions VirtualTags => Set(); /// Gets the DbSet of scripted alarms. public DbSet ScriptedAlarms => Set(); - /// Gets the DbSet of scripted alarm states. - public DbSet ScriptedAlarmStates => Set(); // v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment). /// Gets the DbSet of deployments. @@ -98,7 +96,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions(e => - { - // Logical-id keyed (not generation-scoped) because ack state follows the alarm's - // stable identity across generations — Modified alarms keep their ack audit trail. - e.ToTable("ScriptedAlarmState", t => - { - t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1"); - }); - e.HasKey(x => x.ScriptedAlarmId); - e.Property(x => x.ScriptedAlarmId).HasMaxLength(64); - e.Property(x => x.EnabledState).HasMaxLength(16); - e.Property(x => x.AckedState).HasMaxLength(16); - e.Property(x => x.ConfirmedState).HasMaxLength(16); - e.Property(x => x.ShelvingState).HasMaxLength(16); - e.Property(x => x.ShelvingExpiresUtc).HasColumnType("datetime2(3)"); - e.Property(x => x.LastAckUser).HasMaxLength(128); - e.Property(x => x.LastAckComment).HasMaxLength(1024); - e.Property(x => x.LastAckUtc).HasColumnType("datetime2(3)"); - e.Property(x => x.LastConfirmUser).HasMaxLength(128); - e.Property(x => x.LastConfirmComment).HasMaxLength(1024); - e.Property(x => x.LastConfirmUtc).HasColumnType("datetime2(3)"); - e.Property(x => x.CommentsJson).HasColumnType("nvarchar(max)"); - e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); - }); - } - private static void ConfigureDeployment(ModelBuilder modelBuilder) { modelBuilder.Entity(e => diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 0c310cc0..bc48abb2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -113,9 +113,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// Scripted-alarm condition-state store handed to the ScriptedAlarm engine, or /// when none was wired (per-cluster mesh Phase 4). On a driver-role node /// this is the replicated LocalDb store (LocalDbAlarmConditionStateStore) so condition - /// state survives without central SQL; when null the actor falls back to an - /// over (legacy admin/Direct + - /// test harnesses), or — when there is no ConfigDb either — skips spawning the alarm host. + /// state survives without central SQL; when null the actor skips spawning the alarm host (the + /// ConfigDb-backed EF fallback was retired in Phase 4 Task 9). /// private readonly IAlarmStateStore? _alarmStateStore; @@ -135,7 +134,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly IVirtualTagEvaluator _virtualTagEvaluator; private readonly IHistoryWriter _historyWriter; private readonly IActorRef? _virtualTagHostOverride; - private readonly ILoggerFactory _loggerFactory; private readonly ScriptRootLogger? _scriptRootLogger; private readonly IActorRef? _scriptedAlarmHostOverride; private readonly ILoggingAdapter _log = Context.GetLogger(); @@ -383,9 +381,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// VirtualTag host instead of spawning a real child, so tests /// can intercept the message. Null in /// production (the real host is spawned). - /// Optional logger factory used to create the - /// 's logger when spawning the ScriptedAlarm host; - /// defaults to when not provided. + /// Retained for constructor-signature stability; unused since Phase 4 + /// Task 9 retired the EfAlarmConditionStateStore fallback that consumed it. Defaults to null. /// Optional root script logger required to spawn the ScriptedAlarm /// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left /// unspawned — the graceful dev/None-deployment path. @@ -398,9 +395,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// (pass-through) when not supplied. /// Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a /// driver-role node this is the replicated LocalDb store, so condition state persists with no - /// ConfigDb; null falls back to an over - /// (legacy admin/Direct + test harnesses), or skips the alarm host when - /// there is no ConfigDb either. Defaults to null. + /// ConfigDb; null skips spawning the alarm host (the ConfigDb-backed EF fallback was retired in + /// Phase 4 Task 9). Defaults to null. /// The Akka.NET used to spawn this actor. public static Props Props( IDbContextFactory? dbFactory, @@ -454,8 +450,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// for historized VirtualTag results; defaults to . /// Test seam: when supplied, used as the VirtualTag host /// instead of spawning a real child. - /// Optional logger factory used to create the - /// 's logger; defaults to . + /// Retained for constructor-signature stability; unused since Phase 4 + /// Task 9 retired the EfAlarmConditionStateStore fallback that consumed it. Defaults to null. /// Optional root script logger required to spawn the ScriptedAlarm /// host; when null the host is left unspawned. /// Test seam: when supplied, used as the ScriptedAlarm host @@ -471,9 +467,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// last-known-good configuration while central SQL is unreachable. Null on admin-only nodes and in /// tests that do not exercise the cache — caching is then simply skipped. /// Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a - /// driver-role node this is the replicated LocalDb store; null falls back to an - /// over , or skips the alarm - /// host when there is no ConfigDb either. + /// driver-role node this is the replicated LocalDb store; null skips spawning the alarm host (the + /// ConfigDb-backed EF fallback was retired in Phase 4 Task 9). public DriverHostActor( IDbContextFactory? dbFactory, CommonsNodeId localNode, @@ -517,7 +512,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance; _historyWriter = historyWriter ?? NullHistoryWriter.Instance; _virtualTagHostOverride = virtualTagHostOverride; - _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; _scriptRootLogger = scriptRootLogger; _scriptedAlarmHostOverride = scriptedAlarmHostOverride; @@ -597,9 +591,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// (the engine + its script logging hang off it); when either /// is missing (legacy ControlPlane test harnesses, dev/None deployments) the host is left /// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh - /// + an ; - /// the host (spawned as a child) owns + disposes the engine in its PostStop, so it stops with - /// the driver host. + /// + the wired (the + /// replicated LocalDbAlarmConditionStateStore on a driver-role node); when no store was + /// wired the host is skipped. The host (spawned as a child) owns + disposes the engine in its + /// PostStop, so it stops with the driver host. /// private void SpawnScriptedAlarmHost() { @@ -617,30 +612,20 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } - // Per-cluster mesh Phase 4: prefer the wired condition-state store (the replicated LocalDb store - // on a driver-role node — it persists with no ConfigDb). Only when no store was wired do we fall - // back to the ConfigDb-backed EF store, and only if there IS a ConfigDb (legacy admin/Direct + - // test harnesses). A driver-only node has neither — nowhere to persist condition state — so it - // skips the alarm host outright rather than constructing an EF store over a null factory that - // would NRE the moment the engine touched it (mirrors the _opcUaPublishActor-null skip above). - IAlarmStateStore store; - if (_alarmStateStore is not null) - { - store = _alarmStateStore; - } - else if (_dbFactory is not null) - { - store = new EfAlarmConditionStateStore( - _dbFactory, _loggerFactory.CreateLogger()); - } - else + // Per-cluster mesh Phase 4: condition state lives in the wired store — the replicated LocalDb + // store on a driver-role node, which persists with no ConfigDb. The ConfigDb-backed EF fallback + // was retired (Task 9); when no store was wired there is nowhere to persist condition state, so + // we skip the alarm host outright rather than run the engine against a store it can never save to + // (mirrors the _opcUaPublishActor-null skip above). + if (_alarmStateStore is null) { _log.Debug( - "DriverHost {Node}: skipping ScriptedAlarm host spawn (no condition-state store and no ConfigDb)", + "DriverHost {Node}: skipping ScriptedAlarm host spawn (no condition-state store)", _localNode); return; } + var store = _alarmStateStore; var upstream = new DependencyMuxTagUpstreamSource(); var engine = new ScriptedAlarmEngine( upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs deleted file mode 100644 index 9308cbe6..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System.Collections.Immutable; -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; - -/// -/// Production-side backed by the -/// table in the central config DB. This store maps the -/// full Part 9 — Enabled / Acked / Confirmed / Shelving -/// + the ack/confirm audit trail + operator comments. -/// -/// -/// -/// ActiveState is NOT persisted — the entity has no Active column. On -/// it is restored as ; -/// the engine re-derives it from the live predicate on startup. -/// -/// -/// LastTransitionUtc ↔ UpdatedAtUtc: the table has no dedicated transition -/// column, so LastTransitionUtc is written into the row-write -/// on save and read back from it on load. -/// -/// -/// LastActiveUtc / LastClearedUtc are transient — they have no columns and -/// default to null on load (they re-derive from the predicate alongside Active). -/// -/// -/// serializes to/from -/// via System.Text.Json. An empty list -/// round-trips as "[]" (matching the entity default). -/// -/// -public sealed class EfAlarmConditionStateStore : IAlarmStateStore -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - private readonly IDbContextFactory _dbFactory; - private readonly ILogger _logger; - - /// Initializes a new instance of the . - /// The factory for creating config database contexts. - /// The logger instance. - public EfAlarmConditionStateStore( - IDbContextFactory dbFactory, - ILogger logger) - { - _dbFactory = dbFactory; - _logger = logger; - } - - /// - public async Task LoadAsync(string alarmId, CancellationToken ct) - { - using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var row = await db.ScriptedAlarmStates.AsNoTracking() - .FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct) - .ConfigureAwait(false); - return row is null ? null : MapToState(row); - } - - /// - public async Task> LoadAllAsync(CancellationToken ct) - { - using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var rows = await db.ScriptedAlarmStates.AsNoTracking() - .ToListAsync(ct) - .ConfigureAwait(false); - return rows.Select(MapToState).ToArray(); - } - - /// - public async Task SaveAsync(AlarmConditionState state, CancellationToken ct) - { - using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var row = await db.ScriptedAlarmStates - .FirstOrDefaultAsync(r => r.ScriptedAlarmId == state.AlarmId, ct) - .ConfigureAwait(false); - - if (row is null) - { - // Required members are set here to satisfy the compiler; ApplyState is the single - // source of truth for all columns and immediately overwrites these values below. - row = new ScriptedAlarmState - { - ScriptedAlarmId = state.AlarmId, - EnabledState = MapEnabledToColumn(state.Enabled), - AckedState = MapAckedToColumn(state.Acked), - ConfirmedState = MapConfirmedToColumn(state.Confirmed), - ShelvingState = MapShelvingToColumn(state.Shelving.Kind), - }; - ApplyState(row, state); - db.ScriptedAlarmStates.Add(row); - } - else - { - ApplyState(row, state); - } - - try - { - await db.SaveChangesAsync(ct).ConfigureAwait(false); - } - catch (DbUpdateConcurrencyException ex) - { - // Two writers racing to save the same alarm is benign — last writer wins on - // UpdatedAtUtc and the next transition writes again. Log + drop so a race never - // crashes the engine. - _logger.LogDebug(ex, - "EfAlarmConditionStateStore: concurrency conflict for {AlarmId}; dropping save", - state.AlarmId); - } - } - - /// - public async Task RemoveAsync(string alarmId, CancellationToken ct) - { - using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var row = await db.ScriptedAlarmStates - .FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct) - .ConfigureAwait(false); - if (row is null) return; - - db.ScriptedAlarmStates.Remove(row); - await db.SaveChangesAsync(ct).ConfigureAwait(false); - } - - private static void ApplyState(ScriptedAlarmState row, AlarmConditionState state) - { - row.EnabledState = MapEnabledToColumn(state.Enabled); - row.AckedState = MapAckedToColumn(state.Acked); - row.ConfirmedState = MapConfirmedToColumn(state.Confirmed); - row.ShelvingState = MapShelvingToColumn(state.Shelving.Kind); - row.ShelvingExpiresUtc = state.Shelving.UnshelveAtUtc; - row.LastAckUser = state.LastAckUser; - row.LastAckComment = state.LastAckComment; - row.LastAckUtc = state.LastAckUtc; - row.LastConfirmUser = state.LastConfirmUser; - row.LastConfirmComment = state.LastConfirmComment; - row.LastConfirmUtc = state.LastConfirmUtc; - row.CommentsJson = SerializeComments(state.Comments); - // No dedicated transition column — persist LastTransitionUtc into UpdatedAtUtc. - row.UpdatedAtUtc = state.LastTransitionUtc; - } - - private static AlarmConditionState MapToState(ScriptedAlarmState row) => new( - AlarmId: row.ScriptedAlarmId, - Enabled: string.Equals(row.EnabledState, "Disabled", StringComparison.Ordinal) - ? AlarmEnabledState.Disabled - : AlarmEnabledState.Enabled, // unknown string → Enabled (safe default) - // Active is not persisted — the engine re-derives it from the predicate at startup. - Active: AlarmActiveState.Inactive, - Acked: string.Equals(row.AckedState, "Acknowledged", StringComparison.Ordinal) - ? AlarmAckedState.Acknowledged - : AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default) - Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal) - ? AlarmConfirmedState.Confirmed - : AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default) - Shelving: new ShelvingState(MapShelvingFromColumn(row.ShelvingState), row.ShelvingExpiresUtc), - // No transition column — UpdatedAtUtc carries the last transition timestamp. - LastTransitionUtc: row.UpdatedAtUtc, - // LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load. - LastActiveUtc: null, - LastClearedUtc: null, - LastAckUtc: row.LastAckUtc, - LastAckUser: row.LastAckUser, - LastAckComment: row.LastAckComment, - LastConfirmUtc: row.LastConfirmUtc, - LastConfirmUser: row.LastConfirmUser, - LastConfirmComment: row.LastConfirmComment, - Comments: DeserializeComments(row.CommentsJson)); - - private static string MapEnabledToColumn(AlarmEnabledState enabled) - => enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled"; - - private static string MapAckedToColumn(AlarmAckedState acked) - => acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged"; - - private static string MapConfirmedToColumn(AlarmConfirmedState confirmed) - => confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed"; - - private static string MapShelvingToColumn(ShelvingKind kind) => kind switch - { - ShelvingKind.OneShot => "OneShotShelved", - ShelvingKind.Timed => "TimedShelved", - _ => "Unshelved", - }; - - private static ShelvingKind MapShelvingFromColumn(string column) => column switch - { - "OneShotShelved" => ShelvingKind.OneShot, - "TimedShelved" => ShelvingKind.Timed, - _ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default) - }; - - private static string SerializeComments(ImmutableList comments) - { - if (comments.IsEmpty) return "[]"; - var dtos = comments.Select(c => new CommentDto - { - // AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip; - // the engine always creates AlarmComment instances with Utc kind. - TimestampUtc = c.TimestampUtc, - User = c.User, - Kind = c.Kind, - Text = c.Text, - }); - return JsonSerializer.Serialize(dtos, JsonOptions); - } - - private static ImmutableList DeserializeComments(string? json) - { - if (string.IsNullOrWhiteSpace(json)) return ImmutableList.Empty; - var dtos = JsonSerializer.Deserialize>(json, JsonOptions); - if (dtos is null || dtos.Count == 0) return ImmutableList.Empty; - return dtos - .Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty)) - .ToImmutableList(); - } - - /// Stable on-disk shape for a persisted in CommentsJson. - private sealed class CommentDto - { - /// When the comment was recorded (UTC). - public DateTime TimestampUtc { get; set; } - - /// Identity of the actor that wrote the comment. - public string? User { get; set; } - - /// Human-readable classification of the comment (Acknowledge, Confirm, …). - public string? Kind { get; set; } - - /// Operator-supplied or engine-generated comment text. - public string? Text { get; set; } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 934f8ef1..477a6344 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -286,8 +286,7 @@ public static class ServiceCollectionExtensions // Per-cluster mesh Phase 4: scripted-alarm condition-state store. Registered by the Host's // AddOtOpcUaLocalDb on driver-role nodes (the replicated LocalDb store), so condition state // persists with no ConfigDb; null elsewhere (admin-only graphs, test harnesses) → the actor - // falls back to an EfAlarmConditionStateStore over dbFactory, or skips the alarm host when - // there is no ConfigDb either. + // skips spawning the alarm host (the ConfigDb-backed EF fallback was retired in Phase 4 Task 9). var alarmStateStore = resolver.GetService(); // Per-cluster mesh Phase 3: ConfigSource:Mode selects where this driver reads config. // FetchAndCache pulls the artifact from central over gRPC and reads only the LocalDb cache; @@ -473,7 +472,8 @@ public static class ServiceCollectionExtensions // gated on hasAdmin and driver-only nodes are forced to FetchAndCache. DriverHostActor // guards every ConfigDb-touching path on it (UpsertNodeDeploymentState no-ops; central // persists acks from the ApplyAck) and serves scripted-alarm state from the LocalDb - // alarmStateStore instead of an EfAlarmConditionStateStore. + // alarmStateStore (there is no ConfigDb-backed EF fallback; the host skips the alarm + // host when no store was wired). DriverHostActor.Props(dbFactory, roleInfo.LocalNode, ackRouter: useClusterClient ? nodeComm : null, driverFactory: driverFactory, localRoles: roleInfo.LocalRoles, diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs index 63c8b615..19c8f436 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs @@ -36,8 +36,10 @@ public sealed class SchemaComplianceTests "DriverHostStatus", "DriverInstanceResilienceStatus", "LdapGroupRoleMapping", - // v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables - "Script", "ScriptedAlarm", "ScriptedAlarmState", + // v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables. + // ScriptedAlarmState was dropped in per-cluster mesh Phase 4 (Task 9) — scripted-alarm + // condition state moved to the replicated LocalDb store. + "Script", "ScriptedAlarm", // v2 deploy-model tables (Phase 1 of Akka + fused-hosting alignment) "Deployment", "NodeDeploymentState", "ConfigEdit", "DataProtectionKeys", }; diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ScriptingEntitiesTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ScriptingEntitiesTests.cs index f18cb47a..2f34d40d 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ScriptingEntitiesTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ScriptingEntitiesTests.cs @@ -10,9 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; /// /// Verifies the Phase 7 Stream E entities (, , -/// , ) register correctly in -/// the EF model, map to the expected tables/columns/indexes, and carry the check constraints -/// the plan decisions call for. Introspection only — no SQL Server required. +/// ) register correctly in the EF model, map to the expected +/// tables/columns/indexes, and carry the check constraints the plan decisions call for. +/// Introspection only — no SQL Server required. /// [Trait("Category", "Unit")] public sealed class ScriptingEntitiesTests @@ -127,49 +127,9 @@ public sealed class ScriptingEntitiesTests alarm.Enabled.ShouldBeTrue(); } - /// Verifies that ScriptedAlarmState is keyed on ScriptedAlarmId and not generation-scoped. - [Fact] - public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped() - { - using var ctx = BuildCtx(); - var entity = ctx.Model.FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull(); - entity.GetTableName().ShouldBe("ScriptedAlarmState"); - - var pk = entity.FindPrimaryKey().ShouldNotBeNull(); - pk.Properties.Count.ShouldBe(1); - pk.Properties[0].Name.ShouldBe(nameof(ScriptedAlarmState.ScriptedAlarmId)); - - // State is NOT generation-scoped — GenerationId column should not exist per plan decision #14. - entity.FindProperty("GenerationId").ShouldBeNull( - "ack state follows alarm identity across generations"); - } - - /// Verifies that ScriptedAlarmState default values match Part 9 initial states. - [Fact] - public void ScriptedAlarmState_default_state_values_match_Part9_initial_states() - { - var state = new ScriptedAlarmState - { - ScriptedAlarmId = "a1", - EnabledState = "Enabled", - AckedState = "Unacknowledged", - ConfirmedState = "Unconfirmed", - ShelvingState = "Unshelved", - }; - state.CommentsJson.ShouldBe("[]"); - state.LastAckUser.ShouldBeNull(); - state.LastAckUtc.ShouldBeNull(); - } - - /// Verifies that ScriptedAlarmState has a JSON check constraint on CommentsJson. - [Fact] - public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson() - { - using var ctx = BuildCtx(); - var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull(); - var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray(); - checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson"); - } + // The ConfigDb-backed ScriptedAlarmState entity + table were retired in per-cluster mesh Phase 4 + // (Task 9): scripted-alarm condition state lives in the replicated LocalDb store. The entity-model + // tests that covered it are gone with it. /// Verifies that all new Phase 7 entities are exposed via DbSet properties. [Fact] @@ -179,7 +139,6 @@ public sealed class ScriptingEntitiesTests ctx.Scripts.ShouldNotBeNull(); ctx.VirtualTags.ShouldNotBeNull(); ctx.ScriptedAlarms.ShouldNotBeNull(); - ctx.ScriptedAlarmStates.ShouldNotBeNull(); } /// Verifies that the squashed V3Initial migration exists in the assembly. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs index dc5177ab..dc3504f9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs @@ -177,11 +177,13 @@ public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase } /// - /// Regression — a DB-backed actor (non-null dbFactory, no alarm store passed) still spawns - /// the host on the EF-store fallback path, exactly as before Phase 4. + /// Phase 4 Task 9 — the ConfigDb-backed EF fallback is retired. A DB-backed actor (non-null + /// dbFactory) with no alarm store now SKIPS the ScriptedAlarm host: the wired + /// IAlarmStateStore is the only source of condition-state persistence, and there is no + /// longer an EF store constructed over dbFactory. /// [Fact] - public void DbBackedNode_WithNoStore_StillSpawnsScriptedAlarmHost_OnEfFallback() + public void DbBackedNode_WithNoStore_SkipsScriptedAlarmHost() { var publish = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( @@ -192,7 +194,7 @@ public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase alarmStateStore: null)); AwaitStarted(actor); - ResolveChild(actor, "scripted-alarm-host").ShouldNotBeNull(); + ResolveChild(actor, "scripted-alarm-host").ShouldBeNull(); } /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs deleted file mode 100644 index 00e89ef0..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.Extensions.Logging.Abstractions; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; -using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; - -/// -/// Round-trip + upsert + lifecycle coverage for , -/// the EF-backed over the ScriptedAlarmState table. -/// -public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase -{ - private static EfAlarmConditionStateStore NewStore(out Microsoft.EntityFrameworkCore.IDbContextFactory db) - { - db = NewInMemoryDbFactory(); - return new EfAlarmConditionStateStore(db, NullLogger.Instance); - } - - private static AlarmConditionState Sample( - string alarmId, - AlarmActiveState active = AlarmActiveState.Inactive, - ShelvingState? shelving = null, - ImmutableList? comments = null) - { - var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc); - return new AlarmConditionState( - AlarmId: alarmId, - Enabled: AlarmEnabledState.Disabled, - Active: active, - Acked: AlarmAckedState.Unacknowledged, - Confirmed: AlarmConfirmedState.Unconfirmed, - Shelving: shelving ?? new ShelvingState(ShelvingKind.Timed, t.AddMinutes(30)), - LastTransitionUtc: t, - LastActiveUtc: t.AddMinutes(-5), - LastClearedUtc: t.AddMinutes(-1), - LastAckUtc: t.AddMinutes(-2), - LastAckUser: "jane", - LastAckComment: "ack-comment", - LastConfirmUtc: t.AddMinutes(-3), - LastConfirmUser: "bob", - LastConfirmComment: "confirm-comment", - Comments: comments ?? ImmutableList.Empty); - } - - /// Save then load round-trips every persisted operator-state + audit field. - [Fact] - public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields() - { - var store = NewStore(out _); - var state = Sample("alarm-1"); - - await store.SaveAsync(state, CancellationToken.None); - var loaded = await store.LoadAsync("alarm-1", CancellationToken.None); - - loaded.ShouldNotBeNull(); - loaded.AlarmId.ShouldBe("alarm-1"); - loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled); - loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged); - loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); - loaded.Shelving.Kind.ShouldBe(ShelvingKind.Timed); - loaded.Shelving.UnshelveAtUtc.ShouldBe(state.Shelving.UnshelveAtUtc); - loaded.LastTransitionUtc.ShouldBe(state.LastTransitionUtc); - loaded.LastAckUtc.ShouldBe(state.LastAckUtc); - loaded.LastAckUser.ShouldBe("jane"); - loaded.LastAckComment.ShouldBe("ack-comment"); - loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc); - loaded.LastConfirmUser.ShouldBe("bob"); - loaded.LastConfirmComment.ShouldBe("confirm-comment"); - } - - /// Loading an id that was never saved returns null. - [Fact] - public async Task LoadAsync_for_unknown_id_returns_null() - { - var store = NewStore(out _); - - var loaded = await store.LoadAsync("never-saved", CancellationToken.None); - - loaded.ShouldBeNull(); - } - - /// Active is not persisted — a saved Active state loads back as Inactive. - [Fact] - public async Task Active_is_ignored_on_load_and_defaults_to_Inactive() - { - var store = NewStore(out _); - await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None); - - var loaded = await store.LoadAsync("alarm-active", CancellationToken.None); - - loaded.ShouldNotBeNull(); - loaded.Active.ShouldBe(AlarmActiveState.Inactive); - } - - /// LastActiveUtc / LastClearedUtc have no columns and default to null on load. - [Fact] - public async Task Derived_timestamps_default_to_null_on_load() - { - var store = NewStore(out _); - await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None); - - var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None); - - loaded.ShouldNotBeNull(); - loaded.LastActiveUtc.ShouldBeNull(); - loaded.LastClearedUtc.ShouldBeNull(); - } - - /// Unshelved round-trips with a null UnshelveAtUtc. - [Fact] - public async Task Unshelved_round_trips() - { - var store = NewStore(out _); - await store.SaveAsync( - Sample("alarm-unshelved", shelving: ShelvingState.Unshelved), - CancellationToken.None); - - var loaded = await store.LoadAsync("alarm-unshelved", CancellationToken.None); - - loaded.ShouldNotBeNull(); - loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); - loaded.Shelving.UnshelveAtUtc.ShouldBeNull(); - } - - /// OneShot shelving round-trips. - [Fact] - public async Task OneShot_shelving_round_trips() - { - var store = NewStore(out _); - await store.SaveAsync( - Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)), - CancellationToken.None); - - var loaded = await store.LoadAsync("alarm-oneshot", CancellationToken.None); - - loaded.ShouldNotBeNull(); - loaded.Shelving.Kind.ShouldBe(ShelvingKind.OneShot); - loaded.Shelving.UnshelveAtUtc.ShouldBeNull(); - } - - /// The append-only comment trail round-trips all four fields per comment. - [Fact] - public async Task Comments_round_trip_all_fields() - { - var store = NewStore(out _); - var c1 = new AlarmComment( - new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump"); - var c2 = new AlarmComment( - new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc), "bob", "Confirm", "all clear"); - var comments = ImmutableList.Create(c1, c2); - - await store.SaveAsync(Sample("alarm-comments", comments: comments), CancellationToken.None); - var loaded = await store.LoadAsync("alarm-comments", CancellationToken.None); - - loaded.ShouldNotBeNull(); - loaded.Comments.Count.ShouldBe(2); - loaded.Comments[0].TimestampUtc.ShouldBe(c1.TimestampUtc); - loaded.Comments[0].User.ShouldBe("jane"); - loaded.Comments[0].Kind.ShouldBe("Acknowledge"); - loaded.Comments[0].Text.ShouldBe("checking pump"); - loaded.Comments[1].TimestampUtc.ShouldBe(c2.TimestampUtc); - loaded.Comments[1].User.ShouldBe("bob"); - loaded.Comments[1].Kind.ShouldBe("Confirm"); - loaded.Comments[1].Text.ShouldBe("all clear"); - } - - /// An empty comment list round-trips as an empty list. - [Fact] - public async Task Empty_comments_round_trip() - { - var store = NewStore(out _); - await store.SaveAsync( - Sample("alarm-empty-comments", comments: ImmutableList.Empty), - CancellationToken.None); - - var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None); - - loaded.ShouldNotBeNull(); - loaded.Comments.ShouldBeEmpty(); - } - - /// Saving the same id twice is an upsert — one row, latest values win. - [Fact] - public async Task SaveAsync_twice_upserts_latest() - { - var store = NewStore(out var db); - var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled }; - var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled }; - - await store.SaveAsync(first, CancellationToken.None); - await store.SaveAsync(second, CancellationToken.None); - - using (var ctx = db.CreateDbContext()) - { - ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-upsert").ShouldBe(1); - } - - var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None); - loaded.ShouldNotBeNull(); - loaded.LastAckUser.ShouldBe("second-user"); - loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled); - } - - /// LoadAllAsync returns every saved state. - [Fact] - public async Task LoadAllAsync_returns_all_saved_states() - { - var store = NewStore(out _); - await store.SaveAsync(Sample("a-1"), CancellationToken.None); - await store.SaveAsync(Sample("a-2"), CancellationToken.None); - await store.SaveAsync(Sample("a-3"), CancellationToken.None); - - var all = await store.LoadAllAsync(CancellationToken.None); - - all.Select(s => s.AlarmId).OrderBy(x => x).ShouldBe(new[] { "a-1", "a-2", "a-3" }); - } - - /// RemoveAsync deletes a row so a subsequent load returns null; others survive. - [Fact] - public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null() - { - var store = NewStore(out _); - await store.SaveAsync(Sample("keep"), CancellationToken.None); - await store.SaveAsync(Sample("drop"), CancellationToken.None); - - await store.RemoveAsync("drop", CancellationToken.None); - - (await store.LoadAsync("drop", CancellationToken.None)).ShouldBeNull(); - (await store.LoadAsync("keep", CancellationToken.None)).ShouldNotBeNull(); - } - - /// Removing an unknown id is a no-op (does not throw). - [Fact] - public async Task RemoveAsync_for_unknown_id_is_noop() - { - var store = NewStore(out _); - - await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None)); - } -}