From 1cea01d924f217787e5600d660578ffecb67ef70 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 19 Aug 2026 16:20:18 -0400 Subject: [PATCH] fix(notifications): list sort index + CAS operator Retry/Discard (P5-3, P5-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5-3: add IX_Notifications_Created_NotificationId (CreatedAt DESC, NotificationId DESC) — the Central UI Notification Outbox list page sorts on exactly that pair, but no existing index leads unconditionally on CreatedAt, so an unfiltered/lightly filtered page fell back to a full clustered-index scan + sort on every COUNT + page request. Mirrors the Medium wave's IX_SiteCalls_Created_TrackedOperationId migration + SQL twin pattern faithfully, including the handwritten docs/plans/sql script. P5-4: operator Retry/Discard was read-check-write keyed on NotificationId only — a detached PipeTo task interleaving with the dispatcher's own write (or a second concurrent operator action) could land between the read and the write and get silently clobbered by the unconditional UpdateAsync, stamping a Delivered row Discarded with stale fields. Adds INotificationOutboxRepository.TryUpdateFromParkedAsync, a single-statement compare-and-swap (WHERE Status == Parked) mirroring SecuredWriteRepository.TryMarkApprovedAsync's CAS shape. NotificationOutboxActor's RetryAsync/DiscardAsync now use the CAS write and, on a lost race, re-read to report "notification already progressed to " instead of silently overwriting whatever is there now. Proof of failure: reverting only the actor's CAS usage while keeping the repository addition reproduces the silent-overwrite bug — 7 tests fail, including two new deterministic (non-sleep) race-proof tests that mock the exact GetByIdAsync -> lost-CAS -> re-read sequence the finding describes. P5-3 is perf-only; verified via migration/SQL-twin parity instead (no runtime proof-of-failure is meaningful for an index). --- .../sql/AddNotificationsListSortIndex.sql | 51 + .../INotificationOutboxRepository.cs | 38 + .../NotificationOutboxConfiguration.cs | 18 + ..._AddNotificationsListSortIndex.Designer.cs | 2131 +++++++++++++++++ ...819201210_AddNotificationsListSortIndex.cs | 46 + .../ScadaBridgeDbContextModelSnapshot.cs | 4 + .../NotificationOutboxRepository.cs | 42 + .../NotificationOutboxActor.cs | 42 +- .../RepositoryCoverageTests.cs | 148 ++ .../NotificationOutboxActorQueryTests.cs | 96 +- ...tificationOutboxActorRetryEmissionTests.cs | 2 +- .../OutboxRepositorySubstitute.cs | 21 +- 12 files changed, 2607 insertions(+), 32 deletions(-) create mode 100644 docs/plans/sql/AddNotificationsListSortIndex.sql create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260819201210_AddNotificationsListSortIndex.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260819201210_AddNotificationsListSortIndex.cs diff --git a/docs/plans/sql/AddNotificationsListSortIndex.sql b/docs/plans/sql/AddNotificationsListSortIndex.sql new file mode 100644 index 00000000..5b8d8806 --- /dev/null +++ b/docs/plans/sql/AddNotificationsListSortIndex.sql @@ -0,0 +1,51 @@ +-- arch-review 2026-08-19 P5-3 (register row-35 class): the Notifications list +-- page's sort key gets its own index. +-- +-- NotificationOutboxRepository.QueryAsync orders (CreatedAt DESC, NotificationId +-- DESC) (NotificationId breaks CreatedAt ties for deterministic OFFSET paging). +-- None of the existing Notifications indexes lead unconditionally on CreatedAt: +-- - IX_Notifications_Status_NextAttemptAt requires an equality on Status first. +-- - IX_Notifications_SourceSiteId_CreatedAt requires an equality on SourceSiteId first. +-- - IX_Notifications_Delivered is filtered to Status = 'Delivered' only. +-- So an unfiltered (or lightly filtered) page fell back to a full clustered-index +-- scan + sort. IX_Notifications_Created_NotificationId (CreatedAt DESC, +-- NotificationId DESC) makes a forward scan of the index deliver rows in the +-- requested order directly, so a page reads a Top-N off the index instead of +-- sorting the whole table on every COUNT + page request. +-- +-- Notifications is small enough today that a plain (offline) index build is +-- fine. On an Enterprise edition instance where the table has grown large and +-- the Central UI must stay responsive, add WITH (ONLINE = ON) to the CREATE +-- INDEX below. +-- +-- Run with QUOTED_IDENTIFIER ON. Several tables in this database carry filtered +-- indexes and persisted computed columns, and SQL Server refuses DDL/DML against +-- them with error 1934 when the session has QUOTED_IDENTIFIER OFF -- which is +-- exactly what `sqlcmd` gives you by default (pass -I, or keep this header). The +-- SETs sit in their own batch so they are in force when the statements after the +-- GO parse. +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260819201210_AddNotificationsListSortIndex' +) +BEGIN + EXEC(N'CREATE INDEX [IX_Notifications_Created_NotificationId] + ON [Notifications] ([CreatedAt] DESC, [NotificationId] DESC)'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260819201210_AddNotificationsListSortIndex' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260819201210_AddNotificationsListSortIndex', N'10.0.7'); +END; + +COMMIT; +GO diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs index b0d40b22..4b16636e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications; namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; @@ -83,6 +84,43 @@ public interface INotificationOutboxRepository /// Task UpdateAsync(Notification n, CancellationToken cancellationToken = default); + /// + /// Compare-and-swap variant of for the operator + /// Retry/Discard one-shots: persists 's delivery-state + /// columns (same narrow column set as ) ONLY if the + /// row's current Status in the database is still + /// . Commits internally — this call + /// is its own transaction. + /// + /// + /// Why this exists (arch-review 2026-08-19 P5-4). The operator retry/ + /// discard handlers are read-check-write across two round trips: + /// reads the row, the handler checks its + /// in-memory Status, then a write persists the transition. A detached + /// PipeTo task can interleave with the dispatcher's own write (or a + /// second concurrent operator action) between those two round trips, so the + /// in-memory check is a fast-path rejection, not a correctness guard — a + /// blind in that window can stamp a row that has + /// already moved on (e.g. the dispatcher delivered it) with the operator's + /// stale fields, silently overwriting a Delivered row as + /// Discarded. The WHERE Status = Parked predicate below makes + /// the Parked->(Pending|Discarded) transition atomic at the row + /// level, mirroring SecuredWriteRepository.TryMarkApprovedAsync's + /// single-statement compare-and-swap. A row that has already progressed + /// (dispatcher won the race, or a second operator action won it) and a row + /// that has vanished (retention purge) both report zero rows affected; + /// callers cannot tell those apart from the return value alone and must + /// re-read to report which honestly. + /// + /// The notification whose delivery state should be persisted, if still Parked. + /// Cancellation token. + /// + /// when a row with that NotificationId was + /// found with Status == Parked and its delivery state was persisted; + /// otherwise (not found, or no longer Parked). + /// + Task TryUpdateFromParkedAsync(Notification n, CancellationToken cancellationToken = default); + /// /// Returns a page of notifications matching , ordered by /// CreatedAt descending, together with the total matching count. diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs index b346fbed..09bb4bea 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -83,5 +83,23 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration n.DeliveredAt) .HasDatabaseName("IX_Notifications_Delivered") .HasFilter("[Status] = 'Delivered'"); + + // Created_NotificationId backs the Central UI Notification Outbox list + // page's sort key — NotificationOutboxRepository.QueryAsync orders + // (CreatedAt DESC, NotificationId DESC) (NotificationId breaks CreatedAt + // ties for deterministic OFFSET paging). None of the indexes above lead + // unconditionally on CreatedAt: Status_NextAttemptAt requires an equality + // on Status first, SourceSiteId_CreatedAt requires an equality on + // SourceSiteId first, and IX_Notifications_Delivered is filtered to + // Status='Delivered' only. So an unfiltered (or lightly filtered) page + // fell back to a full clustered-index scan + sort on every COUNT + page + // request (arch-review 2026-08-19 P5-3, register row-35 class). With both + // key columns descending, a forward scan of this index already delivers + // rows in the requested order. Exact mirror of + // SiteCallEntityTypeConfiguration's IX_SiteCalls_Created_TrackedOperationId + // (2026-08-19, arch-review P5-2). + builder.HasIndex(n => new { n.CreatedAt, n.NotificationId }) + .IsDescending(true, true) + .HasDatabaseName("IX_Notifications_Created_NotificationId"); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260819201210_AddNotificationsListSortIndex.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260819201210_AddNotificationsListSortIndex.Designer.cs new file mode 100644 index 00000000..a59510cc --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260819201210_AddNotificationsListSortIndex.Designer.cs @@ -0,0 +1,2131 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260819201210_AddNotificationsListSortIndex")] + partial class AddNotificationsListSortIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_SiteCalls_NonTerminal") + .HasFilter("[TerminalAtUtc] IS NULL"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAtUtc"), new[] { "SourceSite", "SourceNode", "Status" }); + + b.HasIndex("TerminalAtUtc") + .HasDatabaseName("IX_SiteCalls_Terminal") + .HasFilter("[TerminalAtUtc] IS NOT NULL"); + + b.HasIndex("CreatedAtUtc", "TrackedOperationId") + .IsDescending() + .HasDatabaseName("IX_SiteCalls_Created_TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.HasIndex("DeployedAt", "Id") + .IsDescending() + .HasDatabaseName("IX_DeploymentRecords_DeployedAt_Id"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("InstanceId"); + + b.ToTable("PendingDeployments", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiRollupHourly", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HourStartUtc") + .HasColumnType("datetime2"); + + b.Property("MaxValue") + .HasColumnType("float"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("MinValue") + .HasColumnType("float"); + + b.Property("SampleCount") + .HasColumnType("int"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("HourStartUtc") + .HasDatabaseName("IX_KpiRollupHourly_Hour"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "HourStartUtc") + .IsUnique() + .HasDatabaseName("IX_KpiRollupHourly_Series"); + + b.ToTable("KpiRollupHourly", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CapturedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Captured"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Series"); + + b.ToTable("KpiSample", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("IX_Notifications_Delivered") + .HasFilter("[Status] = 'Delivered'"); + + b.HasIndex("CreatedAt", "NotificationId") + .IsDescending() + .HasDatabaseName("IX_Notifications_Created_NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmsConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountSid") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApiBaseUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("AuthToken") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("FromNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("MessagingServiceSid") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.ToTable("SmsConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("OAuth2Authority") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OAuth2Scope") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Transport") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas.SharedSchema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SchemaJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedSchemas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites.PendingSecuredWrite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DecidedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutionError") + .HasMaxLength(2048) + .IsUnicode(false) + .HasColumnType("varchar(2048)"); + + b.Property("OperatorComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("OperatorUser") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("SiteId") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("datetime2"); + + b.Property("TagPath") + .IsRequired() + .HasMaxLength(512) + .IsUnicode(false) + .HasColumnType("varchar(512)"); + + b.Property("ValueJson") + .IsRequired() + .HasMaxLength(4000) + .IsUnicode(false) + .HasColumnType("varchar(4000)"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("VerifierComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("VerifierUser") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("SiteId") + .HasDatabaseName("IX_PendingSecuredWrites_Site"); + + b.HasIndex("Status", "SubmittedAtUtc") + .HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted"); + + b.ToTable("PendingSecuredWrites", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }, + new + { + Id = 5, + LdapGroupName = "SCADA-Viewers", + Role = "Viewer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport.BundleUnlockAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ClientKey") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("ClientKey", "AttemptedAtUtc") + .HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted"); + + b.ToTable("BundleUnlockAttempt", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasColumnName("Category"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true); + + b.Property("IngestedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2(7)") + .HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ParentExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("OccurredAtUtc"), new[] { "Status" }); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260819201210_AddNotificationsListSortIndex.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260819201210_AddNotificationsListSortIndex.cs new file mode 100644 index 00000000..13f9c1fc --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260819201210_AddNotificationsListSortIndex.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + /// Adds IX_Notifications_Created_NotificationId (CreatedAt DESC, + /// NotificationId DESC) — arch-review 2026-08-19 P5-3, register + /// row-35 class. + /// + /// + /// + /// orders (CreatedAt DESC, NotificationId DESC) for the Central UI + /// Notification Outbox list page. None of the existing Notifications indexes + /// lead unconditionally on CreatedAt — IX_Notifications_Status_NextAttemptAt + /// requires an equality on Status first, IX_Notifications_SourceSiteId_CreatedAt + /// requires an equality on SourceSiteId first, and + /// IX_Notifications_Delivered is filtered to Status = 'Delivered' + /// only — so an unfiltered (or lightly filtered) list page fell back to a full + /// clustered-index scan + sort on every COUNT + page request. With both key + /// columns descending, a forward scan of this index already delivers rows in + /// the requested order. See docs/plans/sql/AddNotificationsListSortIndex.sql + /// for the handwritten production-parity script. + /// + public partial class AddNotificationsListSortIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Notifications_Created_NotificationId", + table: "Notifications", + columns: new[] { "CreatedAt", "NotificationId" }, + descending: new bool[0]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Notifications_Created_NotificationId", + table: "Notifications"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index a6455902..edb06ca7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -913,6 +913,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .HasDatabaseName("IX_Notifications_Delivered") .HasFilter("[Status] = 'Delivered'"); + b.HasIndex("CreatedAt", "NotificationId") + .IsDescending() + .HasDatabaseName("IX_Notifications_Created_NotificationId"); + b.HasIndex("SourceSiteId", "CreatedAt"); b.HasIndex("Status", "NextAttemptAt"); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs index 2232a72a..f005c08e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs @@ -205,6 +205,48 @@ VALUES return rowsAffected > 0; } + /// + public async Task TryUpdateFromParkedAsync(Notification n, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(n); + + // Same seven mutable delivery-state columns as UpdateAsync, but the WHERE + // clause below ALSO requires the row's current Status to still be Parked + // -- a single-statement compare-and-swap (arch-review 2026-08-19 P5-4). + // Two round trips (GetByIdAsync then this write) with a status check in + // between is not a correctness guard by itself: a detached PipeTo task + // can race the dispatcher's own write (or a second concurrent operator + // action) in that window. Mirrors SecuredWriteRepository.TryMarkApprovedAsync's + // conditional ExecuteUpdate/ExecuteSql pattern, expressed via LINQ + // ExecuteUpdateAsync (like UpdateAsync above) so this stays portable + // across the SQL Server production provider and the SQLite provider the + // repository tests run against. + var status = n.Status; + var retryCount = n.RetryCount; + var lastError = n.LastError; + var resolvedTargets = n.ResolvedTargets; + var lastAttemptAt = n.LastAttemptAt; + var nextAttemptAt = n.NextAttemptAt; + var deliveredAt = n.DeliveredAt; + + var notificationId = n.NotificationId; + + var rowsAffected = await _context.Notifications + .Where(row => row.NotificationId == notificationId && row.Status == NotificationStatus.Parked) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(row => row.Status, status) + .SetProperty(row => row.RetryCount, retryCount) + .SetProperty(row => row.LastError, lastError) + .SetProperty(row => row.ResolvedTargets, resolvedTargets) + .SetProperty(row => row.LastAttemptAt, lastAttemptAt) + .SetProperty(row => row.NextAttemptAt, nextAttemptAt) + .SetProperty(row => row.DeliveredAt, deliveredAt), + cancellationToken); + + return rowsAffected > 0; + } + /// public async Task GetByIdAsync(string notificationId, CancellationToken cancellationToken = default) { diff --git a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs index 8fbce874..f478aef3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs @@ -1109,14 +1109,25 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers notification.NextAttemptAt = null; notification.LastError = null; - // Zero rows updated means the row was purged between the read above and - // this write. Answer the operator honestly instead of reporting a - // re-queue that never happened — and emit no un-park audit row, because - // there is nothing to attribute it to. - if (!await repository.UpdateAsync(notification)) + // Compare-and-swap on Status == Parked (arch-review 2026-08-19 P5-4). The + // read above and this write are two separate round trips; a detached + // PipeTo task can race the dispatcher's own write (or a second + // concurrent operator action) in between, so the in-memory check above + // is a fast-path rejection, not the correctness guard — a blind + // UpdateAsync here could stamp a row that has already moved on (e.g. the + // dispatcher delivered it) back to Pending with stale fields. A lost CAS + // means either the row vanished (purge) or it progressed past Parked + // under us; re-read to report which honestly instead of silently + // overwriting whatever is there now, and emit no un-park audit row in + // either case, because there is nothing to attribute it to. + if (!await repository.TryUpdateFromParkedAsync(notification)) { + var current = await repository.GetByIdAsync(request.NotificationId); return new RetryNotificationResponse( - request.CorrelationId, Success: false, ErrorMessage: "notification not found"); + request.CorrelationId, Success: false, + ErrorMessage: current is null + ? "notification not found" + : $"notification already progressed to {current.Status}; retry not applied"); } // Operator re-queued a parked notification. Emit a Submitted NotifyDeliver @@ -1190,13 +1201,22 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers notification.Status = NotificationStatus.Discarded; - // Same vanished-row honesty as the retry path: a purge between the read - // and the write leaves nothing to discard, so report not-found rather - // than success (and skip the terminal audit row below). - if (!await repository.UpdateAsync(notification)) + // Same compare-and-swap on Status == Parked as the retry path (arch-review + // 2026-08-19 P5-4): a purge between the read and the write leaves nothing + // to discard, and a dispatcher/other-operator write landing in that same + // window (e.g. a delivery succeeding) must not be silently clobbered by a + // blind write of this stale in-memory copy. Re-read on a lost CAS to + // report which case it was, and skip the terminal audit row below either + // way — there is nothing to attribute a discard of a row that was never + // actually discarded. + if (!await repository.TryUpdateFromParkedAsync(notification)) { + var current = await repository.GetByIdAsync(request.NotificationId); return new DiscardNotificationResponse( - request.CorrelationId, Success: false, ErrorMessage: "notification not found"); + request.CorrelationId, Success: false, + ErrorMessage: current is null + ? "notification not found" + : $"notification already progressed to {current.Status}; discard not applied"); } // A manual discard is the OTHER code path that produces diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index 1e54ebc4..de28404a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -573,6 +573,154 @@ public class NotificationOutboxRepositoryTests : IDisposable Assert.False(await _repository.UpdateAsync(loaded)); } + [Fact] + public async Task TryUpdateFromParkedAsync_StatusIsParked_UpdatesAndReturnsTrue() + { + var id = Guid.NewGuid().ToString(); + _context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked)); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var loaded = await _repository.GetByIdAsync(id); + loaded!.Status = NotificationStatus.Pending; + loaded.RetryCount = 0; + loaded.NextAttemptAt = null; + loaded.LastError = null; + Assert.True(await _repository.TryUpdateFromParkedAsync(loaded)); + + _context.ChangeTracker.Clear(); + var reloaded = await _context.Notifications.FindAsync(id); + Assert.Equal(NotificationStatus.Pending, reloaded!.Status); + } + + /// + /// Direct CAS-miss: the row is currently Delivered (never Parked in this + /// scenario), so the conditional WHERE Status == Parked matches nothing and + /// the row is left completely untouched — arch-review 2026-08-19 P5-4. + /// + [Fact] + public async Task TryUpdateFromParkedAsync_StatusIsNotParked_ReturnsFalse_AndLeavesRowUnchanged() + { + var id = Guid.NewGuid().ToString(); + var deliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero); + _context.Notifications.Add(MakeNotification( + id, NotificationStatus.Delivered, deliveredAt: deliveredAt)); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var staleDiscard = MakeNotification(id, NotificationStatus.Discarded); + Assert.False(await _repository.TryUpdateFromParkedAsync(staleDiscard)); + + _context.ChangeTracker.Clear(); + var reloaded = await _context.Notifications.FindAsync(id); + Assert.Equal(NotificationStatus.Delivered, reloaded!.Status); + Assert.Equal(deliveredAt, reloaded.DeliveredAt); + } + + [Fact] + public async Task TryUpdateFromParkedAsync_RowPurgedBeforeWrite_ReturnsFalse() + { + var id = Guid.NewGuid().ToString(); + _context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked)); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var loaded = await _repository.GetByIdAsync(id); + Assert.NotNull(loaded); + + await _context.Notifications.Where(n => n.NotificationId == id).ExecuteDeleteAsync(); + _context.ChangeTracker.Clear(); + + loaded!.Status = NotificationStatus.Discarded; + Assert.False(await _repository.TryUpdateFromParkedAsync(loaded)); + } + + /// + /// Deterministic (non-sleep) proof of the arch-review 2026-08-19 P5-4 race: + /// the operator reads a Parked row, but before its write lands the + /// dispatcher (or a concurrent second operator action) has already moved + /// the row to Delivered. 's CAS must + /// refuse the stale write and leave the Delivered row exactly as it is. + /// The companion fact below proves the general-purpose + /// — the method the + /// operator path used PRE-FIX — has no such guard, which is the mechanism + /// the finding describes. + /// + [Fact] + public async Task TryUpdateFromParkedAsync_ConcurrentlyDelivered_ReturnsFalse_AndDoesNotOverwrite() + { + var id = Guid.NewGuid().ToString(); + _context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked)); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + // The operator's read (this snapshot is carried forward stale below). + var operatorRead = await _repository.GetByIdAsync(id); + Assert.NotNull(operatorRead); + + // The dispatcher's own write lands first, in the window between the + // operator's read and write. + var deliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero); + var dispatcherRead = await _repository.GetByIdAsync(id); + dispatcherRead!.Status = NotificationStatus.Delivered; + dispatcherRead.DeliveredAt = deliveredAt; + Assert.True(await _repository.UpdateAsync(dispatcherRead)); + _context.ChangeTracker.Clear(); + + // The operator's stale in-memory copy still says Parked; it attempts a Discard. + operatorRead!.Status = NotificationStatus.Discarded; + Assert.False(await _repository.TryUpdateFromParkedAsync(operatorRead)); + + _context.ChangeTracker.Clear(); + var reloaded = await _context.Notifications.FindAsync(id); + Assert.Equal(NotificationStatus.Delivered, reloaded!.Status); + Assert.Equal(deliveredAt, reloaded.DeliveredAt); + } + + /// + /// Companion to : + /// runs the IDENTICAL interleaving through the unconditional + /// instead — the + /// method the operator Retry/Discard path called pre-fix — and shows it + /// silently clobbers the Delivered row. This documents the exact defect + /// arch-review 2026-08-19 P5-4 fixed by routing the operator path through + /// : UpdateAsync itself is unchanged + /// (the dispatcher still needs an unconditional write), so this scenario + /// remains "true" by design — it is the operator actor's call-site choice + /// that was the bug, proven fixed by the actor-level tests in + /// NotificationOutbox.Tests (Discard_ConcurrentlyDelivered_...). + /// + [Fact] + public async Task UpdateAsync_Unconditional_WouldOverwriteConcurrentlyDeliveredRow_DemonstratingP5_4Mechanism() + { + var id = Guid.NewGuid().ToString(); + _context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked)); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var operatorRead = await _repository.GetByIdAsync(id); + Assert.NotNull(operatorRead); + + var deliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero); + var dispatcherRead = await _repository.GetByIdAsync(id); + dispatcherRead!.Status = NotificationStatus.Delivered; + dispatcherRead.DeliveredAt = deliveredAt; + Assert.True(await _repository.UpdateAsync(dispatcherRead)); + _context.ChangeTracker.Clear(); + + operatorRead!.Status = NotificationStatus.Discarded; + // The unconditional UPDATE has no status guard, so it "succeeds" here — + // this is the bug. It clobbers the Delivered row to Discarded and drops + // DeliveredAt, which is exactly the silent-overwrite hazard P5-4 closes + // by moving the operator path to TryUpdateFromParkedAsync. + Assert.True(await _repository.UpdateAsync(operatorRead)); + + _context.ChangeTracker.Clear(); + var reloaded = await _context.Notifications.FindAsync(id); + Assert.Equal(NotificationStatus.Discarded, reloaded!.Status); + Assert.Null(reloaded.DeliveredAt); + } + [Fact] public async Task QueryAsync_AppliesFilters_OrdersByCreatedAtDescending_AndPaginates() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index 5e93a010..fe3143dd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs @@ -258,7 +258,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.True(response.Success); Assert.Null(response.ErrorMessage); - _repository.Received(1).UpdateAsync( + _repository.Received(1).TryUpdateFromParkedAsync( Arg.Is(n => n.NotificationId == row.NotificationId && n.Status == NotificationStatus.Pending && @@ -280,7 +280,7 @@ public class NotificationOutboxActorQueryTests : TestKit var response = ExpectMsg(); Assert.False(response.Success); Assert.NotNull(response.ErrorMessage); - _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + _repository.DidNotReceive().TryUpdateFromParkedAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -300,17 +300,19 @@ public class NotificationOutboxActorQueryTests : TestKit /// /// The row is read successfully but the retention purge deletes it before the - /// write lands, so the targeted UPDATE matches nothing. The operator must be - /// told the notification is gone — reporting a re-queue that never happened - /// is what the ExecuteUpdate rewrite silently introduced (the predecessor - /// threw DbUpdateConcurrencyException here). + /// write lands, so the CAS write matches nothing. The re-read that follows a + /// lost CAS (arch-review 2026-08-19 P5-4) finds nothing either, and the + /// operator must be told the notification is gone — reporting a re-queue + /// that never happened is what a blind ExecuteUpdate would silently do (the + /// pre-ExecuteUpdate predecessor threw DbUpdateConcurrencyException here). /// [Fact] public void Retry_RowPurgedBeforeWrite_RepliesNotFound() { var row = MakeNotification(status: NotificationStatus.Parked, retryCount: 10, lastError: "gave up"); - _repository.GetByIdAsync(row.NotificationId, Arg.Any()).Returns(row); - _repository.UpdateAsync(Arg.Any(), Arg.Any()).Returns(false); + _repository.GetByIdAsync(row.NotificationId, Arg.Any()) + .Returns(row, (Notification?)null); + _repository.TryUpdateFromParkedAsync(Arg.Any(), Arg.Any()).Returns(false); var actor = CreateActor(); actor.Tell(new RetryNotificationRequest("corr-vanished-retry", row.NotificationId), TestActor); @@ -327,8 +329,9 @@ public class NotificationOutboxActorQueryTests : TestKit public void Discard_RowPurgedBeforeWrite_RepliesNotFound() { var row = MakeNotification(status: NotificationStatus.Parked); - _repository.GetByIdAsync(row.NotificationId, Arg.Any()).Returns(row); - _repository.UpdateAsync(Arg.Any(), Arg.Any()).Returns(false); + _repository.GetByIdAsync(row.NotificationId, Arg.Any()) + .Returns(row, (Notification?)null); + _repository.TryUpdateFromParkedAsync(Arg.Any(), Arg.Any()).Returns(false); var actor = CreateActor(); actor.Tell(new DiscardNotificationRequest("corr-vanished-discard", row.NotificationId), TestActor); @@ -340,6 +343,75 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Contains("not found", response.ErrorMessage); } + /// + /// PROOF OF FAILURE for arch-review 2026-08-19 P5-4. Models the race the + /// finding describes: the operator's read sees Parked, but by the time the + /// write lands the dispatcher has already delivered the row (a detached + /// PipeTo task interleaving with the dispatcher's own write). The mechanism + /// is proven deterministically via a mocked call sequence, not a wall-clock + /// sleep (per the "no new sleep-then-assert" constraint) — the first + /// call (the + /// operator's read) returns the Parked row, the CAS write reports a lost + /// race, and the SECOND GetByIdAsync call (the fix's re-read used to + /// build an honest reply) returns the row now Delivered. + /// + /// Against the pre-fix actor — which calls the unconditional + /// UpdateAsync exactly once and never re-reads — this test fails: + /// the single-read code path only ever consumes the first queued value + /// (Parked), the in-memory check passes, UpdateAsync is stubbed + /// Healthy() (returns true), and the response comes back + /// Success = true — i.e. the Delivered row gets silently clobbered + /// to Discarded. Post-fix, the CAS is lost, the actor re-reads and reports + /// "already progressed to Delivered" instead of overwriting it. + /// + /// + [Fact] + public void Discard_ConcurrentlyDelivered_RepliesAlreadyProgressed_DoesNotOverwrite() + { + var parkedRow = MakeNotification(status: NotificationStatus.Parked, retryCount: 5, lastError: "gave up"); + var deliveredRow = MakeNotification(status: NotificationStatus.Delivered); + _repository.GetByIdAsync(parkedRow.NotificationId, Arg.Any()) + .Returns(parkedRow, deliveredRow); + _repository.TryUpdateFromParkedAsync(Arg.Any(), Arg.Any()).Returns(false); + var actor = CreateActor(); + + actor.Tell(new DiscardNotificationRequest("corr-race-discard", parkedRow.NotificationId), TestActor); + + var response = ExpectMsg(); + Assert.Equal("corr-race-discard", response.CorrelationId); + Assert.False(response.Success); + Assert.NotNull(response.ErrorMessage); + Assert.Contains("already progressed", response.ErrorMessage); + Assert.Contains("Delivered", response.ErrorMessage); + + // The critical invariant: the old unconditional UpdateAsync must NEVER + // be reached by the operator path — that call is what silently + // clobbered the Delivered row pre-fix. + _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + /// Retry half of . + [Fact] + public void Retry_ConcurrentlyDelivered_RepliesAlreadyProgressed_DoesNotOverwrite() + { + var parkedRow = MakeNotification(status: NotificationStatus.Parked, retryCount: 5, lastError: "gave up"); + var deliveredRow = MakeNotification(status: NotificationStatus.Delivered); + _repository.GetByIdAsync(parkedRow.NotificationId, Arg.Any()) + .Returns(parkedRow, deliveredRow); + _repository.TryUpdateFromParkedAsync(Arg.Any(), Arg.Any()).Returns(false); + var actor = CreateActor(); + + actor.Tell(new RetryNotificationRequest("corr-race-retry", parkedRow.NotificationId), TestActor); + + var response = ExpectMsg(); + Assert.Equal("corr-race-retry", response.CorrelationId); + Assert.False(response.Success); + Assert.NotNull(response.ErrorMessage); + Assert.Contains("already progressed", response.ErrorMessage); + Assert.Contains("Delivered", response.ErrorMessage); + _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + [Fact] public void Discard_ParkedNotification_MarksDiscarded_AndSucceeds() { @@ -353,7 +425,7 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Equal("corr-8", response.CorrelationId); Assert.True(response.Success); - _repository.Received(1).UpdateAsync( + _repository.Received(1).TryUpdateFromParkedAsync( Arg.Is(n => n.Status == NotificationStatus.Discarded), Arg.Any()); } @@ -370,7 +442,7 @@ public class NotificationOutboxActorQueryTests : TestKit var response = ExpectMsg(); Assert.False(response.Success); Assert.NotNull(response.ErrorMessage); - _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + _repository.DidNotReceive().TryUpdateFromParkedAsync(Arg.Any(), Arg.Any()); } [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorRetryEmissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorRetryEmissionTests.cs index df07b46a..5e35d834 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorRetryEmissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorRetryEmissionTests.cs @@ -147,7 +147,7 @@ public class NotificationOutboxActorRetryEmissionTests : TestKit ExpectMsg(r => r.Success); AwaitAssert(() => { - _outboxRepository.Received(1).UpdateAsync( + _outboxRepository.Received(1).TryUpdateFromParkedAsync( Arg.Is(n => n.Status == NotificationStatus.Pending), Arg.Any()); }); diff --git a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/OutboxRepositorySubstitute.cs b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/OutboxRepositorySubstitute.cs index 67f9e444..7403cdb1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/OutboxRepositorySubstitute.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/OutboxRepositorySubstitute.cs @@ -8,14 +8,16 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests; /// Factory for the outbox repository substitute used across these tests. /// /// -/// returns whether the -/// targeted UPDATE actually matched a row — false is the "this -/// notification no longer exists" signal the operator retry/discard handlers -/// turn into a not-found reply. NSubstitute's default for Task<bool> -/// is false, i.e. the VANISHED-row answer, so an unconfigured substitute -/// would silently put every test on the failure path. Tests that want a healthy -/// store take one from here; the vanished-row tests configure -/// Returns(false) for themselves. +/// and +/// both +/// return whether the targeted UPDATE actually matched a row — false is +/// the "this notification no longer exists (or, for the CAS variant, is no +/// longer Parked)" signal the operator retry/discard handlers turn into a +/// not-found / already-progressed reply. NSubstitute's default for +/// Task<bool> is false, i.e. the failure answer, so an +/// unconfigured substitute would silently put every test on the failure path. +/// Tests that want a healthy store take one from here; the vanished-row and +/// lost-CAS tests configure Returns(false) for themselves. /// internal static class OutboxRepositorySubstitute { @@ -26,6 +28,9 @@ internal static class OutboxRepositorySubstitute repository .UpdateAsync(Arg.Any(), Arg.Any()) .Returns(true); + repository + .TryUpdateFromParkedAsync(Arg.Any(), Arg.Any()) + .Returns(true); return repository; } }