diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs
new file mode 100644
index 00000000..d7e8903b
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs
@@ -0,0 +1,32 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
+
+///
+/// Computes the CURRENT flattened-config revision hash of a deployed instance —
+/// the same value the deployment pipeline produces and stores in
+/// DeployedConfigSnapshot.RevisionHash. Transport's bundle importer uses
+/// this to enumerate stale instances after a template overwrite (#16): an
+/// instance is stale when its freshly-computed hash no longer matches the hash
+/// captured at deploy time.
+///
+/// The seam lives in Commons so the Transport component can depend on it without
+/// referencing the DeploymentManager project (where the flattening pipeline that
+/// implements it lives). The implementation re-flattens the instance via the
+/// existing flattening pipeline over the SAME scoped DbContext, so it sees
+/// any template rows the in-flight import has staged on the change tracker but
+/// not yet committed.
+///
+///
+public interface IStaleInstanceProbe
+{
+ ///
+ /// Computes the current flattened-config revision hash for the given instance,
+ /// or null when the hash cannot be computed (e.g. the instance or its
+ /// template chain cannot be resolved). Callers treat a null result as
+ /// "cannot determine staleness" and skip the instance — staleness is
+ /// informational, never a reason to abort an import.
+ ///
+ /// The target instance's surrogate id.
+ /// Cancellation token.
+ /// The current revision hash (e.g. sha256:...), or null.
+ Task GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs
index 916af136..5b8d5195 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -40,6 +41,13 @@ public static class ServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+
+ // #16 (M8 D2): expose the flattening pipeline's revision-hash computation
+ // to the Transport bundle importer through the Commons IStaleInstanceProbe
+ // seam, so a template overwrite can enumerate the deployed instances whose
+ // flattened config has drifted from their deployed snapshot. Scoped so it
+ // shares the per-request DbContext with the in-flight import.
+ services.AddScoped();
return services;
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs
new file mode 100644
index 00000000..87d501ee
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs
@@ -0,0 +1,38 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
+
+namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
+
+///
+/// backed by the deployment flattening pipeline.
+/// Re-flattens an instance through and returns
+/// the computed revision hash — the exact same value
+/// DeploymentService.GetDeploymentComparisonAsync compares against the
+/// deployed snapshot to decide staleness. Hosted in DeploymentManager so the
+/// Transport component (which references only Commons + TemplateEngine) can probe
+/// staleness without taking a DeploymentManager project reference.
+///
+public sealed class StaleInstanceProbe : IStaleInstanceProbe
+{
+ private readonly IFlatteningPipeline _flatteningPipeline;
+
+ /// Initializes a new .
+ /// The deployment flattening pipeline used to recompute the current revision hash.
+ public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline)
+ {
+ _flatteningPipeline = flatteningPipeline ?? throw new ArgumentNullException(nameof(flatteningPipeline));
+ }
+
+ ///
+ public async Task GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
+ {
+ // The pipeline returns a Result; a flatten failure (e.g. unresolvable
+ // template chain mid-import) yields null so the caller treats the
+ // instance as "staleness indeterminate" and skips it. Flattening reuses
+ // the scoped ITemplateEngineRepository, so it observes template rows the
+ // in-flight import has staged on the shared change tracker.
+ var result = await _flatteningPipeline
+ .FlattenAndValidateAsync(instanceId, cancellationToken)
+ .ConfigureAwait(false);
+ return result.IsSuccess ? result.Value.RevisionHash : null;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
index 634ace03..c91d64e5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
@@ -1,6 +1,7 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
@@ -73,6 +74,11 @@ public sealed class BundleImporter : IBundleImporter
private readonly IOptions _options;
private readonly TimeProvider _timeProvider;
private readonly SemanticValidator _semanticValidator;
+ // #16 (M8 D2): optional. Implemented in DeploymentManager (where the flattening
+ // pipeline lives) and surfaced via the Commons IStaleInstanceProbe seam. Null
+ // in a host that registers Transport without DeploymentManager — staleness is
+ // then best-effort empty (informational only, never gates the import).
+ private readonly IStaleInstanceProbe? _staleInstanceProbe;
private readonly ILogger? _logger;
///
@@ -95,6 +101,8 @@ public sealed class BundleImporter : IBundleImporter
/// Correlation context that carries the active BundleImportId.
/// EF Core context used to commit the import transaction.
/// Validates template references before applying.
+ /// Optional (#16): recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped.
+ /// Optional logger.
public BundleImporter(
BundleSerializer bundleSerializer,
ManifestValidator manifestValidator,
@@ -113,6 +121,7 @@ public sealed class BundleImporter : IBundleImporter
IAuditCorrelationContext correlationContext,
ScadaBridgeDbContext dbContext,
SemanticValidator semanticValidator,
+ IStaleInstanceProbe? staleInstanceProbe = null,
ILogger? logger = null)
{
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
@@ -132,6 +141,7 @@ public sealed class BundleImporter : IBundleImporter
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
+ _staleInstanceProbe = staleInstanceProbe;
_logger = logger;
}
@@ -1047,6 +1057,8 @@ public sealed class BundleImporter : IBundleImporter
// Array.Empty() placeholder). The single deferred SaveChangesAsync
// is the next statement, so a read against the change tracker here sees
// the full post-apply graph before commit.
+ var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
+ content, resolutionMap, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user: user,
@@ -1089,7 +1101,7 @@ public sealed class BundleImporter : IBundleImporter
Overwritten: summary.Overwritten,
Skipped: summary.Skipped,
Renamed: summary.Renamed,
- StaleInstanceIds: Array.Empty(),
+ StaleInstanceIds: staleInstanceIds,
AuditEventCorrelation: bundleImportId.ToString(),
ApiKeysIgnored: apiKeysIgnored);
}
@@ -1197,6 +1209,113 @@ public sealed class BundleImporter : IBundleImporter
}
}
+ ///
+ /// #16 (M8 D2): enumerates the deployed instances stale-ed by this import.
+ /// Overwriting a template changes the flattened-config hash of every instance
+ /// of that template, so a deployed instance's freshly-computed revision hash
+ /// will no longer match the hash captured in its DeployedConfigSnapshot
+ /// at deploy time — it surfaces as "stale" on the Deployments page. This walks
+ /// the templates THIS import OVERWROTE, finds each one's deployed instances
+ /// (State /
+ /// with a snapshot), recomputes their current hash through
+ /// (which reads the just-staged template
+ /// rows on the shared change tracker), and collects the ids whose hash drifted.
+ ///
+ /// Freshly-IMPORTED instances are excluded structurally: they enter the target
+ /// (no snapshot), so they're "new", not
+ /// "stale". Best-effort: a probe that returns null or throws for one instance
+ /// is skipped (logged), never aborting the import — staleness is informational
+ /// and the import's own success path is authoritative. Returns an empty list
+ /// when no probe is wired (Transport-without-DeploymentManager hosts).
+ ///
+ ///
+ private async Task> ComputeStaleInstanceIdsAsync(
+ BundleContentDto content,
+ Dictionary<(string EntityType, string Name), ImportResolution> resolutionMap,
+ CancellationToken ct)
+ {
+ // No flattening pipeline registered → can't recompute hashes. Staleness is
+ // informational, so degrade gracefully to "none".
+ if (_staleInstanceProbe is null || content.Templates.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ // Which templates did THIS import overwrite? A bundle template is
+ // overwritten when its resolution Action == Overwrite AND a target template
+ // of that name already exists (otherwise the Overwrite branch falls through
+ // to Add — see ApplyTemplatesAsync). Map those names to their target ids.
+ var targetTemplateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
+ .GroupBy(t => t.Name, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
+
+ var overwrittenTemplateIds = new HashSet();
+ foreach (var dto in content.Templates)
+ {
+ var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
+ if (resolution.Action != ResolutionAction.Overwrite) continue;
+ if (targetTemplateIdByName.TryGetValue(dto.Name, out var templateId))
+ {
+ overwrittenTemplateIds.Add(templateId);
+ }
+ }
+
+ if (overwrittenTemplateIds.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var staleIds = new List();
+ foreach (var templateId in overwrittenTemplateIds)
+ {
+ var instances = await _templateRepo
+ .GetInstancesByTemplateIdAsync(templateId, ct).ConfigureAwait(false);
+ foreach (var instance in instances)
+ {
+ // NotDeployed instances (including everything this import just
+ // created/overwrote) are "new", not "stale" — skip them.
+ if (instance.State is not (InstanceState.Enabled or InstanceState.Disabled))
+ {
+ continue;
+ }
+
+ try
+ {
+ // A deployed instance has a snapshot; its absence means the
+ // instance is not actually deployed (defensive — State already
+ // gated this) so there's no baseline to compare against.
+ var snapshot = await _dbContext.DeployedConfigSnapshots
+ .AsNoTracking()
+ .Where(s => s.InstanceId == instance.Id)
+ .OrderByDescending(s => s.DeployedAt)
+ .FirstOrDefaultAsync(ct)
+ .ConfigureAwait(false);
+ if (snapshot is null) continue;
+
+ var currentHash = await _staleInstanceProbe
+ .GetCurrentRevisionHashAsync(instance.Id, ct).ConfigureAwait(false);
+ // null => hash indeterminate; don't claim staleness either way.
+ if (currentHash is null) continue;
+
+ if (!string.Equals(currentHash, snapshot.RevisionHash, StringComparison.Ordinal))
+ {
+ staleIds.Add(instance.Id);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Best-effort: a single instance's hash failure must not abort
+ // the (already-successful) import. Skip it and move on.
+ _logger?.LogWarning(ex,
+ "Stale-instance hash computation failed for instance {InstanceId} (template {TemplateId}); skipping.",
+ instance.Id, templateId);
+ }
+ }
+ }
+
+ return staleIds;
+ }
+
/// Mutable per-apply counter struct, accumulated through every helper.
private sealed class ImportSummary
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs
index 023511b6..2c8c0db2 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs
@@ -2,7 +2,9 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
@@ -13,6 +15,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
+using ZB.MOM.WW.ScadaBridge.DeploymentManager;
+using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
@@ -64,9 +68,16 @@ public sealed class BundleImporterApplyTests : IDisposable
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddTransport();
+ // #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager
+ // and the flatten/hash primitives in TemplateEngine — register both so
+ // BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
+ // compute StaleInstanceIds against genuine revision hashes.
+ services.AddTemplateEngine();
+ services.AddDeploymentManager();
_provider = services.BuildServiceProvider();
}
@@ -926,4 +937,238 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Contains(preview.Items, item =>
item.EntityType == "ApiMethod" && item.Name == "GetStatus");
}
+
+ // ─────────────────────────────────────────────────────────────────────
+ // #16 (M8 D2): StaleInstanceIds enumeration. Overwriting a template changes
+ // its flattened-config hash, so deployed instances of that template drift
+ // from their DeployedConfigSnapshot.RevisionHash and must surface as stale
+ // in the import result. NotDeployed (incl. freshly-imported) instances are
+ // never stale.
+ // ─────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Seeds an Enabled instance of and a
+ /// whose RevisionHash is the GENUINE hash
+ /// the deployment flattening pipeline computes for the instance's CURRENT
+ /// (pre-import) config. Returns the instance id. Mirrors how the deploy path
+ /// captures a snapshot so the staleness comparison is faithful.
+ ///
+ private async Task SeedDeployedInstanceWithRealSnapshotAsync(
+ string templateName, string instanceName)
+ {
+ int instanceId;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var template = await ctx.Templates.SingleAsync(t => t.Name == templateName);
+ var instance = new Instance(instanceName)
+ {
+ TemplateId = template.Id,
+ SiteId = (await ctx.Sites.FirstAsync()).Id,
+ State = InstanceState.Enabled,
+ };
+ ctx.Instances.Add(instance);
+ await ctx.SaveChangesAsync();
+ instanceId = instance.Id;
+
+ // Compute the REAL current revision hash via the same pipeline the
+ // deploy path uses, then persist it as the deployed snapshot so the
+ // import's drift comparison runs against a genuine baseline.
+ var pipeline = scope.ServiceProvider.GetRequiredService();
+ var flattened = await pipeline.FlattenAndValidateAsync(instanceId);
+ Assert.True(flattened.IsSuccess,
+ $"Seed flatten failed: {(flattened.IsFailure ? flattened.Error : "(success)")}");
+ ctx.DeployedConfigSnapshots.Add(new DeployedConfigSnapshot(
+ deploymentId: Guid.NewGuid().ToString(),
+ revisionHash: flattened.Value.RevisionHash,
+ configurationJson: System.Text.Json.JsonSerializer.Serialize(flattened.Value.Configuration))
+ {
+ InstanceId = instanceId,
+ DeployedAt = DateTimeOffset.UtcNow,
+ });
+ await ctx.SaveChangesAsync();
+ }
+ return instanceId;
+ }
+
+ private async Task SeedSiteAsync()
+ {
+ await using var scope = _provider.CreateAsyncScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var site = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("Plant 1", "plant-1")
+ {
+ NodeAAddress = "akka://site@10.0.0.1:2552",
+ NodeBAddress = "akka://site@10.0.0.2:2552",
+ };
+ ctx.Sites.Add(site);
+ await ctx.SaveChangesAsync();
+ return site.Id;
+ }
+
+ [Fact]
+ public async Task ApplyAsync_overwriting_template_marks_deployed_instance_stale()
+ {
+ // Arrange — seed Pump with the BUNDLE's (new) attribute shape, export it,
+ // then mutate the target template + reseed the deployed snapshot to the
+ // OLD shape so an Overwrite restores the new shape and drifts the deployed
+ // instance off its captured hash.
+ await SeedSiteAsync();
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var t = new Template("Pump") { Description = "v2" };
+ t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
+ t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" }); // extra attr in bundle
+ ctx.Templates.Add(t);
+ await ctx.SaveChangesAsync();
+ }
+ var sessionId = await ExportAndLoadAsync();
+
+ // Mutate the target template to the OLD shape (drop Pressure, change Flow),
+ // then deploy an instance capturing the OLD config's hash.
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
+ t.Description = "v1";
+ var pressure = t.Attributes.Single(a => a.Name == "Pressure");
+ t.Attributes.Remove(pressure);
+ ctx.TemplateAttributes.Remove(pressure);
+ t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
+ await ctx.SaveChangesAsync();
+ }
+ var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
+
+ // Act — Overwrite Pump back to the bundle's v2 shape.
+ ImportResult result;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var importer = scope.ServiceProvider.GetRequiredService();
+ result = await importer.ApplyAsync(sessionId,
+ new List { new("Template", "Pump", ResolutionAction.Overwrite, null) },
+ user: "bob");
+ }
+
+ // Assert — the deployed instance drifted off its snapshot hash and is stale.
+ Assert.Equal(1, result.Overwritten);
+ Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_overwriting_template_does_not_mark_unchanged_template_instance_stale()
+ {
+ // Two templates: Pump (overwritten with a CHANGED shape) and Valve
+ // (overwritten with an IDENTICAL shape — its instance must NOT be stale).
+ // The export carries both at their seeded shape; we mutate only Pump in
+ // the target so its overwrite changes the hash, while Valve's overwrite
+ // is a no-op hash-wise.
+ await SeedSiteAsync();
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var pump = new Template("Pump") { Description = "v2" };
+ pump.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
+ pump.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" });
+ ctx.Templates.Add(pump);
+ var valve = new Template("Valve") { Description = "stable" };
+ valve.Attributes.Add(new TemplateAttribute("Position") { DataType = DataType.Float, Value = "0.5" });
+ ctx.Templates.Add(valve);
+ await ctx.SaveChangesAsync();
+ }
+ var sessionId = await ExportAndLoadAsync();
+
+ // Mutate ONLY Pump in the target so its overwrite changes the hash; leave
+ // Valve identical to the bundle so its overwrite is hash-neutral.
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var pump = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
+ var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
+ pump.Attributes.Remove(pressure);
+ ctx.TemplateAttributes.Remove(pressure);
+ await ctx.SaveChangesAsync();
+ }
+ var pumpInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
+ var valveInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Valve", "Valve-Deployed");
+
+ // Act — Overwrite BOTH templates.
+ ImportResult result;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var importer = scope.ServiceProvider.GetRequiredService();
+ result = await importer.ApplyAsync(sessionId,
+ new List
+ {
+ new("Template", "Pump", ResolutionAction.Overwrite, null),
+ new("Template", "Valve", ResolutionAction.Overwrite, null),
+ },
+ user: "bob");
+ }
+
+ // Assert — only Pump's deployed instance is stale; Valve's is not.
+ Assert.Contains(pumpInstanceId, result.StaleInstanceIds);
+ Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds);
+ }
+
+ [Fact]
+ public async Task ApplyAsync_freshly_imported_NotDeployed_instance_is_never_stale()
+ {
+ // A bundle that carries a template AND an instance of it: the imported
+ // instance enters the target NotDeployed (no snapshot), so even though the
+ // template is "new" to the target it must NOT appear in StaleInstanceIds.
+ // We also overwrite an EXISTING deployed instance's template to prove the
+ // stale list contains the deployed one but never the fresh one.
+ await SeedSiteAsync();
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var t = new Template("Pump") { Description = "v2" };
+ t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
+ ctx.Templates.Add(t);
+ await ctx.SaveChangesAsync();
+ }
+ var sessionId = await ExportAndLoadAsync();
+
+ // Mutate the target template, then deploy an instance capturing the OLD hash.
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
+ t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
+ await ctx.SaveChangesAsync();
+ }
+ var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
+
+ // Also seed a NotDeployed instance of the same template (simulates a
+ // never-deployed / freshly-imported instance) — it has no snapshot.
+ int notDeployedInstanceId;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
+ var fresh = new Instance("Pump-Fresh")
+ {
+ TemplateId = t.Id,
+ SiteId = (await ctx.Sites.FirstAsync()).Id,
+ State = InstanceState.NotDeployed,
+ };
+ ctx.Instances.Add(fresh);
+ await ctx.SaveChangesAsync();
+ notDeployedInstanceId = fresh.Id;
+ }
+
+ // Act — Overwrite Pump.
+ ImportResult result;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var importer = scope.ServiceProvider.GetRequiredService();
+ result = await importer.ApplyAsync(sessionId,
+ new List { new("Template", "Pump", ResolutionAction.Overwrite, null) },
+ user: "bob");
+ }
+
+ // Assert — deployed instance is stale; the NotDeployed one is excluded.
+ Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
+ Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs
index 2604d5a4..aedbf179 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs
@@ -323,7 +323,8 @@ public sealed class SiteInstanceImportTests : IDisposable
// Counts: site + connection + instance added (template was Skipped).
Assert.Equal(3, result.Added);
Assert.Equal(1, result.Skipped);
- // D2 has not run yet — StaleInstanceIds stays empty.
+ // No template was overwritten and the imported instance is NotDeployed, so
+ // the D2 (#16) stale-instance enumeration finds nothing.
Assert.Empty(result.StaleInstanceIds);
}
@@ -866,6 +867,131 @@ public sealed class SiteInstanceImportTests : IDisposable
}
}
+ // ──────────────────────────────────────────────────────────────────────
+ // M-4 — native-alarm-source ConnectionNameOverride RENAME-REDIRECT
+ // ──────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ApplyAsync_native_alarm_override_connection_name_rewritten_to_redirected_target_name()
+ {
+ // M-4 regression. The existing tests only cover the same-name identity case
+ // (source connection name == target connection name), so a bug that fails to
+ // rewrite ConnectionNameOverride on a RENAME-REDIRECT would go unnoticed.
+ // Here the operator maps the source connection "SourceConn" to a
+ // DIFFERENTLY-named existing target connection "TargetConn" via an explicit
+ // MapToExisting. After import, the imported instance's native-alarm-source
+ // override (and its connection binding) must carry the TARGET name, not the
+ // source's.
+ int targetConnId;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
+ var site = new Site("Plant 1", "plant-1");
+ ctx.Sites.Add(site);
+ await ctx.SaveChangesAsync();
+ // The target connection is named DIFFERENTLY from the source's.
+ var conn = new DataConnection("TargetConn", "OpcUa", site.Id)
+ {
+ PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target\"}",
+ };
+ ctx.DataConnections.Add(conn);
+ await ctx.SaveChangesAsync();
+ targetConnId = conn.Id;
+ }
+
+ // Hand-pack a bundle whose instance references the SOURCE connection name
+ // "SourceConn" (both in its binding and its native-alarm override). The
+ // bundle does NOT carry the connection — the redirect resolves it against
+ // the existing target "TargetConn".
+ var content = new BundleContentDto(
+ TemplateFolders: Array.Empty(),
+ Templates: Array.Empty(),
+ SharedScripts: Array.Empty(),
+ ExternalSystems: Array.Empty(),
+ DatabaseConnections: Array.Empty(),
+ NotificationLists: Array.Empty(),
+ SmtpConfigs: Array.Empty(),
+ ApiMethods: Array.Empty())
+ {
+ Sites = new[]
+ {
+ new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
+ },
+ DataConnections = Array.Empty(),
+ Instances = new[]
+ {
+ new InstanceDto(
+ UniqueName: "Pump-01",
+ TemplateName: "Pump",
+ SiteIdentifier: "plant-1",
+ AreaName: null,
+ State: InstanceState.Enabled,
+ AttributeOverrides: Array.Empty(),
+ AlarmOverrides: Array.Empty(),
+ NativeAlarmSourceOverrides: new[]
+ {
+ new InstanceNativeAlarmSourceOverrideDto(
+ SourceCanonicalName: "NativeSrc",
+ ConnectionNameOverride: "SourceConn",
+ SourceReferenceOverride: "ns=3;s=Pump.Alarm",
+ ConditionFilterOverride: null),
+ },
+ ConnectionBindings: new[]
+ {
+ new InstanceConnectionBindingDto(
+ AttributeName: "Flow",
+ ConnectionName: "SourceConn",
+ DataSourceReferenceOverride: "ns=3;s=Pump.Flow"),
+ }),
+ },
+ };
+ var sessionId = await PackAndLoadAsync(content);
+
+ // Explicit redirect: source "SourceConn" → target "TargetConn" (DIFFERENT name).
+ var nameMap = new BundleNameMap(
+ Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
+ Connections: new[]
+ {
+ new ConnectionMapping("plant-1", "SourceConn", MappingAction.MapToExisting, "TargetConn"),
+ });
+
+ var result = await ApplyAsync(
+ sessionId,
+ new List
+ {
+ new("Site", "plant-1", ResolutionAction.Skip, null),
+ new("Instance", "Pump-01", ResolutionAction.Add, null),
+ },
+ nameMap);
+
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+
+ // No new connection created — the redirect resolved the existing target.
+ var conn = Assert.Single(await ctx.DataConnections.ToListAsync());
+ Assert.Equal("TargetConn", conn.Name);
+ Assert.Equal(targetConnId, conn.Id);
+
+ var inst = await ctx.Instances
+ .Include(i => i.ConnectionBindings)
+ .Include(i => i.NativeAlarmSourceOverrides)
+ .SingleAsync(i => i.UniqueName == "Pump-01");
+
+ // THE FIX: the native-alarm-source override carries the TARGET name,
+ // not the source's "SourceConn".
+ var native = Assert.Single(inst.NativeAlarmSourceOverrides);
+ Assert.Equal("TargetConn", native.ConnectionNameOverride);
+
+ // The connection binding FK rewires to the redirected target connection.
+ var binding = Assert.Single(inst.ConnectionBindings);
+ Assert.Equal(targetConnId, binding.DataConnectionId);
+ }
+
+ Assert.Equal(1, result.Added);
+ }
+
// ──────────────────────────────────────────────────────────────────────
// C2 — two sites with same-named connections resolve independently
// ──────────────────────────────────────────────────────────────────────