fix(transport): transport template NativeAlarmSources — imports no longer amputate native alarm mirrors nor dangle instance overrides

New TemplateNativeAlarmSourceDto + init-only TemplateDto.NativeAlarmSources (empty
default, additive — IsInherited placeholders carried for the collision detector).
Exported in ToBundleContent, consumed in FromBundleContent + BuildTemplate, and a
new SyncTemplateNativeAlarmSourcesAsync runs the Overwrite add/update/delete child
sync with per-change audit rows. ArtifactDiff.CompareTemplate now DiffChildren over
native sources so Preview reports a NativeAlarmSources field change. Proven by an
end-to-end flatten test: an instance native-alarm-source override that dangled
pre-fix now resolves against the re-imported template source.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 16:59:37 -04:00
parent 4df5109742
commit e4136cb920
7 changed files with 589 additions and 3 deletions
@@ -126,7 +126,39 @@ public sealed record TemplateDto(
IReadOnlyList<TemplateAttributeDto> Attributes,
IReadOnlyList<TemplateAlarmDto> Alarms,
IReadOnlyList<TemplateScriptDto> Scripts,
IReadOnlyList<TemplateCompositionDto> Compositions);
IReadOnlyList<TemplateCompositionDto> Compositions)
{
// Template-defined native alarm source bindings. Modeled as an init-only
// property with an empty default (NOT a positional ctor param) because an
// IReadOnlyList has no valid compile-time default for a positional
// parameter, and for the same forward/source-compat reasons as the
// site/instance arrays on BundleContentDto:
// 1. Forward-compat: a bundle written before native alarm sources were
// transported has no NativeAlarmSources field and deserializes this to
// Array.Empty<> — consumers always see an empty list, never null.
// 2. Source-compat: every existing positional `new TemplateDto(...)` caller
// keeps compiling; producers opt in via the object-initializer.
// IsInherited placeholder rows ARE carried (the collision detector depends
// on them), mirroring how the flattener treats inherited native sources.
public IReadOnlyList<TemplateNativeAlarmSourceDto> NativeAlarmSources { get; init; } =
Array.Empty<TemplateNativeAlarmSourceDto>();
}
/// <summary>
/// A template-defined native alarm source binding. Carries every writable field
/// of the <c>TemplateNativeAlarmSource</c> entity, including the inheritance/lock
/// flags, so an export→import round-trip is field-faithful and instance-level
/// overrides of the source no longer dangle in the target environment.
/// </summary>
public sealed record TemplateNativeAlarmSourceDto(
string Name,
string? Description,
string ConnectionName,
string SourceReference,
string? ConditionFilter,
bool IsLocked,
bool IsInherited,
bool LockedInDerived);
public sealed record TemplateAttributeDto(
string Name,
@@ -91,7 +91,20 @@ public sealed class EntitySerializer
LockedInDerived: s.LockedInDerived)).ToList(),
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
InstanceName: c.InstanceName,
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList());
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())
{
// Native alarm sources carry ALL rows including IsInherited
// placeholders — the collision detector depends on them.
NativeAlarmSources = t.NativeAlarmSources.Select(n => new TemplateNativeAlarmSourceDto(
Name: n.Name,
Description: n.Description,
ConnectionName: n.ConnectionName,
SourceReference: n.SourceReference,
ConditionFilter: n.ConditionFilter,
IsLocked: n.IsLocked,
IsInherited: n.IsInherited,
LockedInDerived: n.LockedInDerived)).ToList(),
};
}).ToList(),
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
Name: s.Name,
@@ -352,6 +365,20 @@ public sealed class EntitySerializer
LockedInDerived = s.LockedInDerived,
});
}
foreach (var n in dto.NativeAlarmSources)
{
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name)
{
TemplateId = t.Id,
Description = n.Description,
ConnectionName = n.ConnectionName,
SourceReference = n.SourceReference,
ConditionFilter = n.ConditionFilter,
IsLocked = n.IsLocked,
IsInherited = n.IsInherited,
LockedInDerived = n.LockedInDerived,
});
}
return t;
})
.ToList();