fix(transport): close 5 residual import/export fidelity holes surfaced by the round-trip guard (T8)

The Task-8 reflection round-trip suite exposed five silent-data-loss paths in
non-template entity types:
- ExternalSystemDefinition.MaxRetries/RetryDelay dropped on import Add (and Overwrite
  never updated them) — BuildExternalSystem + Overwrite branch now carry both.
- ExternalSystemMethod rows dropped on import Add — a new post-flush
  PersistAddedExternalSystemMethodsAsync pass adds them (the entity has no parent
  nav, so an Add can't cascade; idempotent vs the inline Overwrite sync).
- NotificationRecipient dropped on export — GetNotificationListByIdAsync now
  Include(Recipients) instead of a bare FindAsync.
- TemplateFolder.ParentFolderId flattened on import Add — a new
  ResolveFolderParentEdgesAsync second-pass rewire resolves ParentName→id,
  mirroring the template inheritance/composition rewires.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 17:44:12 -04:00
parent e498c6e1bd
commit 7c74bbe4b5
2 changed files with 130 additions and 1 deletions
@@ -17,7 +17,12 @@ public class NotificationRepository : INotificationRepository
/// <inheritdoc />
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().FindAsync(new object[] { id }, cancellationToken);
// Eager-load Recipients: single-list selection feeds the Transport exporter,
// which serializes the recipient collection — a FindAsync without the Include
// exported a recipient-less list (silent data loss).
=> await _context.Set<NotificationList>()
.Include(n => n.Recipients)
.FirstOrDefaultAsync(n => n.Id == id, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(CancellationToken cancellationToken = default)
@@ -1050,6 +1050,16 @@ public sealed class BundleImporter : IBundleImporter
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);
// Folder parent edges are name-keyed too — resolve them AFTER the flush
// above so every imported folder has a materialised id to point at
// (the Add pass created the folders parent-less). Mirrors the template
// inheritance/composition rewires.
await ResolveFolderParentEdgesAsync(content.TemplateFolders, resolutionMap, user, ct).ConfigureAwait(false);
// External-system methods have no navigation on the parent definition,
// so an Add can't cascade them — persist them here, once the parent
// system has a real id from the flush above. (Overwrite already syncs
// its methods inline against the existing parent id.)
await PersistAddedExternalSystemMethodsAsync(content.ExternalSystems, resolutionMap, user, ct).ConfigureAwait(false);
// ---- Import-time acyclicity guard over the merged template graph ----
// The two rewire passes above have STAGED (not yet saved) the bundle's
@@ -2544,6 +2554,8 @@ public sealed class BundleImporter : IBundleImporter
existing.EndpointUrl = dto.BaseUrl;
existing.AuthType = dto.AuthType;
existing.AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null;
existing.MaxRetries = dto.MaxRetries;
existing.RetryDelay = dto.RetryDelay;
await _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false);
@@ -2575,6 +2587,8 @@ public sealed class BundleImporter : IBundleImporter
var sys = new ExternalSystemDefinition(overrideName ?? dto.Name, dto.BaseUrl, dto.AuthType)
{
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
MaxRetries = dto.MaxRetries,
RetryDelay = dto.RetryDelay,
};
return sys;
}
@@ -2687,6 +2701,116 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <summary>
/// Second-pass rewire of the name-keyed <see cref="TemplateFolder.ParentFolderId"/>
/// edge. The Add pass creates folders parent-less (parent may be later in the
/// bundle and have no id yet); after the folder flush, resolve each folder's
/// bundle <c>ParentName</c> (through the rename resolution map) to a persisted
/// folder id and set the FK. Mirrors <see cref="ResolveInheritanceEdgesAsync"/>.
/// </summary>
private async Task ResolveFolderParentEdgesAsync(
IReadOnlyList<TemplateFolderDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
var byName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "TemplateFolder", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
var childName = resolution.Action == ResolutionAction.Rename
? resolution.RenameTo ?? dto.Name
: dto.Name;
if (!byName.TryGetValue(childName, out var child)) continue;
int? parentId = null;
if (dto.ParentName is not null)
{
// A renamed parent is persisted under its RenameTo name.
var parentResolution = ResolveOrDefault(map, "TemplateFolder", dto.ParentName);
var parentName = parentResolution.Action == ResolutionAction.Rename
? parentResolution.RenameTo ?? dto.ParentName
: dto.ParentName;
if (byName.TryGetValue(parentName, out var parent))
{
parentId = parent.Id;
}
}
if (child.ParentFolderId != parentId)
{
child.ParentFolderId = parentId;
await _templateRepo.UpdateFolderAsync(child, ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// Persists an added external system's method rows. <see cref="ExternalSystemMethod"/>
/// has no navigation on its parent definition, so an Add can't cascade its
/// methods the way template children do — they are added here after the parent
/// system has a materialised id from the central-config flush. Idempotent: a
/// method already present (e.g. a genuine Overwrite that synced its methods
/// inline) is skipped, so re-processing every non-Skip system is safe.
/// </summary>
private async Task PersistAddedExternalSystemMethodsAsync(
IReadOnlyList<ExternalSystemDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
if (dto.Methods.Count == 0) continue;
var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
var persistedName = resolution.Action == ResolutionAction.Rename
? resolution.RenameTo ?? dto.Name
: dto.Name;
var sys = await _externalRepo.GetExternalSystemByNameAsync(persistedName, ct).ConfigureAwait(false);
if (sys is null) continue;
var existing = await _externalRepo.GetMethodsByExternalSystemIdAsync(sys.Id, ct).ConfigureAwait(false);
var existingNames = existing.Select(m => m.Name).ToHashSet(StringComparer.Ordinal);
foreach (var methodDto in dto.Methods)
{
if (existingNames.Contains(methodDto.Name)) continue; // already synced (Overwrite) or already added.
var method = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path)
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = methodDto.ParameterDefinitions,
ReturnDefinition = methodDto.ReturnDefinition,
};
await _externalRepo.AddExternalSystemMethodAsync(method, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodAdded",
"ExternalSystemMethod",
"0",
$"{sys.Name}.{method.Name}",
new
{
ExternalSystemName = sys.Name,
MethodName = method.Name,
method.HttpMethod,
method.Path,
method.ParameterDefinitions,
method.ReturnDefinition,
},
ct).ConfigureAwait(false);
}
}
}
private async Task ApplyDatabaseConnectionsAsync(
IReadOnlyList<DatabaseConnectionDto> dtos,
Dictionary<(string, string), ImportResolution> map,