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
@@ -1495,6 +1495,7 @@ public sealed class BundleImporter : IBundleImporter
await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateNativeAlarmSourcesAsync(ex, dto, user, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
@@ -1578,6 +1579,19 @@ public sealed class BundleImporter : IBundleImporter
LockedInDerived = s.LockedInDerived,
});
}
foreach (var n in dto.NativeAlarmSources)
{
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name)
{
Description = n.Description,
ConnectionName = n.ConnectionName,
SourceReference = n.SourceReference,
ConditionFilter = n.ConditionFilter,
IsLocked = n.IsLocked,
IsInherited = n.IsInherited,
LockedInDerived = n.LockedInDerived,
});
}
return t;
}
@@ -1936,6 +1950,123 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <summary>
/// #05-T5 — Overwrite child sync (native alarm sources). Mirrors
/// <see cref="SyncTemplateAttributesAsync"/> for the
/// <c>NativeAlarmSources</c> collection: diffs the DTO's sources against the
/// existing template's sources by name and stages add / update / delete on
/// the tracked entity. Audit rows:
/// <c>TemplateNativeAlarmSourceAdded</c> / <c>…Updated</c> / <c>…Deleted</c>.
/// <para>
/// Update detection compares every writable field (Description,
/// ConnectionName, SourceReference, ConditionFilter, IsLocked, IsInherited,
/// LockedInDerived) — an idempotent overwrite produces no audit noise.
/// </para>
/// </summary>
private async Task SyncTemplateNativeAlarmSourcesAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
var dtoByName = dto.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
// Deletes — sources present on the target but not in the bundle.
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateNativeAlarmSourceAsync(existing.Id, ct).ConfigureAwait(false);
ex.NativeAlarmSources.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateNativeAlarmSourceDeleted",
"TemplateNativeAlarmSource",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, SourceName = existing.Name },
ct).ConfigureAwait(false);
}
// Adds + Updates.
foreach (var srcDto in dto.NativeAlarmSources)
{
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;
if (!changed) continue;
current.Description = srcDto.Description;
current.ConnectionName = srcDto.ConnectionName;
current.SourceReference = srcDto.SourceReference;
current.ConditionFilter = srcDto.ConditionFilter;
current.IsLocked = srcDto.IsLocked;
current.IsInherited = srcDto.IsInherited;
current.LockedInDerived = srcDto.LockedInDerived;
await _templateRepo.UpdateTemplateNativeAlarmSourceAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateNativeAlarmSourceUpdated",
"TemplateNativeAlarmSource",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
SourceName = current.Name,
current.Description,
current.ConnectionName,
current.SourceReference,
current.ConditionFilter,
current.IsLocked,
current.IsInherited,
current.LockedInDerived,
},
ct).ConfigureAwait(false);
}
else
{
var newSource = new TemplateNativeAlarmSource(srcDto.Name)
{
Description = srcDto.Description,
ConnectionName = srcDto.ConnectionName,
SourceReference = srcDto.SourceReference,
ConditionFilter = srcDto.ConditionFilter,
IsLocked = srcDto.IsLocked,
IsInherited = srcDto.IsInherited,
LockedInDerived = srcDto.LockedInDerived,
};
ex.NativeAlarmSources.Add(newSource);
await _auditService.LogAsync(
user,
"TemplateNativeAlarmSourceAdded",
"TemplateNativeAlarmSource",
"0",
$"{ex.Name}.{newSource.Name}",
new
{
TemplateName = ex.Name,
SourceName = newSource.Name,
newSource.Description,
newSource.ConnectionName,
newSource.SourceReference,
newSource.ConditionFilter,
newSource.IsLocked,
newSource.IsInherited,
newSource.LockedInDerived,
},
ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// Pass A of the post-template-flush rewire.
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO