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
@@ -64,16 +64,23 @@ public sealed class ArtifactDiff
AddIfDifferent(changes, "FolderName", FolderNameOf(existing), incoming.FolderName);
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName);
// Bundles reference the alarm on-trigger script by name; resolve the
// persisted FK back to a name over this template's own scripts so the
// shared equality can compare it. See TemplateChildEquality.
var scriptNameById = TemplateChildEquality.ScriptNameResolver(existing.Scripts);
// Children: compare each child collection by name. We track which names
// were added, which were removed, and which existed on both sides but
// diverged in body. We use coarse value equality / line counts for
// scripts so the diff JSON stays under a few KB per item.
// scripts so the diff JSON stays under a few KB per item. Change
// detection is single-sourced through TemplateChildEquality so the diff
// can never disagree with what an Overwrite sync writes (#05-T6).
DiffChildren(
existing.Attributes,
incoming.Attributes,
e => e.Name,
i => i.Name,
AttributesEqual,
TemplateChildEquality.AttributesEqual,
"Attributes",
changes);
@@ -82,7 +89,7 @@ public sealed class ArtifactDiff
incoming.Alarms,
e => e.Name,
i => i.Name,
AlarmsEqual,
(e, i) => TemplateChildEquality.AlarmsEqual(e, i, scriptNameById),
"Alarms",
changes);
@@ -106,7 +113,7 @@ public sealed class ArtifactDiff
incoming.NativeAlarmSources,
e => e.Name,
i => i.Name,
NativeAlarmSourcesEqual,
TemplateChildEquality.NativeAlarmSourcesEqual,
"NativeAlarmSources",
changes);
@@ -641,7 +648,7 @@ public sealed class ArtifactDiff
foreach (var (name, ex) in existingByName)
{
if (!incomingByName.TryGetValue(name, out var inc)) continue;
if (!ScriptsEqual(ex, inc))
if (!TemplateChildEquality.ScriptsEqual(ex, inc))
{
// A script row can diverge in Code and/or its trigger/param metadata.
// The structured line diff covers the Code body; when only the
@@ -652,37 +659,6 @@ public sealed class ArtifactDiff
}
}
private static bool AttributesEqual(TemplateAttribute e, TemplateAttributeDto i) =>
e.Value == i.Value
&& e.DataType == i.DataType
&& e.IsLocked == i.IsLocked
&& e.Description == i.Description
&& e.DataSourceReference == i.DataSourceReference;
private static bool AlarmsEqual(TemplateAlarm e, TemplateAlarmDto i) =>
e.Description == i.Description
&& e.PriorityLevel == i.PriorityLevel
&& e.TriggerType == i.TriggerType
&& e.TriggerConfiguration == i.TriggerConfiguration
&& e.IsLocked == i.IsLocked;
private static bool ScriptsEqual(TemplateScript e, TemplateScriptDto i) =>
string.Equals(e.Code, i.Code, StringComparison.Ordinal)
&& e.TriggerType == i.TriggerType
&& e.TriggerConfiguration == i.TriggerConfiguration
&& e.ParameterDefinitions == i.ParameterDefinitions
&& e.ReturnDefinition == i.ReturnDefinition
&& e.IsLocked == i.IsLocked;
private static bool NativeAlarmSourcesEqual(TemplateNativeAlarmSource e, TemplateNativeAlarmSourceDto i) =>
e.Description == i.Description
&& e.ConnectionName == i.ConnectionName
&& e.SourceReference == i.SourceReference
&& e.ConditionFilter == i.ConditionFilter
&& e.IsLocked == i.IsLocked
&& e.IsInherited == i.IsInherited
&& e.LockedInDerived == i.LockedInDerived;
private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
e.HttpMethod == i.HttpMethod
&& e.Path == i.Path