feat(transport): restore composition + alarm-script edges on bundle import

This commit is contained in:
Joseph Doherty
2026-05-24 06:16:24 -04:00
parent cef77e1378
commit 8e73e60f4a
4 changed files with 579 additions and 21 deletions

View File

@@ -518,6 +518,27 @@ public sealed class BundleImporter : IBundleImporter
await using var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
// Run semantic validation FIRST — before any writes are staged.
// This is purely a name-resolution scan over the in-memory DTOs +
// pre-existing target reads, so it has no ordering dependency on
// the Apply* helpers. Failing here means the change tracker is
// still empty, which keeps the rollback contract simple on both
// the in-memory and relational providers (FU-B: intermediate
// SaveChangesAsync between Apply* and the second-pass rewire
// would otherwise prevent the in-memory provider from undoing
// already-flushed template rows on validation failure — the
// in-memory transaction is a no-op, so the only safe pattern is
// ONE deferred SaveChangesAsync at the very end of try-block).
//
// Skip-resolved DTOs are excluded from the in-bundle name set so
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
if (validationErrors.Count > 0)
{
throw new SemanticValidationException(validationErrors);
}
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
@@ -528,15 +549,24 @@ public sealed class BundleImporter : IBundleImporter
await ApplyApiKeysAsync(content.ApiKeys, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Minimal semantic validation — see XML comment above for the v1
// scope. Skip-resolved DTOs are excluded from the in-bundle name
// set so a Skip on a dependency surfaces as a missing-reference
// error rather than silently passing.
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
if (validationErrors.Count > 0)
{
throw new SemanticValidationException(validationErrors);
}
// FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed
// FKs that can only be resolved AFTER every template's scripts and
// child rows have been staged. We flush here so Pass A can look up
// the just-persisted scripts by name and Pass B can look up the
// just-persisted templates by name; both passes stage further
// mutations that ride the SAME outer transaction (committed below).
//
// EF tracking note: AddAsync on the in-memory provider assigns
// synthetic ids eagerly, so this intermediate flush mostly
// materialises identity values on a relational provider. The
// rollback contract is preserved because semantic validation
// already ran above — any throw from this point onward represents
// a successful merge that the user wants to keep, and the only
// remaining failure surface is the final BundleImported audit
// write itself.
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 _auditService.LogAsync(
user: user,
@@ -785,12 +815,26 @@ public sealed class BundleImporter : IBundleImporter
/// <summary>
/// Builds a <see cref="Template"/> from a bundle DTO, copying attributes /
/// alarms / scripts but deliberately NOT wiring composition edges — those
/// need FK ids resolved against the target DB and the first-cut import
/// covers the field-level data; rebuilding the composition graph belongs
/// in a follow-up task once the target's pre-existing template ids can be
/// joined in. <paramref name="overrideName"/> supports the Rename
/// resolution; pass <c>null</c> to keep the DTO's original name.
/// alarms / scripts. Two name-keyed FKs are NOT wired here because they
/// require post-flush identity values:
/// <list type="bullet">
/// <item><c>TemplateAlarm.OnTriggerScriptId</c> — points at a sibling
/// <c>TemplateScript</c>; resolved by <see cref="ResolveAlarmScriptLinksAsync"/>
/// once <c>SaveChangesAsync</c> has assigned script ids.</item>
/// <item><c>TemplateComposition.ComposedTemplateId</c> — points at any
/// other persisted <c>Template</c>; resolved by
/// <see cref="ResolveCompositionEdgesAsync"/> once all bundled templates
/// have been flushed and any pre-existing target templates can be joined
/// in by name.</item>
/// </list>
/// Both resolution passes run inside the same outer import transaction.
/// <paramref name="overrideName"/> supports the Rename resolution; pass
/// <c>null</c> to keep the DTO's original name. Renamed templates are
/// looked up by their <em>imported</em> name (i.e. <c>RenameTo</c>) when
/// the second pass resolves their alarm/composition FKs; however, bundle
/// DTOs that reference a renamed template by its <em>original</em> name
/// will still fall through to the unresolved-audit path — call sites are
/// not rewritten in v1.
/// </summary>
private static Template BuildTemplate(TemplateDto dto, string? overrideName)
{
@@ -831,6 +875,203 @@ public sealed class BundleImporter : IBundleImporter
return t;
}
/// <summary>
/// FU-B / remainder of #37 — Pass A of the post-template-flush rewire.
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
/// carries any alarm with <c>OnTriggerScriptName</c>, look up the
/// persisted alarm + script by name on the same template and set the FK.
/// If the named script is missing from the imported template, leave the
/// FK null and emit a <c>BundleImportAlarmScriptUnresolved</c> audit row
/// (correlation context still carries <c>BundleImportId</c>).
/// </summary>
private async Task ResolveAlarmScriptLinksAsync(
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;
// Resolve the imported template by its post-rename name. Skip-
// resolved DTOs were already filtered out above; everything else
// landed under either the DTO name or RenameTo.
var importedName = resolution.Action == ResolutionAction.Rename
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
// Use the children-loaded variant so the alarm + script collections
// are populated — the unfiltered GetAllTemplatesAsync returns the
// tracked entities EF assigned ids to during the flush, but the
// navigation collections aren't necessarily included.
var stub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, importedName, StringComparison.Ordinal));
if (stub is null) continue;
var template = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
if (template is null) continue;
foreach (var alarmDto in dto.Alarms)
{
if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue;
var alarm = template.Alarms.FirstOrDefault(a =>
string.Equals(a.Name, alarmDto.Name, StringComparison.Ordinal));
if (alarm is null) continue;
var script = template.Scripts.FirstOrDefault(s =>
string.Equals(s.Name, alarmDto.OnTriggerScriptName, StringComparison.Ordinal));
if (script is null)
{
// Unresolved — emit warning audit row, leave FK null.
await _auditService.LogAsync(
user,
"BundleImportAlarmScriptUnresolved",
"TemplateAlarm",
alarm.Id.ToString(),
$"{template.Name}.{alarm.Name}",
new
{
TemplateName = template.Name,
AlarmName = alarm.Name,
UnresolvedScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
continue;
}
alarm.OnTriggerScriptId = script.Id;
await _templateRepo.UpdateTemplateAlarmAsync(alarm, ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// FU-B / #39 — Pass B of the post-template-flush rewire. For every
/// imported template (Add / Overwrite / Rename) whose bundle DTO carries
/// any <c>Compositions</c>, replace the persisted template's existing
/// composition rows with new ones whose <c>ComposedTemplateId</c> is
/// resolved from <c>ComposedTemplateName</c> by looking up the now-
/// persisted template (just-imported set first, then pre-existing target).
/// <para>
/// Overwrite semantics: the persisted template's existing composition
/// rows are CLEARED before re-adding from the bundle so an Overwrite
/// truly overwrites the composition graph and doesn't silently retain
/// stale edges that aren't in the bundle anymore.
/// </para>
/// <para>
/// When <c>ComposedTemplateName</c> cannot be resolved — most commonly
/// because the user chose Skip on the referenced template — we emit a
/// <c>BundleImportCompositionUnresolved</c> audit row and skip the edge.
/// We deliberately do NOT throw: a Skip-resolved dependency is a
/// legitimate operator choice, not an import-killing error.
/// </para>
/// </summary>
private async Task ResolveCompositionEdgesAsync(
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;
if (dto.Compositions.Count == 0) continue;
var importedName = resolution.Action == ResolutionAction.Rename
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
var parentStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, importedName, StringComparison.Ordinal));
if (parentStub is null) continue;
var parent = await _templateRepo.GetTemplateWithChildrenAsync(parentStub.Id, ct).ConfigureAwait(false);
if (parent is null) continue;
// Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
// the deletes via the navigation; explicit DeleteAsync ensures the
// change is staged even when the relational provider doesn't
// detect orphans automatically.
if (parent.Compositions.Count > 0)
{
var existingComps = parent.Compositions.ToList();
foreach (var existing in existingComps)
{
await _templateRepo.DeleteTemplateCompositionAsync(existing.Id, ct).ConfigureAwait(false);
}
parent.Compositions.Clear();
}
foreach (var compDto in dto.Compositions)
{
if (string.IsNullOrEmpty(compDto.ComposedTemplateName))
{
await _auditService.LogAsync(
user,
"BundleImportCompositionUnresolved",
"TemplateComposition",
"0",
$"{parent.Name}.{compDto.InstanceName}",
new
{
OwnerTemplateName = parent.Name,
compDto.InstanceName,
UnresolvedComposedTemplateName = (string?)null,
Reason = "Composition DTO carried empty ComposedTemplateName.",
},
ct).ConfigureAwait(false);
continue;
}
// Resolve the composed template by name — first against the
// tracked Local set (which includes anything just imported,
// pre-flush or post-flush), then against the wider target DB
// (pre-existing rows not staged in this transaction).
var composedStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal));
int? composedId = composedStub?.Id;
if (composedId is null)
{
var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
composedId = allTargets
.FirstOrDefault(t => string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal))?.Id;
}
if (composedId is null)
{
await _auditService.LogAsync(
user,
"BundleImportCompositionUnresolved",
"TemplateComposition",
"0",
$"{parent.Name}.{compDto.InstanceName}",
new
{
OwnerTemplateName = parent.Name,
compDto.InstanceName,
UnresolvedComposedTemplateName = compDto.ComposedTemplateName,
Reason = "Composed template name not present in bundle or target.",
},
ct).ConfigureAwait(false);
continue;
}
var comp = new TemplateComposition(compDto.InstanceName)
{
TemplateId = parent.Id,
ComposedTemplateId = composedId.Value,
};
await _templateRepo.AddTemplateCompositionAsync(comp, ct).ConfigureAwait(false);
}
}
}
private async Task ApplySharedScriptsAsync(
IReadOnlyList<SharedScriptDto> dtos,
Dictionary<(string, string), ImportResolution> map,