feat(audit)!: ScadaBridge C5 — collapse central dbo.AuditLog to 10 canonical cols + persisted computed cols; CollapseAuditLogToCanonical migration; repo writes canonical directly (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 14:06:46 -04:00
parent 1737d15f04
commit 68a6bd1720
12 changed files with 2592 additions and 440 deletions
@@ -6,11 +6,12 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="AuditLogRow"/> persistence shape to the central <c>AuditLog</c>
/// table described in alog.md §4. Column lengths/types and the named indexes are
/// fixed by that specification — keep this in sync with the doc. C3 (Task 2.5) kept
/// the table unchanged; the canonical record is mapped onto this row at the repository
/// boundary via <c>AuditRowProjection</c>.
/// Maps the C5 (Task 2.5) <see cref="AuditLogRow"/> persistence shape to the central
/// <c>dbo.AuditLog</c> table: the 10 canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> columns
/// (writable) plus six read-only, server-side <b>persisted computed columns</b> derived
/// from <c>DetailsJson</c> via <c>JSON_VALUE</c>. The computed-column SQL and the index
/// set here mirror the <c>CollapseAuditLogToCanonical</c> migration's
/// <c>dbo.AuditLog_v2</c> DDL byte-for-byte — keep them in sync.
/// </summary>
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogRow>
{
@@ -35,88 +36,146 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
: null,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
// The computed columns derive enum-named strings from DetailsJson (e.g.
// JSON_VALUE(...,'$.kind') == "CachedResolve"), exactly the value
// HasConversion<string>() expects on read. The repository never writes these
// (they are server-computed), but the string<->enum converter is still
// required so EF materialises them as the strongly-typed enum a LINQ
// predicate like `e.Kind == AuditKind.CachedResolve` translates against.
/// <summary>Applies the EF Core type configuration for <see cref="AuditLogRow"/> to the model builder.</summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<AuditLogRow> builder)
{
builder.ToTable("AuditLog");
// Enforce DateTimeKind.Utc on every *Utc-suffixed DateTime column. See
// the UtcConverter remarks above for the rationale.
builder.Property(e => e.OccurredAtUtc).HasConversion(UtcConverter);
builder.Property(e => e.IngestedAtUtc).HasConversion(NullableUtcConverter);
// ── Canonical columns (writable) ─────────────────────────────────────
//
// Column SQL TYPES are intentionally left to EF's relational conventions
// (driven by HasMaxLength / IsUnicode / the CLR type) rather than pinned
// with HasColumnType, so the SAME configuration maps to SQL Server
// (varchar(n) / nvarchar(n) / uniqueidentifier / datetime2) in production
// AND to the SQLite test provider (TEXT) without a `(max)`/`uniqueidentifier`
// literal leaking into SQLite DDL. The migration's raw DDL pins the exact
// SQL Server types; EF's conventions agree with them (verified clean via
// `has-pending-model-changes`).
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
// separate unique index is declared on EventId alone.
builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
builder.HasIndex(e => e.EventId)
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
// Enum-as-string columns: bounded varchar(32) ASCII.
builder.Property(e => e.Channel)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.Kind)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.Status)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.ForwardState)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false);
// Ascii identifier columns — never carry user-supplied unicode.
builder.Property(e => e.SourceSiteId)
.HasMaxLength(64)
.IsUnicode(false);
builder.Property(e => e.SourceInstanceId)
.HasMaxLength(128)
.IsUnicode(false);
builder.Property(e => e.SourceScript)
.HasMaxLength(128)
.IsUnicode(false);
// Enforce DateTimeKind.Utc on the OccurredAtUtc column. See the
// UtcConverter remarks above for the rationale.
builder.Property(e => e.OccurredAtUtc)
.HasConversion(UtcConverter);
builder.Property(e => e.Actor)
.HasMaxLength(128)
.IsUnicode(false);
.HasMaxLength(256);
builder.Property(e => e.Action)
.HasMaxLength(64)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.Outcome)
.HasConversion<string>()
.HasMaxLength(16)
.IsUnicode(false)
.IsRequired();
// Channel rides in the canonical Category column (Category = channel name
// for ScadaBridge). Stored as the enum's name in varchar(32); the
// string<->enum converter lets `e.Channel == AuditChannel.X` translate to
// `[Category] = 'X'` server-side.
builder.Property(e => e.Channel)
.HasColumnName("Category")
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.Target)
.HasMaxLength(256)
.IsUnicode(false);
.HasMaxLength(256);
// SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the
// cluster member that produced the row (e.g. "node-a", "central-a"). NULL is
// valid for reconciled rows from a retired node and for direct-write rows
// produced before this feature shipped. ASCII — varchar(64), no unicode.
builder.Property(e => e.SourceNode)
.HasColumnType("varchar(64)")
.HasMaxLength(64)
.IsUnicode(false);
// Bounded unicode message column.
builder.Property(e => e.ErrorMessage)
.HasMaxLength(1024);
// DetailsJson: unbounded → nvarchar(max) on SQL Server, TEXT on SQLite.
// (No HasMaxLength / HasColumnType — let conventions pick per provider.)
// ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max).
// ── Persisted computed columns (read-only; derived from DetailsJson) ──
//
// Each is `… AS <expr> PERSISTED`; EF must never attempt to write them
// (ValueGeneratedOnAddOrUpdate + metadata-only mapping). The SQL strings
// here MUST match the migration's dbo.AuditLog_v2 DDL exactly so
// `dotnet ef migrations has-pending-model-changes` stays clean. The SQLite
// test context strips the computed-column SQL (JSON_VALUE is unknown to
// SQLite) so EnsureCreated still works.
// Indexes — names locked to alog.md §4 for reconciliation/migration discoverability.
builder.Property(e => e.Kind)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", stored: true)
.ValueGeneratedOnAddOrUpdate()
.IsRequired();
builder.Property(e => e.Status)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", stored: true)
.ValueGeneratedOnAddOrUpdate()
.IsRequired();
builder.Property(e => e.SourceSiteId)
.HasMaxLength(64)
.IsUnicode(false)
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", stored: true)
.ValueGeneratedOnAddOrUpdate();
builder.Property(e => e.ExecutionId)
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", stored: true)
.ValueGeneratedOnAddOrUpdate();
builder.Property(e => e.ParentExecutionId)
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", stored: true)
.ValueGeneratedOnAddOrUpdate();
// IngestedAtUtc rides in DetailsJson as an ISO-8601-with-offset string
// (always +00:00 — the codec normalises to UTC). SWITCHOFFSET(...,0)
// normalises any offset to UTC before the datetime2 cast, so the column
// is the UTC wall-clock regardless. The datetimeoffset cast / SWITCHOFFSET
// is NON-DETERMINISTic to SQL Server, so this computed column is NOT
// persisted (stored:false) — a PERSISTED non-deterministic column is
// rejected at CREATE. It is not indexed, so non-persistence costs nothing.
// Routed through the nullable UTC converter so the materialised value
// carries Kind=Utc.
builder.Property(e => e.IngestedAtUtc)
.HasColumnType("datetime2(7)")
.HasConversion(NullableUtcConverter)
.HasComputedColumnSql(
"CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))",
stored: false)
.ValueGeneratedOnAddOrUpdate();
// ── Keys + indexes ───────────────────────────────────────────────────
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
// separate (non-aligned) unique index is declared on EventId alone.
builder.HasKey(e => 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
// off the computed columns.
builder.HasIndex(e => e.OccurredAtUtc)
.IsDescending(true)
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
@@ -129,22 +188,22 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
.HasFilter("[CorrelationId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_CorrelationId");
// ExecutionId / ParentExecutionId are persisted computed columns: SQL Server
// forbids a filtered-index WHERE predicate referencing a computed column, so
// these two indexes are UNFILTERED (they also index NULL rows; equality
// lookups are unaffected). Keep HasFilter(null) so the model matches the
// migration DDL exactly.
builder.HasIndex(e => e.ExecutionId)
.HasFilter("[ExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_Execution");
builder.HasIndex(e => e.ParentExecutionId)
.HasFilter("[ParentExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_ParentExecution");
// SourceNode composite index (Audit Log #23, SourceNode-stamping): backs
// per-node Central UI / health-dashboard queries (e.g. "rows produced by
// central-a, newest first"). Created via raw SQL in the migration so it lands
// on the ps_AuditLog_Month(OccurredAtUtc) partition scheme like every other
// IX_AuditLog_* index — keeps the partition-switch purge path intact.
builder.HasIndex(e => new { e.SourceNode, e.OccurredAtUtc })
.HasDatabaseName("IX_AuditLog_Node_Occurred");
// IX_AuditLog_Channel_Status_Occurred name preserved; columns are now the
// canonical Category (= channel) + computed Status + OccurredAtUtc.
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");