refactor(transport): single-source template child equality — diff and overwrite-sync can no longer drift

New TemplateChildEquality is the one place "has this template child changed?" is
decided, comparing the COMPLETE writable field set per entity (attributes now incl.
ElementDataType + LockedInDerived; alarms incl. the on-trigger script by name +
LockedInDerived; scripts incl. MinTimeBetweenRuns/ExecutionTimeoutSeconds +
LockedInDerived; native sources per T5). ArtifactDiff.CompareTemplate and all four
BundleImporter.SyncTemplate*Async predicates now route through it — closing the
class of bug where Preview reported Identical for an artifact an Overwrite would
mutate. The attribute sync passes a value-normalised DTO so List-value re-imports
stay idempotent; the alarm on-trigger change now surfaces in both diff and sync.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 17:17:18 -04:00
parent 33cf577718
commit 38edfefc42
4 changed files with 228 additions and 73 deletions
@@ -1645,15 +1645,12 @@ public sealed class BundleImporter : IBundleImporter
attrDto.Value, attrDto.DataType, attrDto.ElementDataType, _logger, attrDto.Name);
if (existingByName.TryGetValue(attrDto.Name, out var current))
{
// Update only if any field actually changed.
bool changed =
!string.Equals(current.Value, normalizedValue, StringComparison.Ordinal) ||
current.DataType != attrDto.DataType ||
current.IsLocked != attrDto.IsLocked ||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
!string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal) ||
current.ElementDataType != attrDto.ElementDataType ||
current.LockedInDerived != attrDto.LockedInDerived;
// Update only if any field actually changed — single-sourced through
// TemplateChildEquality (#05-T6). Compare against the DTO with its
// List value already normalised so an idempotent re-import of an
// old-form bundle doesn't spuriously report a Value change.
bool changed = !TemplateChildEquality.AttributesEqual(
current, attrDto with { Value = normalizedValue });
if (!changed) continue;
current.Value = normalizedValue;
@@ -1738,6 +1735,13 @@ public sealed class BundleImporter : IBundleImporter
var existingByName = ex.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
var dtoByName = dto.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
// On-trigger script is referenced by name in the bundle; resolve the
// persisted FK back to a name over this template's scripts so the shared
// equality can compare it (#05-T6). Scripts are synced after alarms, so
// this reflects the pre-sync (current) script set — which is exactly the
// set current.OnTriggerScriptId points into.
var scriptNameById = TemplateChildEquality.ScriptNameResolver(ex.Scripts);
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
@@ -1757,13 +1761,9 @@ public sealed class BundleImporter : IBundleImporter
{
if (existingByName.TryGetValue(alarmDto.Name, out var current))
{
bool changed =
!string.Equals(current.Description, alarmDto.Description, StringComparison.Ordinal) ||
current.PriorityLevel != alarmDto.PriorityLevel ||
current.TriggerType != alarmDto.TriggerType ||
!string.Equals(current.TriggerConfiguration, alarmDto.TriggerConfiguration, StringComparison.Ordinal) ||
current.IsLocked != alarmDto.IsLocked ||
current.LockedInDerived != alarmDto.LockedInDerived;
// Single-sourced through TemplateChildEquality (#05-T6) — includes
// the on-trigger script binding, which the diff also compares.
bool changed = !TemplateChildEquality.AlarmsEqual(current, alarmDto, scriptNameById);
if (!changed)
{
// Always reset the script FK on Overwrite so the post-flush
@@ -1877,16 +1877,8 @@ public sealed class BundleImporter : IBundleImporter
{
if (existingByName.TryGetValue(scriptDto.Name, out var current))
{
bool changed =
!string.Equals(current.Code, scriptDto.Code, StringComparison.Ordinal) ||
!string.Equals(current.TriggerType, scriptDto.TriggerType, StringComparison.Ordinal) ||
!string.Equals(current.TriggerConfiguration, scriptDto.TriggerConfiguration, StringComparison.Ordinal) ||
!string.Equals(current.ParameterDefinitions, scriptDto.ParameterDefinitions, StringComparison.Ordinal) ||
!string.Equals(current.ReturnDefinition, scriptDto.ReturnDefinition, StringComparison.Ordinal) ||
current.IsLocked != scriptDto.IsLocked ||
current.MinTimeBetweenRuns != scriptDto.MinTimeBetweenRuns ||
current.ExecutionTimeoutSeconds != scriptDto.ExecutionTimeoutSeconds ||
current.LockedInDerived != scriptDto.LockedInDerived;
// Single-sourced through TemplateChildEquality (#05-T6).
bool changed = !TemplateChildEquality.ScriptsEqual(current, scriptDto);
if (!changed) continue;
current.Code = scriptDto.Code;
@@ -1993,14 +1985,8 @@ public sealed class BundleImporter : IBundleImporter
{
if (existingByName.TryGetValue(srcDto.Name, out var current))
{
bool changed =
!string.Equals(current.Description, srcDto.Description, StringComparison.Ordinal) ||
!string.Equals(current.ConnectionName, srcDto.ConnectionName, StringComparison.Ordinal) ||
!string.Equals(current.SourceReference, srcDto.SourceReference, StringComparison.Ordinal) ||
!string.Equals(current.ConditionFilter, srcDto.ConditionFilter, StringComparison.Ordinal) ||
current.IsLocked != srcDto.IsLocked ||
current.IsInherited != srcDto.IsInherited ||
current.LockedInDerived != srcDto.LockedInDerived;
// Single-sourced through TemplateChildEquality (#05-T6).
bool changed = !TemplateChildEquality.NativeAlarmSourcesEqual(current, srcDto);
if (!changed) continue;
current.Description = srcDto.Description;