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");
@@ -1,20 +1,41 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
/// <summary>
/// Transitional EF Core persistence shape for the central <c>dbo.AuditLog</c> table
/// (Audit Log #23). This is the 24-column row formerly modelled by
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent</c>; in C3 (Task 2.5)
/// the canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> became the type at every seam,
/// emit site, DTO boundary, and redactor, and this row type was relocated here as a
/// storage-only entity so the existing table keeps working unchanged.
/// EF Core persistence shape for the central <c>dbo.AuditLog</c> table after the
/// C5 collapse (Audit Log #23, Task 2.5). The table is now the 10 canonical
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> fields stored DIRECTLY plus a set of
/// read-only, server-side <b>persisted computed columns</b> derived from
/// <see cref="DetailsJson"/> (<c>JSON_VALUE</c> … <c>PERSISTED</c>) so the
/// reporting queries stay indexable without re-parsing JSON.
/// </summary>
/// <remarks>
/// <para>
/// The repository maps canonical ⇄ this row at the persistence boundary via
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.AuditRowProjection</c>. C5 replaces
/// this shim + table with the real DetailsJson-backed schema.
/// <b>C5 (Task 2.5).</b> The transitional 24-typed-column shim is retired. The
/// repository writes the 10 canonical columns directly (no <c>Decompose</c>) and
/// the computed columns auto-derive at INSERT; reads build the canonical
/// <c>AuditEvent</c> straight off the canonical columns (no <c>Recompose</c>).
/// </para>
/// <para>
/// <b>Canonical columns (writable):</b> <see cref="EventId"/>,
/// <see cref="OccurredAtUtc"/>, <see cref="Actor"/>, <see cref="Action"/>,
/// <see cref="Outcome"/>, <see cref="Channel"/> (the canonical <c>Category</c>
/// column — for ScadaBridge, Category = channel name), <see cref="Target"/>,
/// <see cref="SourceNode"/>, <see cref="CorrelationId"/>,
/// <see cref="DetailsJson"/>.
/// </para>
/// <para>
/// <b>Persisted computed columns (read-only):</b> <see cref="Kind"/>,
/// <see cref="Status"/>, <see cref="SourceSiteId"/>, <see cref="ExecutionId"/>,
/// <see cref="ParentExecutionId"/> (the five spec'd queryability columns), plus
/// <see cref="IngestedAtUtc"/> (central ingest timestamp, also a DetailsJson
/// field). These are populated by SQL Server from <see cref="DetailsJson"/>; EF
/// never writes them. Their getters expose them as typed
/// (enum / <see cref="Guid"/> / <see cref="DateTime"/>) properties so the
/// existing LINQ filter/aggregate queries keep their meaning; the value
/// converters that turn enum names ⇄ varchar match the JSON_VALUE string output.
/// </para>
/// <para>
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties are invariantly UTC
@@ -27,6 +48,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
/// </remarks>
public sealed record AuditLogRow
{
// ── Canonical columns (the 10 ZB.MOM.WW.Audit.AuditEvent fields) ──────────
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
public Guid EventId { get; init; }
@@ -38,7 +61,75 @@ public sealed record AuditLogRow
}
private readonly DateTime _occurredAtUtc;
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.); null/empty for system/anon.</summary>
public string? Actor { get; init; }
/// <summary>Canonical action verb — <c>"{channel}.{kind}"</c> (e.g. <c>ApiOutbound.ApiCall</c>).</summary>
public string Action { get; init; } = string.Empty;
/// <summary>Normalized canonical outcome (Success / Failure / Denied).</summary>
public AuditOutcome Outcome { get; init; }
/// <summary>
/// Trust-boundary channel the audited action crossed. Stored in the canonical
/// <c>Category</c> column (for ScadaBridge the canonical Category IS the channel
/// name); exposed here as the strongly-typed <see cref="AuditChannel"/> enum.
/// </summary>
public AuditChannel Channel { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>The cluster node on which the event was emitted.</summary>
public string? SourceNode { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Canonical JSON extension bag carrying every ScadaBridge domain field.</summary>
public string? DetailsJson { get; init; }
// ── Persisted computed columns (read-only; derived from DetailsJson) ──────
/// <summary>
/// Specific event kind. Computed column <c>JSON_VALUE(DetailsJson,'$.kind')</c>
/// PERSISTED; read-only (the DB derives it on INSERT).
/// </summary>
public AuditKind Kind { get; init; }
/// <summary>
/// Lifecycle status. Computed column <c>JSON_VALUE(DetailsJson,'$.status')</c>
/// PERSISTED; read-only.
/// </summary>
public AuditStatus Status { get; init; }
/// <summary>
/// Site id where the action originated; null for central-direct events. Computed
/// column <c>JSON_VALUE(DetailsJson,'$.sourceSiteId')</c> PERSISTED; read-only.
/// </summary>
public string? SourceSiteId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request. Computed column
/// <c>CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)</c>
/// PERSISTED; read-only.
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>
/// ExecutionId of the execution that spawned this run; null for top-level runs.
/// Computed column
/// <c>CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)</c>
/// PERSISTED; read-only.
/// </summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>
/// UTC timestamp when the central AuditLog store ingested this row; null until
/// central stamps it. Computed column over
/// <c>JSON_VALUE(DetailsJson,'$.ingestedAtUtc')</c> (normalized to UTC datetime2)
/// PERSISTED; read-only.
/// </summary>
public DateTime? IngestedAtUtc
{
get => _ingestedAtUtc;
@@ -47,67 +138,4 @@ public sealed record AuditLogRow
: null;
}
private readonly DateTime? _ingestedAtUtc;
/// <summary>Trust-boundary channel the audited action crossed.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind within the channel.</summary>
public AuditKind Kind { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Id of the originating script execution / inbound request.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>ExecutionId of the execution that spawned this run; null for top-level runs.</summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>The cluster node on which the event was emitted.</summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action, when applicable.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
public string? Actor { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status of this row.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable.</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary on failure rows.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; null on central rows.</summary>
public AuditForwardState? ForwardState { get; init; }
}
@@ -0,0 +1,244 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <summary>
/// C5 of the ScadaBridge audit re-architecture (Task 2.5): collapses the central
/// <c>dbo.AuditLog</c> table from the transitional 24 typed columns to the 10
/// canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> columns plus six read-only,
/// server-side <b>persisted computed columns</b> derived from <c>DetailsJson</c>
/// (<c>JSON_VALUE</c> … <c>PERSISTED</c>) that back the indexed reporting queries.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why new-table + copy (not in-place ALTER).</b> An in-place collapse is
/// infeasible: the partition-aligned indexes are keyed on columns that are being
/// dropped; the purge path (<c>SwitchOutPartitionAsync</c>) hard-codes a
/// byte-identical staging column list; and dropping <c>nvarchar(max)</c> columns
/// per partition is expensive. Instead this migration builds a fresh
/// <c>dbo.AuditLog_v2</c> on the SAME preserved partition scheme
/// (<c>ps_AuditLog_Month(OccurredAtUtc)</c>), copies every row with a one-way
/// projection of the old typed columns into canonical columns + <c>DetailsJson</c>,
/// drops the old table, renames v2 into place, and re-grants the append-only roles.
/// </para>
/// <para>
/// <b>Down() is a documented ONE-WAY.</b> The projection of ~17 typed columns into
/// a single JSON bag is lossy to reverse byte-for-byte (e.g. the codec's
/// null-omission + key order, the Action/Category/Outcome derivation), so the
/// reverse is NOT implemented. <see cref="Down"/> throws
/// <see cref="System.NotSupportedException"/> with guidance to restore from backup.
/// </para>
/// <para>
/// <b>Partition / SWITCH / computed-column interaction.</b> The new table and its
/// non-clustered indexes are created directly on <c>ps_AuditLog_Month</c> so the
/// partition-switch purge keeps working; the non-aligned <c>UX_AuditLog_EventId</c>
/// stays on <c>[PRIMARY]</c> exactly as before. The persisted computed columns are
/// part of the table's storage, so the staging table used by
/// <c>SwitchOutPartitionAsync</c> MUST declare them with identical expressions —
/// see that method (kept in sync with the v2 DDL below).
/// </para>
/// </remarks>
public partial class CollapseAuditLogToCanonical : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Create dbo.AuditLog_v2 on the PRESERVED partition scheme. Ten
// canonical columns first (ordinal-stable), then the six persisted
// computed columns. The clustered PK is composite {EventId,
// OccurredAtUtc} (partition-aligned). The computed-column expressions
// here are the single source of truth that AuditLogEntityTypeConfiguration
// and SwitchOutPartitionAsync's staging table must mirror exactly.
migrationBuilder.Sql(@"
CREATE TABLE dbo.AuditLog_v2 (
EventId uniqueidentifier NOT NULL,
OccurredAtUtc datetime2(7) NOT NULL,
Actor nvarchar(256) NULL,
Action varchar(64) NOT NULL,
Outcome varchar(16) NOT NULL,
Category varchar(32) NOT NULL,
Target nvarchar(256) NULL,
SourceNode varchar(64) NULL,
CorrelationId uniqueidentifier NULL,
DetailsJson nvarchar(max) NULL,
Kind AS JSON_VALUE(DetailsJson,'$.kind') PERSISTED,
Status AS JSON_VALUE(DetailsJson,'$.status') PERSISTED,
SourceSiteId AS JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED,
ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED,
-- IngestedAtUtc is NOT persisted: the datetimeoffset cast / SWITCHOFFSET is
-- non-deterministic and SQL Server rejects a PERSISTED non-deterministic
-- computed column. It is not indexed, so non-persistence costs nothing.
IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)),
CONSTRAINT PK_AuditLog_v2 PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
ON ps_AuditLog_Month(OccurredAtUtc)
) ON ps_AuditLog_Month(OccurredAtUtc);");
// 2) Data copy: project the old 24-column rows into the canonical shape.
// - Action = Channel + '.' + Kind (matches AuditFieldBuilders.BuildAction)
// - Category = Channel (matches AuditFieldBuilders.BuildCategory)
// - Outcome = AuditOutcomeProjector.Project(Status, Kind):
// Kind='InboundAuthFailure' -> 'Denied' (wins over any status);
// Status in ('Failed','Parked','Discarded') -> 'Failure'; else 'Success'.
// - Actor : empty string maps to NULL (canonical Actor is non-null,
// but the old column stored NULL for system/anon — keep NULL).
// - DetailsJson: every domain field re-serialised as a single JSON object
// with camelCase keys matching AuditDetailsCodec. FOR JSON
// PATH, WITHOUT_ARRAY_WRAPPER OMITS null keys by default
// (no INCLUDE_NULL_VALUES), matching the codec's
// JsonIgnoreCondition.WhenWritingNull; channel/kind/status
// are NOT NULL in the legacy table so they are always present
// and emit the enum-name strings the computed Kind/Status
// (and Channel-as-Category) derive from. payloadTruncated is a
// non-null bit so it is always written, matching the codec
// (which always writes the bool).
// The six computed columns auto-derive from DetailsJson on INSERT.
migrationBuilder.Sql(@"
INSERT INTO dbo.AuditLog_v2
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
SELECT
a.EventId,
a.OccurredAtUtc,
NULLIF(a.Actor, '') AS Actor,
a.Channel + '.' + a.Kind AS Action,
CASE
WHEN a.Kind = 'InboundAuthFailure' THEN 'Denied'
WHEN a.Status IN ('Failed','Parked','Discarded') THEN 'Failure'
ELSE 'Success'
END AS Outcome,
a.Channel AS Category,
a.Target,
a.SourceNode,
a.CorrelationId,
(
SELECT
a.Channel AS channel,
a.Kind AS kind,
a.Status AS status,
a.ExecutionId AS executionId,
a.ParentExecutionId AS parentExecutionId,
a.SourceSiteId AS sourceSiteId,
a.SourceInstanceId AS sourceInstanceId,
a.SourceScript AS sourceScript,
a.HttpStatus AS httpStatus,
a.DurationMs AS durationMs,
a.ErrorMessage AS errorMessage,
a.ErrorDetail AS errorDetail,
a.RequestSummary AS requestSummary,
a.ResponseSummary AS responseSummary,
CAST(a.PayloadTruncated AS bit) AS payloadTruncated,
a.Extra AS extra,
CASE WHEN a.IngestedAtUtc IS NULL THEN NULL
ELSE CONVERT(varchar(33),
CAST(a.IngestedAtUtc AS datetimeoffset(7)) AT TIME ZONE 'UTC',
126)
END AS ingestedAtUtc
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
) AS DetailsJson
FROM dbo.AuditLog a;");
// 3) Drop the old table and rename v2 into place. The table rename uses
// the 'schema.table' form; the PK constraint rename uses the BARE
// constraint name with @objtype='OBJECT' (a constraint is a schema-
// scoped object, NOT table-qualified — a 'dbo.AuditLog.PK_…' form is
// rejected as ambiguous). The non-clustered indexes were dropped with
// the old table; they are recreated by name in step 4 (no index rename
// needed because the v2 table was created without them).
migrationBuilder.Sql(@"
DROP TABLE dbo.AuditLog;
EXEC sp_rename 'dbo.AuditLog_v2', 'AuditLog';
EXEC sp_rename 'PK_AuditLog_v2', 'PK_AuditLog', 'OBJECT';");
// 4) Recreate the reconciliation/query indexes on the new shape, names
// preserved (alog.md §4 semantics): Channel→Category, Site/Node read
// off the canonical/computed columns. All non-clustered indexes are
// partition-aligned on ps_AuditLog_Month(OccurredAtUtc) so the
// partition-switch purge keeps touching a single partition. The
// UX_AuditLog_EventId unique index is INTENTIONALLY non-aligned (on
// [PRIMARY]) to give single-column EventId uniqueness for
// InsertIfNotExistsAsync idempotency.
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
ON dbo.AuditLog (OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred
ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId
ON dbo.AuditLog (CorrelationId)
WHERE CorrelationId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred
ON dbo.AuditLog (Category ASC, Status ASC, OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred
ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC)
WHERE Target IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);
-- IX_AuditLog_Execution / IX_AuditLog_ParentExecution are NOT filtered: SQL
-- Server forbids a filtered-index WHERE predicate from referencing a computed
-- column (ExecutionId / ParentExecutionId are persisted computed columns). An
-- unfiltered index still backs the equality lookups GetExecutionTreeAsync uses;
-- it just also indexes the NULL rows. (The pre-C5 typed columns allowed the
-- IS NOT NULL filter; the computed-column constraint does not.)
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
ON dbo.AuditLog (ExecutionId)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution
ON dbo.AuditLog (ParentExecutionId)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred
ON dbo.AuditLog (SourceNode ASC, OccurredAtUtc ASC)
ON ps_AuditLog_Month(OccurredAtUtc);
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId
ON dbo.AuditLog (EventId)
ON [PRIMARY];");
// 5) Re-grant the append-only roles on the renamed table. The grants were
// object-scoped to the old (now-dropped) table, so they must be re-issued
// against the new one. Idempotent role creation guards a fresh DB. The
// DENY UPDATE / DENY DELETE on the writer role is deliberate — a future
// db_datawriter membership cannot quietly re-enable mutation (DENY > GRANT).
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_writer') IS NULL
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_writer';
GRANT INSERT ON dbo.AuditLog TO scadabridge_audit_writer;
GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_writer;
DENY UPDATE ON dbo.AuditLog TO scadabridge_audit_writer;
DENY DELETE ON dbo.AuditLog TO scadabridge_audit_writer;
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NULL
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_purger';
GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_purger;
GRANT ALTER ON SCHEMA::dbo TO scadabridge_audit_purger;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// ONE-WAY MIGRATION. Collapsing the 24 typed columns into the canonical
// shape + a single DetailsJson bag is lossy to reverse byte-for-byte: the
// Action/Category/Outcome derivation discards the original Channel/Kind/
// Status split in a way only DetailsJson can reconstruct, and the codec's
// null-omission + key-order contract cannot be reproduced by a generic
// reverse projection. Reversing in place would also have to rebuild the
// partition-aligned indexes on the dropped typed columns. If a rollback is
// required, restore the central database from a pre-migration backup.
throw new System.NotSupportedException(
"CollapseAuditLogToCanonical is a one-way migration (Task 2.5 C5): the " +
"central dbo.AuditLog collapse projects the legacy typed columns into a " +
"lossy canonical/DetailsJson shape that cannot be reversed automatically. " +
"Restore the database from a pre-migration backup to roll back.");
}
}
}
@@ -41,145 +41,6 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
{
b.Property<Guid>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("OccurredAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Actor")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("DurationMs")
.HasColumnType("int");
b.Property<string>("ErrorDetail")
.HasColumnType("nvarchar(max)");
b.Property<string>("ErrorMessage")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<Guid?>("ExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Extra")
.HasColumnType("nvarchar(max)");
b.Property<string>("ForwardState")
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<int?>("HttpStatus")
.HasColumnType("int");
b.Property<DateTime?>("IngestedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<Guid?>("ParentExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("PayloadTruncated")
.HasColumnType("bit");
b.Property<string>("RequestSummary")
.HasColumnType("nvarchar(max)");
b.Property<string>("ResponseSummary")
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceInstanceId")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("SourceNode")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("SourceScript")
.HasMaxLength(128)
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("SourceSiteId")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<string>("Target")
.HasMaxLength(256)
.IsUnicode(false)
.HasColumnType("varchar(256)");
b.HasKey("EventId", "OccurredAtUtc");
b.HasIndex("CorrelationId")
.HasDatabaseName("IX_AuditLog_CorrelationId")
.HasFilter("[CorrelationId] IS NOT NULL");
b.HasIndex("EventId")
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("ExecutionId")
.HasDatabaseName("IX_AuditLog_Execution")
.HasFilter("[ExecutionId] IS NOT NULL");
b.HasIndex("OccurredAtUtc")
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
b.HasIndex("ParentExecutionId")
.HasDatabaseName("IX_AuditLog_ParentExecution")
.HasFilter("[ParentExecutionId] IS NOT NULL");
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.Audit.AuditLogEntry", b =>
{
b.Property<int>("Id")
@@ -1490,6 +1351,129 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("TemplateScripts");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
{
b.Property<Guid>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("OccurredAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Actor")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)")
.HasColumnName("Category");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DetailsJson")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("ExecutionId")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("uniqueidentifier")
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true);
b.Property<DateTime?>("IngestedAtUtc")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("datetime2(7)")
.HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false);
b.Property<string>("Kind")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)")
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true);
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(16)
.IsUnicode(false)
.HasColumnType("varchar(16)");
b.Property<Guid?>("ParentExecutionId")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("uniqueidentifier")
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true);
b.Property<string>("SourceNode")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("SourceSiteId")
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)")
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true);
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(32)
.IsUnicode(false)
.HasColumnType("varchar(32)")
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true);
b.Property<string>("Target")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("EventId", "OccurredAtUtc");
b.HasIndex("CorrelationId")
.HasDatabaseName("IX_AuditLog_CorrelationId")
.HasFilter("[CorrelationId] IS NOT NULL");
b.HasIndex("EventId")
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("ExecutionId")
.HasDatabaseName("IX_AuditLog_Execution");
b.HasIndex("OccurredAtUtc")
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
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)
@@ -46,36 +46,35 @@ public class AuditLogRepository : IAuditLogRepository
throw new ArgumentNullException(nameof(evt));
}
// C3 transitional shim: the canonical record carries the ScadaBridge domain
// fields inside DetailsJson — decompose it into the typed 24-column values the
// existing dbo.AuditLog table expects. Central rows leave ForwardState null
// (it is a site-storage-only concern, never on a central row).
var r = AuditRowProjection.Decompose(evt);
// C5 (Task 2.5): write the 10 canonical columns DIRECTLY — no Decompose.
// The five queryability columns (Kind/Status/SourceSiteId/ExecutionId/
// ParentExecutionId) plus IngestedAtUtc are PERSISTED computed columns on
// dbo.AuditLog; SQL Server derives them from DetailsJson at INSERT, so they
// are intentionally absent from this column list (writing a computed column
// is an error). The canonical OccurredAtUtc is UTC by construction; store a
// Kind=Utc DateTime so downstream UTC/local conversions are safe.
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
// the conversion in C# rather than relying on parameter type inference —
// SqlClient would otherwise bind enums as int by default.
var channel = r.Channel.ToString();
var kind = r.Kind.ToString();
var status = r.Status.ToString();
string? forwardState = null;
// Canonical Actor is a required non-null string; an empty Actor maps to a
// NULL column (legacy/central rows stored null for system/anon).
string? actor = string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor;
// Outcome / Category are varchar columns (Outcome via HasConversion<string>;
// Category carries the channel name). Bind as strings rather than relying on
// parameter type inference.
var outcome = evt.Outcome.ToString();
string? category = evt.Category;
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
try
{
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {r.EventId})
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
VALUES
({r.EventId}, {r.OccurredAtUtc}, {r.IngestedAtUtc}, {channel}, {kind}, {r.CorrelationId}, {r.ExecutionId}, {r.ParentExecutionId},
{r.SourceSiteId}, {r.SourceNode}, {r.SourceInstanceId}, {r.SourceScript}, {r.Actor}, {r.Target}, {status},
{r.HttpStatus}, {r.DurationMs}, {r.ErrorMessage}, {r.ErrorDetail}, {r.RequestSummary},
{r.ResponseSummary}, {r.PayloadTruncated}, {r.Extra}, {forwardState});",
({evt.EventId}, {occurred}, {actor}, {evt.Action}, {outcome}, {category}, {evt.Target}, {evt.SourceNode}, {evt.CorrelationId}, {evt.DetailsJson});",
ct);
}
catch (SqlException ex) when (
@@ -92,7 +91,7 @@ VALUES
ex,
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
ex.Number,
r.EventId);
evt.EventId);
}
}
@@ -110,9 +109,11 @@ VALUES
throw new ArgumentNullException(nameof(paging));
}
// C3 transitional shim: the typed-column filter predicates query the
// AuditLogRow persistence shape as before (C6 retargets how the filter is
// applied); the materialized rows are recomposed into canonical records.
// C5 (Task 2.5): the filter predicates bind to the canonical columns and the
// persisted computed columns directly — Channel→Category, Kind/Status/
// SourceSiteId/ExecutionId/ParentExecutionId are computed columns. The
// materialized rows are projected to the canonical record by reading the 10
// canonical columns (no 24-column Recompose).
var query = _context.Set<AuditLogRow>().AsNoTracking();
// Multi-value dimensions: a null OR empty list means "no constraint"
@@ -201,36 +202,29 @@ VALUES
}
/// <summary>
/// C3 transitional shim: recompose a canonical <see cref="AuditEvent"/> from a
/// materialized <see cref="AuditLogRow"/> read back from <c>dbo.AuditLog</c>.
/// <c>ForwardState</c> is dropped (central rows never carry it; it is not a
/// canonical / DetailsJson field).
/// C5 (Task 2.5): build the canonical <see cref="AuditEvent"/> DIRECTLY from the
/// 10 canonical columns of a materialized <see cref="AuditLogRow"/> read back from
/// <c>dbo.AuditLog</c> — no 24-column <c>Recompose</c>, because the table now holds
/// the canonical shape (every ScadaBridge domain field already lives in
/// <c>DetailsJson</c>). The persisted computed columns are read helpers only and
/// are not part of the canonical record. <see cref="AuditLogRow.Channel"/> is the
/// canonical <c>Category</c> column (Category = channel name for ScadaBridge).
/// </summary>
private static AuditEvent RowToCanonical(AuditLogRow row)
=> AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
EventId: row.EventId,
OccurredAtUtc: row.OccurredAtUtc,
IngestedAtUtc: row.IngestedAtUtc,
Channel: row.Channel,
Kind: row.Kind,
Status: row.Status,
CorrelationId: row.CorrelationId,
ExecutionId: row.ExecutionId,
ParentExecutionId: row.ParentExecutionId,
SourceSiteId: row.SourceSiteId,
SourceNode: row.SourceNode,
SourceInstanceId: row.SourceInstanceId,
SourceScript: row.SourceScript,
Actor: row.Actor,
Target: row.Target,
HttpStatus: row.HttpStatus,
DurationMs: row.DurationMs,
ErrorMessage: row.ErrorMessage,
ErrorDetail: row.ErrorDetail,
RequestSummary: row.RequestSummary,
ResponseSummary: row.ResponseSummary,
PayloadTruncated: row.PayloadTruncated,
Extra: row.Extra));
=> new()
{
EventId = row.EventId,
OccurredAtUtc = new DateTimeOffset(
DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc)),
Actor = row.Actor ?? string.Empty,
Action = row.Action,
Outcome = row.Outcome,
Category = row.Channel.ToString(),
Target = row.Target,
SourceNode = row.SourceNode,
CorrelationId = row.CorrelationId,
DetailsJson = row.DetailsJson,
};
/// <inheritdoc />
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
@@ -270,38 +264,29 @@ VALUES
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
-- 2. Staging table on [PRIMARY] (non-partitioned) with column shapes
-- byte-identical to dbo.AuditLog. Any drift here causes SWITCH to
-- reject the operation with msg 4904/4915.
-- byte-identical to the C5 dbo.AuditLog — INCLUDING the persisted
-- computed columns, whose definitions must match EXACTLY (same
-- expression text + PERSISTED) or ALTER TABLE ... SWITCH PARTITION
-- rejects the operation with msg 4904/4948. The ordinal order also
-- matches dbo.AuditLog_v2 (the CollapseAuditLogToCanonical migration):
-- 10 canonical columns first, then the 6 computed columns.
CREATE TABLE dbo.[{stagingTableName}] (
EventId uniqueidentifier NOT NULL,
OccurredAtUtc datetime2(7) NOT NULL,
IngestedAtUtc datetime2(7) NULL,
Channel varchar(32) NOT NULL,
Kind varchar(32) NOT NULL,
CorrelationId uniqueidentifier NULL,
SourceSiteId varchar(64) NULL,
SourceInstanceId varchar(128) NULL,
SourceScript varchar(128) NULL,
Actor varchar(128) NULL,
Target varchar(256) NULL,
Status varchar(32) NOT NULL,
HttpStatus int NULL,
DurationMs int NULL,
ErrorMessage nvarchar(1024) NULL,
ErrorDetail nvarchar(max) NULL,
RequestSummary nvarchar(max) NULL,
ResponseSummary nvarchar(max) NULL,
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL,
-- ExecutionId, ParentExecutionId, and SourceNode are last (in this
-- ordinal order) because each was added to the live AuditLog table
-- by a later ALTER TABLE ADD migration; the staging table must
-- match the live table column shape ordinal-for-ordinal or
-- ALTER TABLE ... SWITCH PARTITION fails (msg 4904/4915).
ExecutionId uniqueidentifier NULL,
ParentExecutionId uniqueidentifier NULL,
Actor nvarchar(256) NULL,
Action varchar(64) NOT NULL,
Outcome varchar(16) NOT NULL,
Category varchar(32) NOT NULL,
Target nvarchar(256) NULL,
SourceNode varchar(64) NULL,
CorrelationId uniqueidentifier NULL,
DetailsJson nvarchar(max) NULL,
Kind AS JSON_VALUE(DetailsJson,'$.kind') PERSISTED,
Status AS JSON_VALUE(DetailsJson,'$.status') PERSISTED,
SourceSiteId AS JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED,
ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED,
IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)),
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY];
@@ -648,24 +633,28 @@ VALUES
SELECT ParentExecutionId FROM Chain
WHERE ParentExecutionId IS NOT NULL
)
-- ParentExecutionId / SourceSiteId / SourceInstanceId are
-- derived via MIN: every audit row of one execution carries
-- the SAME ParentExecutionId (and source identity) — it is
-- stamped once per script run / inbound request — so MIN
-- simply picks that one shared value, it is not collapsing a
-- genuine disagreement across rows.
-- C5 (Task 2.5): ExecutionId / ParentExecutionId / SourceSiteId
-- are persisted computed columns (same names); Channel is now the
-- canonical Category column (Category = channel name, so the
-- Channels aggregate still yields channel names); SourceInstanceId
-- is no longer a column — read it from DetailsJson via JSON_VALUE.
-- ParentExecutionId / SourceSiteId / SourceInstanceId are derived
-- via MIN: every audit row of one execution carries the SAME value
-- (stamped once per script run / inbound request) — MIN simply
-- picks that one shared value, not collapsing a genuine
-- disagreement across rows.
SELECT
ids.ExecutionId AS [ExecutionId],
MIN(a.ParentExecutionId) AS [ParentExecutionId],
COUNT(a.EventId) AS [RowCount],
(SELECT STRING_AGG(d.Channel, ',')
FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2
(SELECT STRING_AGG(d.Category, ',')
FROM (SELECT DISTINCT a2.Category FROM dbo.AuditLog a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
(SELECT STRING_AGG(d.Status, ',')
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
MIN(a.SourceSiteId) AS [SourceSiteId],
MIN(a.SourceInstanceId) AS [SourceInstanceId],
MIN(JSON_VALUE(a.DetailsJson,'$.sourceInstanceId')) AS [SourceInstanceId],
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
FROM ChainIds ids
@@ -154,6 +154,43 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaBridgeDbContext).Assembly);
ApplySecretColumnEncryption(modelBuilder);
NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(modelBuilder);
}
/// <summary>
/// C5 (Task 2.5): the central <c>dbo.AuditLog</c> persisted computed columns use
/// SQL Server's <c>JSON_VALUE</c> expression, which only SQL Server can evaluate.
/// On a non-SQL-Server provider (the SQLite test contexts) emitting that SQL in a
/// <c>CREATE TABLE</c> fails ("no such function: JSON_VALUE"). Strip the
/// computed-column SQL for any non-SQL-Server provider so those columns degrade to
/// plain (always-null on the test provider) nullable columns; SQL Server keeps the
/// real <c>… AS JSON_VALUE(...) PERSISTED</c> definitions. This is a model-shape
/// adaptation only — it never runs under the design-time SQL Server provider, so the
/// migration / model-snapshot remain the canonical SQL Server shape.
/// </summary>
private void NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(ModelBuilder modelBuilder)
{
// Database.IsSqlServer() reads the configured provider — true for production and
// for the design-time factory (UseSqlServer), false for the SQLite test contexts.
if (Database.IsSqlServer())
{
return;
}
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.GetComputedColumnSql() is not null)
{
property.SetComputedColumnSql(null);
property.SetColumnType(null);
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Save);
}
}
}
}
/// <inheritdoc />