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:
+6
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user