fix(transport): connection map Pass-2 (FK) + site-qualified connection resolution (M8 D1-FIX, C1+C2)

This commit is contained in:
Joseph Doherty
2026-06-18 07:08:31 -04:00
parent 6457f03fae
commit d974477e87
3 changed files with 507 additions and 16 deletions
@@ -474,11 +474,18 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ----
// C2: connection names are unique only WITHIN a site, so the preview item's
// identity is SITE-QUALIFIED (`{SiteIdentifier}/{Name}`). The diff CONTENT is
// unchanged — only the item Name is qualified (after CompareDataConnection
// returns) so two sites' same-named connections resolve to distinct items and
// the operator's per-item Skip/Overwrite applies to the right site's connection.
// ApplyDataConnectionsAsync looks the resolution up by this same qualified key.
foreach (var dcDto in content.DataConnections)
{
var targetConns = await ResolveTargetConnectionsAsync(dcDto.SiteIdentifier).ConfigureAwait(false);
var existing = targetConns.FirstOrDefault(c => string.Equals(c.Name, dcDto.Name, StringComparison.Ordinal));
items.Add(_diff.CompareDataConnection(dcDto, existing));
var item = _diff.CompareDataConnection(dcDto, existing);
items.Add(item with { Name = QualifiedConnectionName(dcDto.SiteIdentifier, dcDto.Name) });
}
// ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
@@ -709,9 +716,11 @@ public sealed class BundleImporter : IBundleImporter
if (bundleConnections.Contains((site, name))) continue;
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue;
// C2: site-qualify the blocker Name so two sites' same-named-but-missing
// connections surface as distinct blockers (a bare name would collide).
blockers.Add(new ImportPreviewItem(
EntityType: "Instance",
Name: name,
Name: QualifiedConnectionName(site, name),
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Blocker,
@@ -859,6 +868,18 @@ public sealed class BundleImporter : IBundleImporter
private static bool IsIdentifierStart(char c) => c == '_' || char.IsLetter(c);
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
/// <summary>
/// C2: the site-qualified identity (<c>{siteIdentifier}/{connectionName}</c>) used
/// for every <c>DataConnection</c> preview item Name + blocker Name, and for the
/// matching resolution lookup in <see cref="ApplyDataConnectionsAsync"/>. Connection
/// names are unique only WITHIN a site, so a bare-name key would collapse two sites'
/// same-named connections onto one resolution entry. Callers building a resolution
/// map from <c>preview.Items</c> keyed by <c>(EntityType, Name)</c> get the
/// site-qualified key for free.
/// </summary>
private static string QualifiedConnectionName(string siteIdentifier, string connectionName) =>
$"{siteIdentifier}/{connectionName}";
/// <inheritdoc />
public async Task<ImportResult> ApplyAsync(
Guid sessionId,
@@ -2722,8 +2743,12 @@ public sealed class BundleImporter : IBundleImporter
}
// MapToExisting — honour the connection's own conflict resolution.
// C2: the resolution is keyed by the SITE-QUALIFIED name
// (`{SiteIdentifier}/{Name}`), matching the qualified preview-item Name —
// so a same-named connection under a different site can't pick up the
// wrong Skip/Overwrite.
var resolution = ResolveOrDefault(
resolutionMap, "DataConnection", dcDto.Name);
resolutionMap, "DataConnection", QualifiedConnectionName(dcDto.SiteIdentifier, dcDto.Name));
if (resolution.Action == ResolutionAction.Overwrite)
{
ApplyDataConnectionFields(existing, dcDto);
@@ -2743,9 +2768,89 @@ public sealed class BundleImporter : IBundleImporter
targetNameByRef[key] = existing.Name;
}
// ---- C1 Pass 2 — referenced-but-not-carried connections ----
// A binding (or native-alarm-source override) can name a connection that
// exists in the TARGET database but was NOT carried in the bundle's
// DataConnections (e.g. exported without it). Preview correctly does NOT
// block it — it auto-matches against the target. But the main loop above
// only populated the maps for connections the bundle CARRIES, so without
// this pass IdBySourceRef would MISS for such a binding and the instance
// pass would write DataConnectionId = 0 (an invalid FK). Mirror the
// site path's Pass 2: for every distinct (sourceSite, connName) referenced
// by an instance binding / native-alarm override that isn't already mapped,
// resolve the target site (honouring a nameMap redirect to a differently-
// named target connection) and look up the EXISTING target connection by
// name, populating both maps with its real id + name.
foreach (var (sourceSite, connName) in EnumerateReferencedConnectionRefs(content))
{
var key = (sourceSite, connName);
if (result.ContainsKey(key)) continue; // already mapped by the carried-connection loop.
if (!siteBySourceIdentifier.TryGetValue(sourceSite, out var targetSite))
{
// ApplySitesAsync resolved every referenced site; guard so a missing
// entry fails the import rather than writing an orphan FK.
throw new InvalidOperationException(
$"Connection '{sourceSite}/{connName}' references a site that could not be "
+ "resolved to a target.");
}
connMappingByRef.TryGetValue(key, out var mapping);
var targetName = mapping?.Action == MappingAction.MapToExisting
&& mapping.TargetConnectionName is { Length: > 0 } tn
? tn
: connName;
var targetConns = await TargetConnsAsync(targetSite.Id).ConfigureAwait(false);
var existing = targetConns.FirstOrDefault(c =>
string.Equals(c.Name, targetName, StringComparison.Ordinal));
if (existing is null)
{
// Should already be a preview blocker (present in neither bundle nor
// target). Fail with a clear message rather than letting the instance
// pass write DataConnectionId = 0.
throw new InvalidOperationException(
$"Connection '{sourceSite}/{connName}' is referenced by the bundle but is present "
+ "in neither the bundle nor the target — cannot resolve a target connection.");
}
result[key] = existing.Id;
targetNameByRef[key] = existing.Name;
}
return new ResolvedConnectionMaps(result, targetNameByRef);
}
/// <summary>
/// C1: every distinct <c>(sourceSiteIdentifier, connectionName)</c> pair an instance
/// references — via a <see cref="InstanceConnectionBindingDto.ConnectionName"/> or a
/// non-null <see cref="InstanceNativeAlarmSourceOverrideDto.ConnectionNameOverride"/>.
/// Drives the connection-map Pass 2 that resolves references the bundle didn't carry.
/// </summary>
private static IEnumerable<(string Site, string Name)> EnumerateReferencedConnectionRefs(BundleContentDto content)
{
var seen = new HashSet<(string, string)>();
foreach (var inst in content.Instances)
{
foreach (var b in inst.ConnectionBindings)
{
if (!string.IsNullOrEmpty(b.ConnectionName)
&& seen.Add((inst.SiteIdentifier, b.ConnectionName)))
{
yield return (inst.SiteIdentifier, b.ConnectionName);
}
}
foreach (var n in inst.NativeAlarmSourceOverrides)
{
if (!string.IsNullOrEmpty(n.ConnectionNameOverride)
&& seen.Add((inst.SiteIdentifier, n.ConnectionNameOverride)))
{
yield return (inst.SiteIdentifier, n.ConnectionNameOverride);
}
}
}
}
private static DataConnection BuildDataConnection(DataConnectionDto dto, int siteId) =>
new(dto.Name, dto.Protocol, siteId)
{
@@ -3013,10 +3118,24 @@ public sealed class BundleImporter : IBundleImporter
}
foreach (var b in dto.ConnectionBindings)
{
// Resolve ConnectionName → target DataConnectionId. A binding whose
// connection didn't resolve was a preview blocker; default to 0 here
// (the FK constraint / a later deploy gate surfaces it) rather than
// throwing, since the binding's attribute may legitimately be unbound.
// Resolve ConnectionName → target DataConnectionId. After the C1 Pass-2
// in ApplyDataConnectionsAsync, the map carries an entry for every
// referenced connection — carried in the bundle OR auto-matched in the
// target. A binding whose connection name STILL doesn't resolve is a
// structural error (it should already have been a preview blocker +
// a pre-write validation failure); THROW rather than write
// DataConnectionId = 0, which would be an invalid FK on a relational
// provider and a silently-broken binding on the in-memory one.
// A binding may legitimately carry NO connection name (unbound
// attribute) — only a NON-EMPTY name that fails to resolve is an error.
if (!string.IsNullOrEmpty(b.ConnectionName)
&& !connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out _))
{
throw new InvalidOperationException(
$"Instance '{inst.UniqueName}' binding for attribute '{b.AttributeName}' references "
+ $"connection '{sourceSiteIdentifier}/{b.ConnectionName}' which could not be resolved "
+ "to a target connection (present in neither bundle nor target).");
}
connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out var connId);
inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
{