diff --git a/docs/components/ConfigurationDatabase.md b/docs/components/ConfigurationDatabase.md
index f98a9661..5ca6a6c7 100644
--- a/docs/components/ConfigurationDatabase.md
+++ b/docs/components/ConfigurationDatabase.md
@@ -78,13 +78,15 @@ builder.Property(e => e.ExecutionId)
"CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", stored: true)
.ValueGeneratedOnAddOrUpdate();
-// Composite PK includes OccurredAtUtc for partition alignment
+// Composite PK includes OccurredAtUtc for partition alignment — and is the ONLY
+// EventId uniqueness enforcement (WP2.2 / AlignAuditLogEventIdUniqueness). EventId
+// is a GUID minted once at the site alongside OccurredAtUtc, so a given EventId
+// always lands in one partition and pair-uniqueness is EventId-uniqueness.
builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
-
-builder.HasIndex(e => e.EventId).IsUnique()
- .HasDatabaseName("UX_AuditLog_EventId");
```
+The predecessor `UX_AuditLog_EventId` — a single-column unique index on `[PRIMARY]`, deliberately NOT partition-aligned — was dropped: a non-aligned index blocks `ALTER TABLE … SWITCH PARTITION`, so the retention purge had to drop it, switch, and rebuild it offline inside the switch transaction on every run.
+
**`TemplateConfiguration`** (representative of the domain-area configs) sets up the self-referencing parent FK, folder FK, cascade-delete relationships to attributes/alarms/scripts/compositions/native alarm sources, and the filtered unique index that enforces name uniqueness only on non-derived (base) templates.
**`SiteCallEntityTypeConfiguration`** maps `SiteCall` to `dbo.SiteCalls` with a `TrackedOperationId` PK stored as `varchar(36)` (GUID in `"D"` format) so the column shape matches the wire format and the site SQLite store — one consistent format for operational debugging.
@@ -108,11 +110,13 @@ VALUES
`FormattableString` interpolation parameterises every value so there is no injection surface. SQL error numbers `2601` and `2627` (unique-index violation) are swallowed as no-ops because the IF NOT EXISTS check has a race window; both the check-loser and the retrying telemetry path are semantically correct duplicates.
+`InsertManyIfNotExistsAsync` is the set-based form the ingest hot path uses: one `INSERT … SELECT … FROM (VALUES …) WHERE NOT EXISTS` per chunk of 100 rows (ten bound parameters per row against SQL Server's 2,100-parameter ceiling), so a telemetry packet costs one round trip instead of one per event. Because the anti-semi-join only sees committed rows, the packet is de-duplicated by `EventId` in C# first (first-write-wins, matching the single-row contract); a duplicate-key fault from a concurrent writer falls back to the per-row path so the batch is never a correctness dependency. It uses raw ADO.NET with explicitly typed `SqlParameter`s — the `VALUES` constructor derives its column types from the first row's parameters, so an untyped null would give the derived column the wrong type — and enlists in the DbContext's ambient transaction when one is open (the cached-telemetry dual-write).
+
`QueryAsync` builds LINQ predicates over `AuditLogRow` using `AsNoTracking()`, translating filter dimensions (`Channels`, `Kinds`, `Statuses`, `SourceSiteIds`, `SourceNodes`, `ExecutionId`, `ParentExecutionId`, time range) to server-side SQL IN/equality predicates and using keyset pagination on `(OccurredAtUtc DESC, EventId DESC)`.
`GetExecutionTreeAsync` walks the `ParentExecutionId` graph in two phases: a loop climbs to the root (bounded at 32 levels), then a recursive CTE descends the full tree and LEFT JOINs back to `AuditLog` so stub nodes (purged or row-less executions) still appear with `RowCount = 0`.
-`SwitchOutPartitionAsync` executes a drop-and-rebuild dance — dropping `UX_AuditLog_EventId`, creating a byte-identical staging table (including the computed-column definitions), switching the target partition to staging, dropping staging, and rebuilding the unique index — all inside a single `BEGIN TRY / BEGIN CATCH` block that guarantees the index is present whether the switch succeeds or rolls back.
+`SwitchOutPartitionAsync` creates a byte-identical staging table (including the computed-column definitions), switches the target partition to staging, and drops staging, inside a single `BEGIN TRY / BEGIN CATCH` block whose CATCH cleans up the staging table on any failure. No index is dropped or rebuilt: uniqueness rides the partition-aligned clustered PK, so the switch is metadata-only. A guarded defensive `DROP INDEX UX_AuditLog_EventId` remains at the head of the batch purely so a database restored from a pre-alignment backup still purges.
### IAuditService — config-change audit
@@ -241,7 +245,7 @@ The host is running in production mode and `GetPendingMigrationsAsync` found una
### AuditLog partition switch fails mid-operation
-`SwitchOutPartitionAsync` wraps the drop-and-rebuild dance in `BEGIN TRY / BEGIN CATCH`. On failure the CATCH block drops the staging table if it exists and rebuilds `UX_AuditLog_EventId` if it was dropped before the failure. The original exception is re-thrown so the Audit Log purge actor logs it and retries on the next daily tick. Verify that the `scadabridge_audit_purger` role still holds `ALTER ON SCHEMA::dbo` if the operation fails with a permissions error.
+`SwitchOutPartitionAsync` wraps the staging/switch batch in `BEGIN TRY / BEGIN CATCH`. On failure the CATCH block drops the staging table if it exists, so no orphaned `AuditLog_Staging_*` object is left behind. There is no index to repair — uniqueness lives on the clustered PK, which the switch never touches. The original exception is re-thrown so the Audit Log purge actor logs it and retries on the next daily tick. Verify that the `scadabridge_audit_purger` role still holds `ALTER ON SCHEMA::dbo` if the operation fails with a permissions error.
### Design-time `dotnet ef` tooling cannot find a connection string
diff --git a/docs/plans/sql/AlignAuditLogEventIdUniqueness.sql b/docs/plans/sql/AlignAuditLogEventIdUniqueness.sql
new file mode 100644
index 00000000..90b3003f
--- /dev/null
+++ b/docs/plans/sql/AlignAuditLogEventIdUniqueness.sql
@@ -0,0 +1,24 @@
+BEGIN TRANSACTION;
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260815004957_AlignAuditLogEventIdUniqueness'
+)
+BEGIN
+
+ IF EXISTS (SELECT 1 FROM sys.indexes
+ WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
+ DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
+END;
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260815004957_AlignAuditLogEventIdUniqueness'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260815004957_AlignAuditLogEventIdUniqueness', N'10.0.7');
+END;
+
+COMMIT;
+GO
+
diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md
index c36ef201..67429493 100644
--- a/docs/requirements/Component-AuditLog.md
+++ b/docs/requirements/Component-AuditLog.md
@@ -291,6 +291,27 @@ stay `Pending` for the next sweep. Cadence is short (default 5 s) when
non-empty, longer (default 30 s) when idle; telemetry runs on a dedicated
dispatcher.
+**Central-side ingest is set-based.** `AuditLogIngestActor` writes a whole
+packet with ONE `InsertManyIfNotExistsAsync` statement rather than one
+`IF NOT EXISTS … INSERT` round trip per event; the cached-telemetry dual-write
+similarly runs the whole packet in ONE transaction (set-based audit insert plus
+one single-statement `SiteCalls` upsert per entry) instead of a transaction per
+entry. Idempotency is unchanged: duplicates that repeat *within* a packet
+collapse first-write-wins before the statement is built, duplicates *across*
+packets are eliminated by the anti-semi-join, and any failure falls back to the
+per-row / per-entry path — so the documented invariant that one bad row cannot
+sink the rest of the batch still holds, it is simply no longer paid for on the
+healthy path.
+
+**Timeout ladder.** The ingest budget is deliberately the smallest on the path:
+the site's Ask and the central gRPC handler's Ask are both 30 s, the actor's
+own database budget is 20 s and the per-statement SQL timeout is 15 s. Before
+this the three were identical, so they expired at the same instant and the
+caller learned nothing but "it took 30 s" — no partial ack, no way to tell a
+slow database from a wedged singleton. With the inner budgets strictly smaller,
+a slow batch is abandoned by the actor first and the accepted-so-far ids are
+still replied while the outer Asks are still waiting.
+
### Reconciliation pull (self-healing for missed telemetry)
A central `SiteAuditReconciliationActor` periodically (default 5 min per site)
@@ -490,12 +511,19 @@ MS SQL for direct-write events). Unredacted secrets never persist.
silently failing retention job would otherwise be invisible until the table grew
unbounded. Per-boundary/per-channel error isolation is unchanged — a single failure
still never abandons the rest of the tick.
-- **Maintenance command timeout:** the switch-out drop-and-rebuild dance and each
+- **Maintenance command timeout:** the switch-out staging batch and each
per-channel `DELETE TOP` batch run with an explicit command timeout
(`AuditLog:Purge:MaintenanceCommandTimeoutMinutes`, default 30, floor 1 min) rather
- than the ~30 s ADO.NET default, which could abort the metadata-only SWITCH mid-dance
- on a large or contended partition and leave the live table without
- `UX_AuditLog_EventId` until a later tick's CATCH branch rebuilt it.
+ than the ~30 s ADO.NET default, which could abort the metadata-only SWITCH mid-batch
+ on a large or contended partition and leave an orphaned staging table for the next
+ tick's CATCH branch to clean up.
+- **Partition-aligned uniqueness:** the switch no longer drops and rebuilds an index.
+ `EventId` uniqueness rides the clustered `PK_AuditLog (EventId, OccurredAtUtc)`,
+ which is aligned on `ps_AuditLog_Month(OccurredAtUtc)`, so `SWITCH PARTITION` has no
+ non-aligned unique index to object to. The predecessor `UX_AuditLog_EventId` forced
+ an offline whole-table index build inside the switch transaction — blocking every
+ audit writer for its duration — and left a window in which the idempotency-supporting
+ index did not exist at all. See migration `AlignAuditLogEventIdUniqueness`.
- **Per-channel retention overrides (M5.5 T3):** `AuditLog:PerChannelRetentionDays`
is a dictionary keyed by canonical channel name (`ApiOutbound`, `DbOutbound`,
`Notification`, `ApiInbound`, `SecuredWrite` — all five `AuditChannel` values are
diff --git a/docs/requirements/Component-ConfigurationDatabase.md b/docs/requirements/Component-ConfigurationDatabase.md
index e5bd42b0..4fd3e8d8 100644
--- a/docs/requirements/Component-ConfigurationDatabase.md
+++ b/docs/requirements/Component-ConfigurationDatabase.md
@@ -95,9 +95,11 @@ The configuration database stores all central system data, organized by domain a
- `ParentExecutionId` — `CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)` PERSISTED — spawner's `ExecutionId`; null for top-level runs.
- `IngestedAtUtc` — `CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))` — central ingest timestamp; **not** persisted (SQL Server rejects PERSISTED on the non-deterministic `SWITCHOFFSET` expression).
- *Clustered primary key:* `(EventId, OccurredAtUtc)` — composite so the key is partition-aligned. `UX_AuditLog_EventId` (unique, non-aligned on `[PRIMARY]`) enforces global `EventId` uniqueness for `InsertIfNotExistsAsync` idempotency.
+ *Clustered primary key:* `(EventId, OccurredAtUtc)` — composite so the key is partition-aligned, and the **sole** `EventId` uniqueness enforcement backing `InsertIfNotExistsAsync` idempotency. `EventId` is a GUID minted once at the emitting site in the same operation that stamps `OccurredAtUtc`, and neither field is ever re-stamped downstream, so a given `EventId` always arrives with the same `OccurredAtUtc` and can only map to one partition — pair-uniqueness is `EventId`-uniqueness for every row the system produces. The idempotency probe (`WHERE EventId = @id`) still seeks the clustered key's leading column, at the cost of one seek per partition rather than one seek overall.
- *Indexes* (all non-clustered, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)` except `UX_AuditLog_EventId`):
+ A non-aligned single-column `UX_AuditLog_EventId` on `[PRIMARY]` used to carry that uniqueness; it was dropped by the `AlignAuditLogEventIdUniqueness` migration because a non-aligned index blocks `ALTER TABLE … SWITCH PARTITION`, forcing the retention purge to drop it, switch, and rebuild it **offline inside the switch transaction** on every run.
+
+ *Indexes* (all non-clustered, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)`):
- `IX_AuditLog_OccurredAtUtc` (primary time-range index for global scans)
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` (per-site filters)
- `IX_AuditLog_CorrelationId (CorrelationId) WHERE CorrelationId IS NOT NULL` (drilldown from a single operation)
diff --git a/docs/requirements/Component-SiteCallAudit.md b/docs/requirements/Component-SiteCallAudit.md
index 0b2c4f09..58b2388f 100644
--- a/docs/requirements/Component-SiteCallAudit.md
+++ b/docs/requirements/Component-SiteCallAudit.md
@@ -135,6 +135,28 @@ Pinned)` on the EventStream (transition-only, mirroring
health-observable condition rather than a silent log line, and the latch clears
with `Pinned=false` once a later tick makes progress.
+**The drain runs off the mailbox.** A reconciliation pass is unbounded work —
+every site, up to a page ceiling of network pulls each, one upsert per row — so
+it runs as a background task with a `PipeTo`-delivered completion message and a
+single-flight guard, not as an actor message handler. A handler occupies the
+actor for its whole duration, so a post-outage catch-up used to park telemetry
+ingest, UI queries and KPI Asks behind it; those callers time out rather than
+queue, which made a slow site look like a dead central. The single-flight guard
+is raised and lowered ON the actor thread, so the per-site cursor and pinned-latch
+dictionaries the pass mutates are still only ever touched by one task at a time,
+and the mailbox supplies the memory barrier between consecutive passes. The
+daily terminal-row purge uses the same shape. (`NotificationOutboxActor`'s
+dispatch sweep is the in-repo reference.)
+
+**The central upsert is one statement.** `SiteCallAuditRepository.UpsertAsync`
+issues a single batch that runs the monotonic UPDATE first and INSERTs only when
+nothing matched *and* the row genuinely does not exist — instead of an
+unconditional insert-if-absent followed by the update, which cost two round trips
+on every packet and wasted the insert half for every packet after the first. The
+existence re-check is load-bearing: a zero row count also means "the monotonic
+guard rejected this packet", and inserting there would fork the mirror with a
+second row for an id that already exists.
+
## Retry / Discard Relay
Parked cached calls live in the owning site's S&F buffer. Operator Retry/Discard
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs
index 8cece24d..e7b3f9a0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs
@@ -18,8 +18,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// ingest timestamp into DetailsJson (there is no promoted IngestedAtUtc
/// column — the value is a DetailsJson field set via
/// ) and inserted idempotently
-/// via — duplicates are
-/// silently swallowed (first-write-wins).
+/// via — duplicates
+/// are silently swallowed (first-write-wins), whether they repeat inside one
+/// packet or across packets.
///
///
///
@@ -28,9 +29,11 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// consistent and the site is free to flip its local row to Forwarded.
///
///
-/// Audit-write failures must NEVER abort the user-facing action. The actor
-/// wraps each repository call in its own try/catch so a single bad row cannot
-/// cause the rest of the batch to be lost, and it guards scope/repository
+/// Audit-write failures must NEVER abort the user-facing action. Each message
+/// is written as ONE set-based statement (or one transaction, for the cached
+/// dual-write); if that fails the actor retries the same work row-by-row inside
+/// per-row try/catch, so a single bad row still cannot cause the rest of the
+/// batch to be lost. It also guards scope/repository
/// resolution so a transient DI fault cannot restart the singleton — those
/// catches are what keep this actor alive across handler throws, not the
/// supervisor strategy. The override returns
@@ -50,6 +53,31 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
///
public class AuditLogIngestActor : ReceiveActor
{
+ ///
+ /// Overall budget for one ingest message's database work, deliberately
+ /// SHORTER than the gRPC Ask that wraps it.
+ ///
+ ///
+ /// The path used to stack three identical 30 s budgets — the site's Ask
+ /// (CommunicationOptions.NotificationForwardTimeout), the central gRPC
+ /// handler's Ask (SiteStreamGrpcServer.AuditIngestAskTimeout) and the
+ /// ADO.NET command default — so they all expired at the same instant. The
+ /// caller therefore learned nothing except "it took 30 s": no partial ack, no
+ /// distinction between a slow database and a wedged singleton. Making the
+ /// innermost budget strictly smallest means a slow batch is abandoned by the
+ /// actor FIRST, with the accepted-so-far ids still replied, while the outer
+ /// Asks are still waiting.
+ ///
+ internal static readonly TimeSpan IngestBudget = TimeSpan.FromSeconds(20);
+
+ ///
+ /// Per-statement SQL timeout for the ingest write, strictly inside
+ /// so a single wedged statement surfaces as a SQL
+ /// timeout the per-batch catch can log, rather than consuming the whole
+ /// actor-level budget and starving the rest of the batch.
+ ///
+ internal static readonly TimeSpan IngestSqlCommandTimeout = TimeSpan.FromSeconds(15);
+
private readonly IServiceProvider? _serviceProvider;
private readonly IAuditLogRepository? _injectedRepository;
private readonly ILogger _logger;
@@ -185,39 +213,75 @@ public class AuditLogIngestActor : ReceiveActor
DateTime nowUtc,
List accepted)
{
+ // Stamp the ingest timestamp here, not at the site. Redact BEFORE the
+ // IngestedAtUtc stamp so the redacted copy carries the central-side
+ // ingest timestamp. The redactor is contract-bound to never throw; a null
+ // redactor (test composition root, no IAuditRedactor registered) falls
+ // back to the SafeDefault rather than pass-through, so HTTP header
+ // redaction always runs. IngestedAtUtc is a DetailsJson field on the
+ // canonical record, so stamp it via the projection helper.
+ var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
+ var projected = new List(cmd.Events.Count);
foreach (var evt in cmd.Events)
{
- try
+ var filtered = safeRedactor.Apply(evt);
+ projected.Add(AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc));
+ }
+
+ // ONE set-based statement for the whole packet (WP2.2) instead of one
+ // IF NOT EXISTS … INSERT round trip per event. A telemetry packet of 200
+ // rows used to cost 200 sequential round trips on the central singleton's
+ // dispatcher; it now costs two. Idempotency is unchanged — duplicates
+ // within the packet collapse first-write-wins, duplicates against
+ // committed rows are eliminated by the anti-semi-join, and a concurrent
+ // writer degrades to the repository's per-row fallback.
+ using var budget = new CancellationTokenSource(IngestBudget);
+ try
+ {
+ await repository
+ .InsertManyIfNotExistsAsync(projected, IngestSqlCommandTimeout, budget.Token)
+ .ConfigureAwait(false);
+
+ // A batch that returned without throwing means every row is now
+ // present — inserted here or already committed by an earlier delivery.
+ // Both count as accepted: the storage state is consistent and the site
+ // is free to flip its local rows to Forwarded.
+ foreach (var evt in cmd.Events)
{
- // Stamp the ingest timestamp here, not at the site. The
- // repository's duplicate-key hardening already swallows
- // duplicate-key races, so the same id arriving twice (site
- // retry, reconciliation) is a silent no-op.
- // Redact BEFORE the IngestedAtUtc stamp so the redacted
- // copy carries the central-side ingest timestamp. The redactor
- // is contract-bound to never throw. A null
- // redactor (test composition root, no IAuditRedactor
- // registered) now falls back to the SafeDefault rather than
- // pass-through, so HTTP header redaction always runs.
- // IngestedAtUtc is a DetailsJson field on
- // the canonical record, so stamp it via the projection helper.
- var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
- var filtered = safeRedactor.Apply(evt);
- var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
- await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
accepted.Add(evt.EventId);
}
- catch (Exception ex)
+ }
+ catch (Exception ex)
+ {
+ // The batch is a throughput optimisation, NOT a change to the
+ // failure grain. The documented invariant — "a single bad row cannot
+ // cause the rest of the batch to be lost" — is preserved by falling
+ // back to the per-row path here, so a poison row still costs only
+ // itself. Re-running rows the failed batch may already have committed
+ // is safe: every insert is idempotent on EventId.
+ _logger.LogWarning(ex,
+ "Set-based ingest of {Count} audit event(s) failed; falling back to per-row inserts so one bad row does not sink the batch.",
+ cmd.Events.Count);
+
+ for (var i = 0; i < projected.Count; i++)
{
- // Per-row catch — one bad row never sinks the whole batch.
- // The row stays Pending at the site; the next drain retries.
- // Bump the central health counter so a
- // sustained insert-throw failure surfaces on the dashboard.
- try { failureCounter?.Increment(); }
- catch { /* counter must never throw — defence in depth */ }
- _logger.LogError(ex,
- "Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.",
- evt.EventId);
+ try
+ {
+ await repository.InsertIfNotExistsAsync(projected[i], budget.Token).ConfigureAwait(false);
+ accepted.Add(cmd.Events[i].EventId);
+ }
+ catch (Exception rowEx)
+ {
+ // Per-row catch — one bad row never sinks the whole batch.
+ // The row stays Pending at the site; the next drain retries.
+ // Bump the central health counter so a sustained insert-throw
+ // failure surfaces on the dashboard.
+ try { failureCounter?.Increment(); }
+ catch { /* counter must never throw — defence in depth */ }
+ _logger.LogError(rowEx,
+ "Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.",
+ cmd.Events[i].EventId);
+ }
}
}
}
@@ -262,6 +326,24 @@ public class AuditLogIngestActor : ReceiveActor
var strategy = dbContext.Database.CreateExecutionStrategy();
+ // Fast path (WP2.2): the WHOLE packet in ONE transaction — one
+ // set-based audit insert plus one single-statement upsert per entry.
+ // The predecessor opened a transaction PER entry and issued three
+ // statements inside it (IF NOT EXISTS insert, then the two-statement
+ // SiteCalls upsert), so a 50-entry packet cost ~200 round trips and 50
+ // commits. If anything faults, the per-entry loop below re-runs the
+ // packet with its original per-entry isolation, so the documented
+ // "one entry's failure does not abort the others" invariant survives —
+ // it is simply no longer paid for on the healthy path.
+ using var budget = new CancellationTokenSource(IngestBudget);
+ if (await TryIngestCachedBatchAsync(
+ strategy, dbContext, auditRepo, siteCallRepo, redactor, cmd, accepted, budget.Token)
+ .ConfigureAwait(false))
+ {
+ replyTo.Tell(new IngestCachedTelemetryReply(accepted));
+ return;
+ }
+
foreach (var entry in cmd.Entries)
{
try
@@ -330,6 +412,90 @@ public class AuditLogIngestActor : ReceiveActor
replyTo.Tell(new IngestCachedTelemetryReply(accepted));
}
+ ///
+ /// Attempts the whole cached-telemetry packet as ONE transaction: a single
+ /// set-based audit insert followed by one monotonic SiteCalls upsert
+ /// per entry. Returns when it committed (and only then
+ /// appends to ), when the
+ /// caller should fall back to the per-entry transaction loop.
+ ///
+ ///
+ /// The batch is all-or-nothing by construction — it is a single transaction —
+ /// which is why a failure MUST fall back rather than report partial success:
+ /// the per-entry loop is what turns one poison entry into one lost entry
+ /// instead of a lost packet. Nothing is appended to
+ /// until the commit returns, so a failed attempt
+ /// leaves the caller's list untouched and the retry starts from a clean slate.
+ ///
+ private async Task TryIngestCachedBatchAsync(
+ Microsoft.EntityFrameworkCore.Storage.IExecutionStrategy strategy,
+ ScadaBridgeDbContext dbContext,
+ IAuditLogRepository auditRepo,
+ ISiteCallAuditRepository siteCallRepo,
+ IAuditRedactor? redactor,
+ IngestCachedTelemetryCommand cmd,
+ List accepted,
+ CancellationToken ct)
+ {
+ try
+ {
+ await strategy.ExecuteAsync(async () =>
+ {
+ await using var tx = await dbContext.Database
+ .BeginTransactionAsync(ct)
+ .ConfigureAwait(false);
+
+ // One central-side instant for the whole packet so a join across
+ // the two tables sees matching timestamps (debugging convenience,
+ // not a correctness invariant).
+ var ingestedAt = DateTime.UtcNow;
+ var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
+
+ var auditRows = new List(cmd.Entries.Count);
+ foreach (var entry in cmd.Entries)
+ {
+ // Only the AuditLog row's payload columns are redactable;
+ // SiteCalls carries operational state only (status, retry
+ // count) and is left untouched.
+ var filteredAudit = safeRedactor.Apply(entry.Audit);
+ auditRows.Add(AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt));
+ }
+
+ await auditRepo
+ .InsertManyIfNotExistsAsync(auditRows, IngestSqlCommandTimeout, ct)
+ .ConfigureAwait(false);
+
+ foreach (var entry in cmd.Entries)
+ {
+ await siteCallRepo
+ .UpsertAsync(entry.SiteCall with { IngestedAtUtc = ingestedAt }, ct)
+ .ConfigureAwait(false);
+ }
+
+ await tx.CommitAsync(ct).ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // No health-counter bump here — the per-entry retry is the authority
+ // on whether this packet genuinely failed, and double-counting a
+ // batch that the fallback then writes successfully would make the
+ // dashboard read as a sustained fault during normal contention.
+ _logger.LogWarning(
+ ex,
+ "Batched cached-telemetry dual-write of {Count} entr(ies) failed; falling back to per-entry transactions.",
+ cmd.Entries.Count);
+ return false;
+ }
+
+ foreach (var entry in cmd.Entries)
+ {
+ accepted.Add(entry.Audit.EventId);
+ }
+
+ return true;
+ }
+
///
/// Fallback handler installed on the single-repository test ctor — that
/// ctor has no DbContext and no , so
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs
index dc95d731..d824c32d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs
@@ -17,8 +17,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// for monthly boundaries whose latest OccurredAtUtc is older
/// than DateTime.UtcNow - RetentionDays.
/// - For each eligible boundary, calls
-/// which runs
-/// the drop-and-rebuild dance around UX_AuditLog_EventId.
+/// , a
+/// metadata-only staging-table switch (WP2.2 removed the index
+/// drop/rebuild that used to bracket it).
/// - Publishes on the actor-system
/// EventStream so the central health collector + ops surfaces
/// can subscribe without coupling to this actor.
@@ -26,11 +27,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
///
///
///
-/// Daily cadence. Partition switch is metadata-only but the
-/// drop-and-rebuild dance briefly removes UX_AuditLog_EventId; running
-/// more often than necessary trades unique-index rebuild outages for
-/// negligible freshness wins. The default 24-hour interval matches
-/// alog.md §10's retention policy.
+/// Daily cadence. The partition switch is metadata-only, but it still
+/// takes schema-modification locks on a table the ingest path is writing to;
+/// running more often than necessary trades contention for negligible freshness
+/// wins. The default 24-hour interval matches alog.md §10's retention policy.
///
///
/// Continue-on-error. A single boundary that throws (transient SQL
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs
index 5621cd56..0d97571c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs
@@ -10,12 +10,12 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
///
///
///
-/// The purge actor is a daily-cadence singleton, not a hot-loop, because
-/// partition-switch I/O is metadata-only but the drop-and-rebuild dance
-/// briefly removes the UX_AuditLog_EventId unique index — running
-/// more often than necessary trades index-rebuild outages for marginal
-/// freshness gains. Lower this only when an operator can prove they need
-/// sub-daily purge granularity.
+/// The purge actor is a daily-cadence singleton, not a hot-loop. The
+/// partition switch itself is metadata-only (since WP2.2's
+/// AlignAuditLogEventIdUniqueness it no longer drops and rebuilds a
+/// non-aligned unique index around it), but it still takes schema-modification
+/// locks and competes with ingest for the same table. Lower this only when an
+/// operator can prove they need sub-daily purge granularity.
///
///
/// exists for tests to drop the cadence to
@@ -58,15 +58,14 @@ public sealed class AuditLogPurgeOptions
///
/// Per-command timeout (in minutes) for the maintenance SQL the purge tick issues —
- /// both the partition switch-out drop-and-rebuild dance
+ /// both the partition switch-out staging batch
/// ()
/// and each per-channel DELETE TOP batch. Default 30 minutes.
///
///
/// The ADO.NET default command timeout is ~30 seconds. On a large or contended partition the
- /// SWITCH dance (which briefly drops UX_AuditLog_EventId) can exceed that and abort
- /// mid-flight — leaving the live table without its idempotency-supporting unique index until a
- /// later tick's CATCH branch rebuilds it (arch-review 04, S2). A generous maintenance timeout
+ /// SWITCH batch can exceed that and abort mid-flight, leaving an orphaned staging table for the
+ /// next tick's CATCH branch to clean up (arch-review 04, S2). A generous maintenance timeout
/// lets the metadata-only switch complete rather than self-locking. Resolved via
/// , clamped to a 1-minute floor.
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
index 6aa0e707..72e31a66 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -34,6 +34,62 @@ public interface IAuditLogRepository
/// A task that represents the asynchronous operation.
Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);
+ ///
+ /// Set-based form of : inserts every
+ /// event in that does not already exist, in as few
+ /// round trips as the provider allows, and returns the number of rows
+ /// actually written. First-write-wins idempotency is unchanged — an EventId
+ /// already present is silently skipped.
+ ///
+ ///
+ ///
+ /// Duplicate EventIds are tolerated both within and across packets.
+ /// Duplicates inside one call collapse to the first occurrence before the
+ /// statement is built (an append-only row is immutable, so later copies of an
+ /// EventId carry the same content); duplicates against already-committed rows
+ /// are eliminated by the anti-semi-join. A concurrent writer that commits a
+ /// row mid-statement is handled by falling back to the per-row path, so the
+ /// batch is a throughput optimisation and never a correctness dependency.
+ ///
+ ///
+ /// Default implementation. The interface supplies a per-row loop so a
+ /// test double or an alternative store keeps working unchanged; the EF Core
+ /// implementation overrides it with a genuine set-based statement. Callers on
+ /// the ingest hot path should always prefer this method — the per-row form
+ /// cost one IF NOT EXISTS … INSERT round trip per audit event.
+ ///
+ ///
+ /// Audit events to insert; may contain duplicates.
+ ///
+ /// Optional per-statement timeout. The ingest actor passes a budget strictly
+ /// SHORTER than the gRPC Ask that wraps it, so the reply is produced before
+ /// the caller gives up rather than at the same instant.
+ ///
+ /// Cancellation token.
+ /// A task that resolves to the number of rows inserted (duplicates excluded).
+ async Task InsertManyIfNotExistsAsync(
+ IReadOnlyList events,
+ TimeSpan? commandTimeout = null,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(events);
+
+ var inserted = 0;
+ var seen = new HashSet(events.Count);
+ foreach (var evt in events)
+ {
+ if (evt is null || !seen.Add(evt.EventId))
+ {
+ continue;
+ }
+
+ await InsertIfNotExistsAsync(evt, ct).ConfigureAwait(false);
+ inserted++;
+ }
+
+ return inserted;
+ }
+
///
/// Returns up to rows matching
/// , ordered by (OccurredAtUtc DESC, EventId DESC).
@@ -60,36 +116,32 @@ public interface IAuditLogRepository
///
///
///
- /// Drop-and-rebuild dance. UX_AuditLog_EventId is intentionally
- /// non-partition-aligned (it lives on [PRIMARY] so single-column
- /// EventId uniqueness — required by —
- /// can be enforced cheaply). SQL Server rejects
- /// ALTER TABLE … SWITCH PARTITION while a non-aligned unique index
- /// is present, so the implementation drops the index, creates a staging
- /// table with byte-identical schema, switches the partition's data into
- /// staging, drops staging (discarding the rows), and rebuilds the unique
- /// index. The CATCH branch guarantees the index is rebuilt even on partial
- /// failure so the table never returns to live traffic without its
- /// idempotency-supporting index.
+ /// Partition-aligned uniqueness — no index drop. EventId uniqueness is
+ /// enforced by the clustered PK_AuditLog (EventId, OccurredAtUtc),
+ /// which is aligned on ps_AuditLog_Month(OccurredAtUtc), so
+ /// ALTER TABLE … SWITCH PARTITION has no non-aligned unique index to
+ /// object to. The implementation creates a staging table with byte-identical
+ /// schema, switches the partition's data into staging, and drops staging
+ /// (discarding the rows). Nothing is dropped from the live table, so there is
+ /// no window in which the idempotency enforcement is absent and no offline
+ /// index rebuild inside the switch transaction.
///
///
- /// Outage window. The dance briefly removes the unique index, so
- /// concurrent calls during the switch
- /// could in principle race past the IF NOT EXISTS check without the index
- /// catching the duplicate. This is acceptable for the daily purge cadence
- /// — the inserts that the IF NOT EXISTS check guards are themselves rare
- /// enough that a sub-second collision window is operationally negligible,
- /// and the composite PK still rejects same-(EventId, OccurredAtUtc) rows.
+ /// The predecessor design carried a non-aligned
+ /// UX_AuditLog_EventId on [PRIMARY] that had to be dropped and
+ /// rebuilt around every switch. A defensive guarded DROP INDEX remains
+ /// in the batch so a database restored from a pre-alignment backup still
+ /// purges; it is a one-way cleanup, never rebuilt.
///
///
/// Lower-bound datetime of the monthly partition to switch out.
///
/// Optional per-command timeout for the maintenance SQL (the row-count sample plus the
- /// drop-and-rebuild dance). When null the provider default applies. The purge actor passes
+ /// staging/switch batch). When null the provider default applies. The purge actor passes
///
- /// (default 30 min) because the ~30s ADO.NET default can abort the switch mid-dance on a large
- /// or contended partition, leaving the table without UX_AuditLog_EventId until the next
- /// tick recovers (arch-review 04, S2).
+ /// (default 30 min) because the ~30s ADO.NET default can abort the switch mid-batch on a large
+ /// or contended partition, leaving an orphaned staging table for the next tick's CATCH to clean
+ /// up (arch-review 04, S2).
///
/// Cancellation token.
/// A task that resolves to the approximate number of rows discarded by the partition switch.
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 fa8299de..9165e576 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs
@@ -46,10 +46,25 @@ public interface INotificationOutboxRepository
Task GetByIdAsync(string notificationId, CancellationToken cancellationToken = default);
///
- /// Marks modified and persists it (status transitions).
- /// Commits internally — this call is its own transaction.
+ /// Persists 's delivery-state columns —
+ /// Status, RetryCount, LastError, ResolvedTargets,
+ /// LastAttemptAt, NextAttemptAt, DeliveredAt. Commits
+ /// internally — this call is its own transaction.
///
- /// The notification to update.
+ ///
+ /// Scope is deliberately narrow. Those seven columns are the ONLY
+ /// mutable state a notification has: everything else (identity, type, list,
+ /// subject, body, type data, source/origin attribution, enqueue and creation
+ /// timestamps) is written once at ingest and is immutable by contract. Every
+ /// caller — the dispatcher's per-attempt write and the operator retry/discard
+ /// one-shots — touches only this set. Implementations are therefore free to
+ /// issue a targeted UPDATE of these columns rather than rewriting the whole
+ /// row, which matters because the row carries nvarchar(max) body and
+ /// payload columns and the dispatcher writes on EVERY attempt. A future
+ /// caller that needs to change an immutable column must add its own method
+ /// rather than widening this one.
+ ///
+ /// The notification whose delivery state should be persisted.
/// Cancellation token.
/// A task that completes when the notification has been persisted.
Task UpdateAsync(Notification n, CancellationToken cancellationToken = default);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
index 269854d9..ee94b2a7 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs
@@ -172,15 +172,18 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration new { e.EventId, e.OccurredAtUtc });
- builder.HasIndex(e => e.EventId)
- .IsUnique()
- .HasDatabaseName("UX_AuditLog_EventId");
-
// Index names are locked for reconciliation/migration discoverability. The
// column SETS migrate to the canonical/computed shape (alog.md §4 semantics
// preserved): Channel→Category, Site/Node/Execution/ParentExecution now read
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.Designer.cs
new file mode 100644
index 00000000..fc39c29b
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815004957_AlignAuditLogEventIdUniqueness.Designer.cs
@@ -0,0 +1,2090 @@
+//
+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("20260815004957_AlignAuditLogEventIdUniqueness")]
+ partial class AlignAuditLogEventIdUniqueness
+ {
+ ///
+ 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("SourceSite", "CreatedAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Source_Created");
+
+ b.HasIndex("Status", "UpdatedAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_SiteCalls_Status_Updated");
+
+ b.ToTable("SiteCalls", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConfigurationJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeploymentId");
+
+ b.HasIndex("InstanceId")
+ .IsUnique();
+
+ b.ToTable("DeployedConfigSnapshots");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedBy")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ErrorMessage")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeployedAt");
+
+ b.HasIndex("DeploymentId")
+ .IsUnique();
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("DeploymentRecords");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.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("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