feat(m9/T24a): guarded move-data-connection-between-sites command + handler
This commit is contained in:
@@ -70,13 +70,26 @@ public interface ISiteRepository
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// Instances (for deletion constraint checks)
|
||||
// Instances (for deletion / move constraint checks)
|
||||
/// <summary>Retrieves all instances deployed to a site.</summary>
|
||||
/// <param name="siteId">The site primary key to filter by.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to a read-only list of <see cref="Instance"/> entities for the given site.</returns>
|
||||
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the distinct instances that have at least one
|
||||
/// <see cref="InstanceConnectionBinding"/> referencing the given data
|
||||
/// connection. Used as the primary data-integrity guard for moving a
|
||||
/// connection between sites: a bound connection cannot leave its site
|
||||
/// without orphaning the (site-scoped) binding, so the move handler rejects
|
||||
/// the move and names the returned instances as blockers.
|
||||
/// </summary>
|
||||
/// <param name="dataConnectionId">The data connection primary key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the distinct referencing <see cref="Instance"/> entities (empty when none).</returns>
|
||||
Task<IReadOnlyList<Instance>> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Saves all pending changes to the database.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the number of state entries written to the database.</returns>
|
||||
|
||||
@@ -5,3 +5,18 @@ public record GetDataConnectionCommand(int DataConnectionId);
|
||||
public record CreateDataConnectionCommand(int SiteId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3);
|
||||
public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3);
|
||||
public record DeleteDataConnectionCommand(int DataConnectionId);
|
||||
|
||||
/// <summary>
|
||||
/// Moves a data connection from its current site to <paramref name="TargetSiteId"/>.
|
||||
/// Designer-gated and heavily guarded (see the handler): the target site must
|
||||
/// exist, the target site must not already own a connection with the same name,
|
||||
/// and NO <see cref="Entities.Instances.InstanceConnectionBinding"/> may reference
|
||||
/// the connection (instances are site-scoped, so a bound connection cannot leave
|
||||
/// its site without orphaning the binding). Name-based native-alarm-source
|
||||
/// references (template <c>ConnectionName</c> / instance
|
||||
/// <c>ConnectionNameOverride</c>) are also checked and the move is blocked rather
|
||||
/// than silently creating an ambiguous/orphaned state. No auto-rewrite is performed.
|
||||
/// </summary>
|
||||
/// <param name="DataConnectionId">Primary key of the connection to move.</param>
|
||||
/// <param name="TargetSiteId">Primary key of the destination site.</param>
|
||||
public record MoveDataConnectionCommand(int DataConnectionId, int TargetSiteId);
|
||||
|
||||
@@ -125,13 +125,37 @@ public class SiteRepository : ISiteRepository
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Instances (for deletion constraint checks) ---
|
||||
// --- Instances (for deletion / move constraint checks) ---
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.Include(i => i.NativeAlarmSourceOverrides)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The distinct instance ids whose bindings reference the connection.
|
||||
// Bindings are not exposed via their own DbSet on ISiteRepository, so
|
||||
// resolve them through the InstanceConnectionBindings set, then load the
|
||||
// owning instances (cheap; the blocker list is expected to be tiny).
|
||||
var instanceIds = await _dbContext.InstanceConnectionBindings
|
||||
.Where(b => b.DataConnectionId == dataConnectionId)
|
||||
.Select(b => b.InstanceId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (instanceIds.Count == 0)
|
||||
return Array.Empty<Instance>();
|
||||
|
||||
return await _dbContext.Instances
|
||||
.Where(i => instanceIds.Contains(i.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ public class ManagementActor : ReceiveActor
|
||||
or DeleteNotificationListCommand
|
||||
or UpdateSmtpConfigCommand
|
||||
or CreateDataConnectionCommand or UpdateDataConnectionCommand
|
||||
or DeleteDataConnectionCommand
|
||||
or DeleteDataConnectionCommand or MoveDataConnectionCommand
|
||||
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
|
||||
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
|
||||
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
|
||||
@@ -306,6 +306,7 @@ public class ManagementActor : ReceiveActor
|
||||
CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username),
|
||||
UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username),
|
||||
DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username),
|
||||
MoveDataConnectionCommand cmd => await HandleMoveDataConnection(sp, cmd, user.Username),
|
||||
|
||||
// External Systems
|
||||
ListExternalSystemsCommand => await HandleListExternalSystems(sp),
|
||||
@@ -1373,6 +1374,108 @@ public class ManagementActor : ReceiveActor
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a data connection to another site (M9 / T24a). High-risk + data
|
||||
/// integrity: every guard runs server-side BEFORE the write, and when in
|
||||
/// doubt the move is BLOCKED with a clear error rather than risking an
|
||||
/// orphaned binding/reference. No bindings or name references are rewritten.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleMoveDataConnection(IServiceProvider sp, MoveDataConnectionCommand cmd, string user)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||||
|
||||
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId)
|
||||
?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");
|
||||
|
||||
var sourceSiteId = conn.SiteId;
|
||||
if (sourceSiteId == cmd.TargetSiteId)
|
||||
throw new ManagementCommandException(
|
||||
$"DataConnection '{conn.Name}' (ID {conn.Id}) already belongs to site {cmd.TargetSiteId}.");
|
||||
|
||||
// Guard 1: target site must exist.
|
||||
var targetSite = await repo.GetSiteByIdAsync(cmd.TargetSiteId)
|
||||
?? throw new ManagementCommandException($"Target site with ID {cmd.TargetSiteId} not found.");
|
||||
|
||||
// Guard 2: no name collision at the target site.
|
||||
var targetConnections = await repo.GetDataConnectionsBySiteIdAsync(cmd.TargetSiteId);
|
||||
if (targetConnections.Any(c => c.Id != conn.Id
|
||||
&& string.Equals(c.Name, conn.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
$"Target site '{targetSite.Name}' (ID {cmd.TargetSiteId}) already has a data connection named '{conn.Name}'. " +
|
||||
"Rename one of them before moving.");
|
||||
}
|
||||
|
||||
// Guard 3 (primary data-integrity guard): no InstanceConnectionBinding may
|
||||
// reference the connection. Instances are site-scoped, so a bound
|
||||
// connection cannot leave its site without orphaning the binding.
|
||||
var blockingInstances = await repo.GetInstancesReferencingDataConnectionAsync(conn.Id);
|
||||
if (blockingInstances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", blockingInstances
|
||||
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(i => $"'{i.UniqueName}' (ID {i.Id})"));
|
||||
throw new ManagementCommandException(
|
||||
$"Cannot move data connection '{conn.Name}' (ID {conn.Id}): it is referenced by " +
|
||||
$"{blockingInstances.Count} instance binding(s) on its current site. Rebind or delete the " +
|
||||
$"following instance(s) first: {names}.");
|
||||
}
|
||||
|
||||
// Guard 4 (name-based native-alarm-source references): templates reference
|
||||
// a connection by Name (TemplateNativeAlarmSource.ConnectionName) and
|
||||
// instances may override that name
|
||||
// (InstanceNativeAlarmSourceOverride.ConnectionNameOverride). Moving a
|
||||
// connection changes which physical connection that name resolves to on
|
||||
// the source site, so any such reference to the moving name is a blocker.
|
||||
// Detect and report only — never auto-rewrite.
|
||||
var referenceBlockers = new List<string>();
|
||||
|
||||
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var templates = await templateRepo.GetAllTemplatesAsync();
|
||||
foreach (var template in templates)
|
||||
{
|
||||
foreach (var source in template.NativeAlarmSources)
|
||||
{
|
||||
if (string.Equals(source.ConnectionName, conn.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
referenceBlockers.Add(
|
||||
$"template '{template.Name}' native-alarm-source '{source.Name}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instance-level overrides on the SOURCE site that name this connection
|
||||
// would orphan once the connection leaves the site.
|
||||
var sourceInstances = await repo.GetInstancesBySiteIdAsync(sourceSiteId);
|
||||
foreach (var instance in sourceInstances)
|
||||
{
|
||||
foreach (var ovr in instance.NativeAlarmSourceOverrides)
|
||||
{
|
||||
if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
referenceBlockers.Add(
|
||||
$"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (referenceBlockers.Count > 0)
|
||||
{
|
||||
var details = string.Join(", ", referenceBlockers);
|
||||
throw new ManagementCommandException(
|
||||
$"Cannot move data connection '{conn.Name}' (ID {conn.Id}): its name is referenced by " +
|
||||
$"native-alarm-source binding(s) that would be orphaned or made ambiguous by the move. " +
|
||||
$"Resolve the following reference(s) first: {details}.");
|
||||
}
|
||||
|
||||
// All guards passed: persist the site change and audit.
|
||||
conn.SiteId = cmd.TargetSiteId;
|
||||
await repo.UpdateDataConnectionAsync(conn);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Move", "DataConnection", conn.Id.ToString(), conn.Name, conn);
|
||||
return conn;
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// External System handlers
|
||||
|
||||
Reference in New Issue
Block a user