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:
@@ -101,6 +101,15 @@ public sealed class ArtifactDiff
|
||||
"Compositions",
|
||||
changes);
|
||||
|
||||
DiffChildren(
|
||||
existing.NativeAlarmSources,
|
||||
incoming.NativeAlarmSources,
|
||||
e => e.Name,
|
||||
i => i.Name,
|
||||
NativeAlarmSourcesEqual,
|
||||
"NativeAlarmSources",
|
||||
changes);
|
||||
|
||||
return BuildItem("Template", incoming.Name, changes);
|
||||
}
|
||||
|
||||
@@ -665,6 +674,15 @@ public sealed class ArtifactDiff
|
||||
&& 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user