fix(correctness): close Theme 10 — 5 data-integrity / serialisation findings

Final themed batch. 5 well-localised correctness fixes.

Serialisation precision:
- ESG-020: DatabaseGateway.JsonElementToParameterValue probes
  TryGetInt64 → TryGetDecimal → GetDouble, so a script's high-precision
  decimal SQL parameter survives the cached-write retry round-trip
  without silent precision loss. 3 new regression tests.

Template engine correctness:
- TE-018: DiffService gains ComputeConnectionsDiff over
  FlattenedConfiguration.Connections, mirroring the existing entity-diff
  shape and pairing with the Theme 1 TE-017 hash-coverage fix. A
  ConfigurationDiff record extension in Commons is flagged as a follow-up.
- TE-019: TemplateResolver.BuildInheritanceChain now walks via the
  int? ParentTemplateId directly — only null means "no parent". A real
  Id of 0 (the prior special-cased sentinel) now walks the chain like
  any other node, matching the TemplateEngine-013 CycleDetector fix.
  Regression of TE-013 closed.
- TE-020: All 5 Create* paths in TemplateService + SharedScriptService
  re-ordered to save-first → log-with-real-Id → save-audit (matching
  the InstanceService pattern). Create* audit rows no longer carry a
  literal "0" EntityId.

Doc deferral:
- Transport-012: Component-Transport.md §Audit Trail now spells out that
  the BundleImportId repository filter IS wired (in CentralUiRepository),
  but the Audit-Log-Viewer UI dropdown + summary-row hyperlink are a
  deferred CentralUI follow-up. CLI workaround documented
  (audit query --bundle-import-id).

11+ new regression tests (3 ESG, 4 DiffService, 3 TemplateResolver, 4
TemplateService, 1 SharedScriptService). Build clean; ESG 72/72,
TemplateEngine 324/324. README regenerated: 1 pending of 481 total.

Session-to-date: 135 of 136 originally-open Theme findings closed
across 10 themes in 10 commits.
This commit is contained in:
Joseph Doherty
2026-05-28 08:48:44 -04:00
parent 77cb0ad0e2
commit 11950b0a8e
15 changed files with 620 additions and 42 deletions
@@ -201,10 +201,23 @@ public class DatabaseGateway : IDatabaseGateway
return true;
}
private static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
// ExternalSystemGateway-020: a JSON number that does not fit in Int64 must
// prefer decimal over double — a script's decimal SQL parameter is
// serialised as JSON without a type tag, and downcasting it to double on
// the cached-write retry path silently loses precision (e.g.
// 1234567890.1234567890 -> 1234567890.1234567 as a binary float). Probe
// long first (whole-number fast path), then decimal (preserves authored
// precision for typical money/measurement values), and only fall through
// to double for genuinely out-of-decimal-range values (very large
// scientific-notation floats).
internal static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
{
JsonValueKind.String => (object?)element.GetString() ?? DBNull.Value,
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
JsonValueKind.Number => element.TryGetInt64(out var l)
? l
: element.TryGetDecimal(out var dec)
? dec
: element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null or JsonValueKind.Undefined => DBNull.Value,
@@ -139,10 +139,7 @@ public class DiffService
/// Compares two <see cref="ConnectionConfig"/> instances for equality across
/// the fields that travel in the deployment package: protocol, primary and
/// backup configuration JSON, and failover retry count. Used by callers that
/// need to detect connection-endpoint drift; the public diff shape only
/// exposes attribute / alarm / script changes today (see TemplateEngine-018
/// for the diff-shape extension that surfaces added / removed / changed
/// connections in the UI).
/// need to detect connection-endpoint drift.
/// </summary>
/// <param name="a">First connection configuration.</param>
/// <param name="b">Second connection configuration.</param>
@@ -157,4 +154,52 @@ public class DiffService
a.BackupConfigurationJson == b.BackupConfigurationJson &&
a.FailoverRetryCount == b.FailoverRetryCount;
}
/// <summary>
/// TemplateEngine-018: produces a per-connection diff between two flattened
/// configurations, emitting Added / Removed / Changed entries keyed by the
/// connection name. Mirrors the existing <see cref="ComputeEntityDiff{T}"/>
/// shape used for attributes / alarms / scripts but is exposed as a separate
/// method because <see cref="ConfigurationDiff"/> in
/// <c>ScadaLink.Commons</c> does not yet carry a <c>ConnectionChanges</c>
/// slot — the public diff record will be extended in a paired Commons change
/// (this file is the only one in this fix's scope). A null
/// <c>Connections</c> dictionary on either side is treated as the empty map.
/// </summary>
/// <param name="oldConfig">The previously deployed configuration, or null
/// for first-time deployment.</param>
/// <param name="newConfig">The current flattened configuration.</param>
/// <returns>Added / Removed / Changed entries keyed by connection name,
/// sorted ordinally by <see cref="DiffEntry{T}.CanonicalName"/>.</returns>
public IReadOnlyList<DiffEntry<ConnectionConfig>> ComputeConnectionsDiff(
FlattenedConfiguration? oldConfig,
FlattenedConfiguration newConfig)
{
ArgumentNullException.ThrowIfNull(newConfig);
// Project both sides to (name, config) pairs. A null Connections
// dictionary models the no-connections case (first deploy, or all
// bindings cleared) — treat it as empty so the diff still reports the
// counterpart side's entries as Added / Removed rather than throwing.
var oldPairs = (oldConfig?.Connections ?? new Dictionary<string, ConnectionConfig>())
.Select(kvp => (kvp.Key, kvp.Value))
.ToList();
var newPairs = (newConfig.Connections ?? new Dictionary<string, ConnectionConfig>())
.Select(kvp => (kvp.Key, kvp.Value))
.ToList();
return ComputeEntityDiff(
oldPairs,
newPairs,
pair => pair.Key,
(a, b) => ConnectionsEqual(a.Value, b.Value))
.Select(entry => new DiffEntry<ConnectionConfig>
{
CanonicalName = entry.CanonicalName,
ChangeType = entry.ChangeType,
OldValue = entry.OldValue.Value,
NewValue = entry.NewValue.Value,
})
.ToList();
}
}
@@ -67,8 +67,14 @@ public class SharedScriptService
ReturnDefinition = returnDefinition
};
// TemplateEngine-020: save the entity first so EF Core populates the
// auto-generated key, then write the audit row with the real
// script.Id, then save the audit row. The pre-fix order logged
// EntityId = "0" because the audit row was queued before
// SaveChangesAsync ran.
await _repository.AddSharedScriptAsync(script, cancellationToken);
await _auditService.LogAsync(user, "Create", "SharedScript", "0", name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "SharedScript", script.Id.ToString(), name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<SharedScript>.Success(script);
@@ -103,6 +103,16 @@ public static class TemplateResolver
/// <summary>
/// Gets the inheritance chain from root ancestor to the specified template.
/// </summary>
/// <remarks>
/// TemplateEngine-019: the parent walk uses the <see cref="int?"/>
/// <see cref="Template.ParentTemplateId"/> directly — only a missing
/// (<c>null</c>) value means "no parent". The legacy <c>0</c>-as-no-parent
/// sentinel that was removed from <c>CycleDetector</c> in the
/// TemplateEngine-013 fix had silently truncated chains whenever a real
/// template id of 0 appeared (e.g. import-staging / not-yet-saved rows);
/// the duplicate-tolerant <c>BuildLookup</c> upstream means an Id of 0 is
/// a valid node here and must walk the chain like any other.
/// </remarks>
/// <param name="templateId">The template ID to build the chain for.</param>
/// <param name="lookup">A dictionary mapping template IDs to templates.</param>
/// <returns>A read-only list of templates from root to the specified template.</returns>
@@ -111,16 +121,16 @@ public static class TemplateResolver
IReadOnlyDictionary<int, Template> lookup)
{
var chain = new List<Template>();
var currentId = templateId;
int? currentId = templateId;
var visited = new HashSet<int>();
while (currentId != 0 && lookup.TryGetValue(currentId, out var current))
while (currentId.HasValue && lookup.TryGetValue(currentId.Value, out var current))
{
if (!visited.Add(currentId))
if (!visited.Add(currentId.Value))
break; // Safety: cycle detected
chain.Add(current);
currentId = current.ParentTemplateId ?? 0;
currentId = current.ParentTemplateId;
}
chain.Reverse(); // Root first
@@ -73,8 +73,15 @@ public class TemplateService
// collisions are enforced on every member-mutating call (AddAttribute,
// AddAlarm, AddScript, AddComposition) and on rename in UpdateTemplate.
// TemplateEngine-020: save the entity first so EF Core populates the
// auto-generated key, then write the audit row with the real
// template.Id, then save the audit row. The pre-fix order logged
// EntityId = "0" because the audit row was queued before
// SaveChangesAsync ran — every Create audit entry lost the link back
// to the row it described.
await _repository.AddTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Create", "Template", "0", name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "Template", template.Id.ToString(), name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<Template>.Success(template);
@@ -277,8 +284,11 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real attribute Id rather than a literal "0".
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAttribute", "0", attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAttribute>.Success(attribute);
@@ -441,8 +451,11 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real alarm Id rather than a literal "0".
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAlarm", "0", alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAlarm>.Success(alarm);
@@ -599,8 +612,11 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateScript>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real script Id rather than a literal "0".
await _repository.AddTemplateScriptAsync(script, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateScript", "0", script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateScript>.Success(script);
@@ -787,8 +803,12 @@ public class TemplateService
// No global name pre-check: derived templates store their contained
// (slot) name, which need only be unique within the owner — and that is
// already enforced above and by the (TemplateId, InstanceName) index.
// TemplateEngine-020: CreateCascadedCompositionAsync already saves the
// composition row, so composition.Id is populated by the time control
// returns here — the audit row therefore carries the real Id instead
// of the pre-fix literal "0".
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", composition.Id.ToString(), instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateComposition>.Success(composition);