fix(transport): wire template inheritance edges on bundle import (C3) — derived templates no longer land as roots

This commit is contained in:
Joseph Doherty
2026-07-08 15:17:47 -04:00
parent 6ff90702f0
commit 099728f05a
3 changed files with 295 additions and 2 deletions
@@ -1042,6 +1042,7 @@ public sealed class BundleImporter : IBundleImporter
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
// ---- Site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
@@ -2092,6 +2093,91 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <summary>
/// Second-pass rewire for template inheritance edges (<c>ParentTemplateId</c>),
/// mirroring <see cref="ResolveCompositionEdgesAsync"/>. The bundle carries the
/// base by name (<c>TemplateDto.BaseTemplateName</c>); the create/overwrite pass
/// never sets the FK, so without this pass a derived template lands as a root and
/// its inheritance is silently lost (arch-review C3). Runs after templates are
/// flushed so both the derived template and its base are resolvable by name.
/// Never throws — an unresolvable base leaves the edge null and records an audit
/// row, keeping the import all-or-nothing on real faults only.
/// </summary>
private async Task ResolveInheritanceEdgesAsync(
IReadOnlyList<TemplateDto> dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
string user,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
var importedName = resolution.Action == ResolutionAction.Rename
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
// The just-imported/updated template is tracked in the Local set.
var template = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, importedName, StringComparison.Ordinal));
if (template is null) continue;
// A root-template bundle — or an Overwrite that dropped the base —
// must clear any stale parent edge on the target row.
if (string.IsNullOrEmpty(dto.BaseTemplateName))
{
template.ParentTemplateId = null;
continue;
}
// Resolve the base template's PERSISTED name through the resolution
// map (it may have been renamed on import), then look it up by name —
// first in the tracked Local set (anything imported this run), then in
// the wider target DB (pre-existing rows not staged in this transaction).
var baseResolution = ResolveOrDefault(resolutionMap, "Template", dto.BaseTemplateName);
var baseName = baseResolution.Action == ResolutionAction.Rename
? (baseResolution.RenameTo ?? dto.BaseTemplateName)
: dto.BaseTemplateName;
var baseStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, baseName, StringComparison.Ordinal));
int? baseId = baseStub?.Id;
if (baseId is null)
{
var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
baseId = allTargets
.FirstOrDefault(t => string.Equals(t.Name, baseName, StringComparison.Ordinal))?.Id;
}
if (baseId is null)
{
// Unresolvable base (e.g. it was Skip-resolved and isn't in the
// target). Leave the edge null and record it; never throw.
template.ParentTemplateId = null;
await _auditService.LogAsync(
user,
"BundleImportBaseTemplateUnresolved",
"Template",
"0",
importedName,
new
{
DerivedTemplateName = importedName,
UnresolvedBaseTemplateName = dto.BaseTemplateName,
ResolvedBaseName = baseName,
Reason = "Base template name not present in bundle or target.",
},
ct).ConfigureAwait(false);
continue;
}
template.ParentTemplateId = baseId.Value;
}
}
private async Task ApplySharedScriptsAsync(
IReadOnlyList<SharedScriptDto> dtos,
Dictionary<(string, string), ImportResolution> map,