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 />
|
/// <inheritdoc />
|
||||||
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(CancellationToken cancellationToken = default)
|
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 ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
|
||||||
await ResolveCompositionEdgesAsync(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);
|
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 ----
|
// ---- Import-time acyclicity guard over the merged template graph ----
|
||||||
// The two rewire passes above have STAGED (not yet saved) the bundle's
|
// 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.EndpointUrl = dto.BaseUrl;
|
||||||
existing.AuthType = dto.AuthType;
|
existing.AuthType = dto.AuthType;
|
||||||
existing.AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null;
|
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 _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false);
|
||||||
await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
|
await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
|
||||||
new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false);
|
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)
|
var sys = new ExternalSystemDefinition(overrideName ?? dto.Name, dto.BaseUrl, dto.AuthType)
|
||||||
{
|
{
|
||||||
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
|
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
|
||||||
|
MaxRetries = dto.MaxRetries,
|
||||||
|
RetryDelay = dto.RetryDelay,
|
||||||
};
|
};
|
||||||
return sys;
|
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(
|
private async Task ApplyDatabaseConnectionsAsync(
|
||||||
IReadOnlyList<DatabaseConnectionDto> dtos,
|
IReadOnlyList<DatabaseConnectionDto> dtos,
|
||||||
Dictionary<(string, string), ImportResolution> map,
|
Dictionary<(string, string), ImportResolution> map,
|
||||||
|
|||||||
Reference in New Issue
Block a user