feat(transport): import name-map plumbing via CLI + ManagementActor (M8 D3)

This commit is contained in:
Joseph Doherty
2026-06-18 07:08:33 -04:00
parent d974477e87
commit 565d53d0fe
5 changed files with 721 additions and 7 deletions
@@ -2421,7 +2421,11 @@ public class ManagementActor : ReceiveActor
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
return new PreviewBundleResult(preview.Items, adds, mods, ids, blocks);
// M8 (D3): surface the required site/connection mappings so an operator
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
}
/// <summary>
@@ -2462,10 +2466,19 @@ public class ManagementActor : ReceiveActor
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
// M8 (D3): resolve every required site/connection reference into a
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
// fall back to the preview's auto-match; otherwise (no match) create-new
// ONLY if the create-missing flag is set, else fail with a clear message.
var nameMap = BuildNameMap(cmd, preview);
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
// Central UI's TransportImport.BuildDefaultResolutions behavior.
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
// DataConnection rows the preview item Name is already site-qualified
// ({site}/{name}, D1-FIX), so keying generically by (EntityType, Name) is
// automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
@@ -2484,7 +2497,96 @@ public class ManagementActor : ReceiveActor
item.EntityType, item.Name, action, renameTo);
}
return await importer.ApplyAsync(session.SessionId, resolutionsMap.Values.ToList(), username);
return await importer.ApplyAsync(
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
}
/// <summary>
/// Merges the operator-supplied <see cref="SiteMappingSpec"/> /
/// <see cref="ConnectionMappingSpec"/> lists with the preview's auto-matches
/// and the create-missing flags into a <see cref="BundleNameMap"/>.
/// <para>
/// Per required site reference: an explicit spec wins (target present →
/// <see cref="MappingAction.MapToExisting"/>; null/blank target →
/// <see cref="MappingAction.CreateNew"/>); otherwise the preview's
/// <see cref="RequiredSiteMapping.AutoMatchTargetIdentifier"/> is used when
/// present (MapToExisting); otherwise CreateNew only when
/// <see cref="ImportBundleCommand.CreateMissingSites"/> is set, else the
/// reference is unresolved and the import fails. Connections mirror this with
/// <see cref="ImportBundleCommand.CreateMissingConnections"/>.
/// </para>
/// </summary>
private static BundleNameMap BuildNameMap(ImportBundleCommand cmd, ImportPreview preview)
{
var siteSpecs = (cmd.SiteMappings ?? Array.Empty<SiteMappingSpec>())
.ToDictionary(s => s.SourceSiteIdentifier, StringComparer.Ordinal);
var connSpecs = (cmd.ConnectionMappings ?? Array.Empty<ConnectionMappingSpec>())
.ToDictionary(
c => (c.SourceSiteIdentifier, c.SourceConnectionName),
c => c);
var siteMappings = new List<SiteMapping>();
var unresolved = new List<string>();
foreach (var required in preview.RequiredSiteMappings)
{
if (siteSpecs.TryGetValue(required.SourceSiteIdentifier, out var spec))
{
siteMappings.Add(string.IsNullOrWhiteSpace(spec.TargetSiteIdentifier)
? new SiteMapping(required.SourceSiteIdentifier, MappingAction.CreateNew, null)
: new SiteMapping(required.SourceSiteIdentifier, MappingAction.MapToExisting, spec.TargetSiteIdentifier));
}
else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetIdentifier))
{
siteMappings.Add(new SiteMapping(
required.SourceSiteIdentifier, MappingAction.MapToExisting, required.AutoMatchTargetIdentifier));
}
else if (cmd.CreateMissingSites)
{
siteMappings.Add(new SiteMapping(
required.SourceSiteIdentifier, MappingAction.CreateNew, null));
}
else
{
unresolved.Add($"site '{required.SourceSiteIdentifier}'");
}
}
var connMappings = new List<ConnectionMapping>();
foreach (var required in preview.RequiredConnectionMappings)
{
var key = (required.SourceSiteIdentifier, required.SourceConnectionName);
if (connSpecs.TryGetValue(key, out var spec))
{
connMappings.Add(string.IsNullOrWhiteSpace(spec.TargetConnectionName)
? new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null)
: new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, spec.TargetConnectionName));
}
else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetName))
{
connMappings.Add(new ConnectionMapping(
required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, required.AutoMatchTargetName));
}
else if (cmd.CreateMissingConnections)
{
connMappings.Add(new ConnectionMapping(
required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null));
}
else
{
unresolved.Add($"connection '{required.SourceSiteIdentifier}/{required.SourceConnectionName}'");
}
}
if (unresolved.Count > 0)
{
throw new ManagementCommandException(
$"Import has {unresolved.Count} unresolved mapping(s): " +
$"{string.Join(", ", unresolved.OrderBy(u => u, StringComparer.Ordinal))}. " +
"Supply --map-site/--map-connection for each, or pass " +
"--create-missing-sites/--create-missing-connections to create them from the bundle.");
}
return new BundleNameMap(siteMappings, connMappings);
}
private static byte[] DecodeBundle(string base64)