fix(transport): carry derived composition-slot templates and rebuild them through the compose flow (ScadaBridge#118)

Bundles did not record that a template was a derived slot template, so the
importer re-created every slot as a BASE template named after the slot.

- Export: TemplateDto gains an additive, null-omitted `Derivation`
  (owner path + base path by stable names); a derived template carries only
  its own rows (overrides/additions, not inherited placeholders). The closure
  pulls in each derived template's base/owner; base orders first.
  schemaVersion 1.1 -> 1.2, bundleFormatVersion stays 1.
- Import: base templates go through the ordinary passes; derived templates are
  ordered topologically (DerivedTemplatePlan; cycle / duplicate / malformed =
  preview Blocker + SemanticValidationException before anything is staged),
  their owner and base resolved by path (#100 root rule), and the slot is
  created with TemplateService.AddCompositionAsync, synced in place when it
  already exists with the same base, or deleted+recomposed when the base
  differs; own rows applied as overrides, then ResyncInheritedMembersAsync.
  Missing owner/base fails that record (BundleImportDerivedTemplateUnresolved
  audit + ImportResult warning), never creating a base in its place.
- Preview/diff: derived rows are keyed by qualified name and compared against
  the target slot at the same owner path (own rows + base), so an unchanged
  slot is Identical and a same-named base template is never matched.
- Pre-#118 bundles import exactly as before (new fixture
  pre-118-derived-plaintext.scadabundle, written by a13aabc7). The field is in
  content, pinned by the signed manifest's contentHash.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 05:02:06 -05:00
parent fb04b54d62
commit 84a050420f
14 changed files with 1676 additions and 41 deletions
@@ -176,6 +176,57 @@ public sealed record TemplateDto(
/// </summary>
public IReadOnlyList<TemplateNativeAlarmSourceDto> NativeAlarmSources { get; init; } =
Array.Empty<TemplateNativeAlarmSourceDto>();
// ScadaBridge#118: set only on a DERIVED (composition-slot) template. Init-only with a
// null default for the same forward/source-compat reasons as NativeAlarmSources, and
// omitted from the JSON when null (BundleJsonOptions WhenWritingNull), so a base
// template's wire shape is byte-identical to a pre-#118 bundle. A bundle written before
// #118 has no Derivation anywhere and imports exactly as it always did. When set, the
// member lists carry only the derived template's OWN rows (overrides + additions,
// IsInherited == false); the inherited placeholders are rebuilt on import by the normal
// compose flow from the base.
/// <summary>
/// The derived (composition-slot) template's place in the composition graph, or
/// <c>null</c> for a base template (and for every template of a pre-#118 bundle).
/// </summary>
public TemplateDerivationDto? Derivation { get; init; }
}
/// <summary>
/// ScadaBridge#118 — where a derived (composition-slot) template sits, by stable name
/// paths instead of ids. A derived template's own <see cref="TemplateDto.Name"/> is its
/// slot name, unique only within its owner, so it is identified by its owner path: the
/// base template at the root of the owner chain plus the slot names from it down to this
/// template (<see cref="SlotPath"/>, whose last element is this template's slot). The base
/// it derives from is addressed the same way: a base template name plus an empty
/// <see cref="BaseSlotPath"/>, or — for the nested slots the compose cascade creates — the
/// source-side derived template under that base.
/// </summary>
/// <param name="OwnerTemplateName">The base template at the root of the owner chain.</param>
/// <param name="SlotPath">Slot instance names from <paramref name="OwnerTemplateName"/> down to this template.</param>
/// <param name="BaseTemplateName">The base template at the root of the inherited-from path.</param>
/// <param name="BaseSlotPath">Slot names under <paramref name="BaseTemplateName"/>; empty when the base is a base template.</param>
public sealed record TemplateDerivationDto(
string OwnerTemplateName,
IReadOnlyList<string> SlotPath,
string BaseTemplateName,
IReadOnlyList<string> BaseSlotPath)
{
/// <summary>The dotted qualified name of the derived template (<c>Owner.Slot…</c>) —
/// its identity in the import preview and resolutions.</summary>
/// <returns>The qualified name.</returns>
public string QualifiedName() => Qualify(OwnerTemplateName, SlotPath);
/// <summary>The dotted qualified name of the base the template derives from.</summary>
/// <returns>The qualified base name.</returns>
public string BaseQualifiedName() => Qualify(BaseTemplateName, BaseSlotPath);
/// <summary>Joins a root template name and a slot path with dots.</summary>
/// <param name="root">The root template name.</param>
/// <param name="slots">The slot path below it.</param>
/// <returns><paramref name="root"/> when the path is empty, else <c>root.slot.slot…</c>.</returns>
public static string Qualify(string root, IReadOnlyList<string> slots) =>
slots.Count == 0 ? root : root + "." + string.Join('.', slots);
}
/// <summary>
@@ -28,6 +28,11 @@ public sealed class EntitySerializer
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
var templateById = aggregate.Templates.ToDictionary(t => t.Id);
var compositionById = aggregate.Templates
.SelectMany(t => t.Compositions)
.GroupBy(c => c.Id)
.ToDictionary(g => g.Key, g => g.First());
// Name-link lookups. Instances/data-connections reference their owning
// site by the portable SiteIdentifier (not the EF surrogate SiteId); a
@@ -49,12 +54,20 @@ public sealed class EntitySerializer
// (TemplateAlarm.OnTriggerScriptId FK is scoped to TemplateId),
// so we don't need a global script index.
var scriptNameById = t.Scripts.ToDictionary(s => s.Id, s => s.Name);
// ScadaBridge#118: a derived (composition-slot) template travels as its
// derivation plus its OWN rows only — the inherited placeholders are the
// base's members, which the importer's compose flow re-materializes. A
// derivation that cannot be resolved inside this aggregate (owner or base
// missing) falls back to the pre-#118 shape rather than emitting a dangling path.
var derivation = TryBuildDerivation(t, templateById, compositionById);
bool Own(bool isInherited) => derivation is null || !isInherited;
return new TemplateDto(
Name: t.Name,
FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
Description: t.Description,
Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
Attributes: t.Attributes.Where(a => Own(a.IsInherited)).Select(a => new TemplateAttributeDto(
Name: a.Name,
Value: a.Value,
DataType: a.DataType,
@@ -63,7 +76,7 @@ public sealed class EntitySerializer
DataSourceReference: a.DataSourceReference,
ElementDataType: a.ElementDataType,
LockedInDerived: a.LockedInDerived)).ToList(),
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
Alarms: t.Alarms.Where(a => Own(a.IsInherited)).Select(a => new TemplateAlarmDto(
Name: a.Name,
Description: a.Description,
PriorityLevel: a.PriorityLevel,
@@ -79,7 +92,7 @@ public sealed class EntitySerializer
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
LockedInDerived: a.LockedInDerived,
IsMesReportable: a.IsMesReportable)).ToList(),
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
Scripts: t.Scripts.Where(s => Own(s.IsInherited)).Select(s => new TemplateScriptDto(
Name: s.Name,
Code: s.Code,
TriggerType: s.TriggerType,
@@ -95,8 +108,10 @@ public sealed class EntitySerializer
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(
// placeholders — the collision detector depends on them — except
// on a template that travels with a Derivation (#118), whose
// placeholders the importer's compose flow rebuilds from the base.
NativeAlarmSources = t.NativeAlarmSources.Where(n => Own(n.IsInherited)).Select(n => new TemplateNativeAlarmSourceDto(
Name: n.Name,
Description: n.Description,
ConnectionName: n.ConnectionName,
@@ -106,6 +121,7 @@ public sealed class EntitySerializer
IsInherited: n.IsInherited,
LockedInDerived: n.LockedInDerived,
IsMesReportable: n.IsMesReportable)).ToList(),
Derivation = derivation,
};
}).ToList(),
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
@@ -307,6 +323,61 @@ public sealed class EntitySerializer
};
}
/// <summary>
/// ScadaBridge#118: the derivation of a derived (composition-slot) template — its owner
/// path and its base path — or <c>null</c> for a base template, or when the owner or
/// base chain leaves the aggregate (the template then travels in the pre-#118 shape).
/// </summary>
internal static TemplateDerivationDto? TryBuildDerivation(
Template template,
IReadOnlyDictionary<int, Template> templateById,
IReadOnlyDictionary<int, TemplateComposition> compositionById)
{
if (!template.IsDerived || template.ParentTemplateId is not { } baseId) return null;
if (!templateById.TryGetValue(baseId, out var baseTemplate)) return null;
var owner = TryBuildPath(template, templateById, compositionById);
if (owner is null) return null;
(string Root, List<string> Slots)? basePath = baseTemplate.IsDerived
? TryBuildPath(baseTemplate, templateById, compositionById)
: (baseTemplate.Name, new List<string>());
if (basePath is null) return null;
return new TemplateDerivationDto(
OwnerTemplateName: owner.Value.Root,
SlotPath: owner.Value.Slots,
BaseTemplateName: basePath.Value.Root,
BaseSlotPath: basePath.Value.Slots);
}
// Walks a derived template's OwnerCompositionId chain up to the base template at its
// root: (root name, slot names root→template). Null when a link leaves the aggregate
// or the chain loops (the composition graph is acyclic by the save-gate; defensive).
private static (string Root, List<string> Slots)? TryBuildPath(
Template template,
IReadOnlyDictionary<int, Template> templateById,
IReadOnlyDictionary<int, TemplateComposition> compositionById)
{
var slots = new List<string>();
var visited = new HashSet<int>();
var current = template;
while (current.IsDerived)
{
if (!visited.Add(current.Id)
|| current.OwnerCompositionId is not { } compId
|| !compositionById.TryGetValue(compId, out var composition)
|| composition.ComposedTemplateId != current.Id
|| !templateById.TryGetValue(composition.TemplateId, out var owner))
{
return null;
}
slots.Insert(0, composition.InstanceName);
current = owner;
}
return (current.Name, slots);
}
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
/// <param name="content">The bundle content DTO to deserialize.</param>
/// <returns>An <see cref="EntityAggregate"/> with entities linked by name, ready for import conflict resolution.</returns>
@@ -17,7 +17,10 @@ public sealed class ManifestBuilder
// site/dataConnection/instance summary counts (and their content arrays).
// Minor bumps are additive-only: ManifestValidator gates solely on
// BundleFormatVersion, so an older "1.0" bundle still validates Ok.
public const string CurrentSchemaVersion = "1.1";
// 1.1 → 1.2 (ScadaBridge#118): TemplateDto.Derivation marks derived composition-slot
// templates. Additive — an older importer ignores the field (and imports the slot as
// it always did), a 1.0/1.1 bundle has no derivations and imports unchanged.
public const string CurrentSchemaVersion = "1.2";
/// <summary>
/// Builds a <see cref="BundleManifest"/> with the current format version, a SHA-256 content hash,