@@ -22,6 +22,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport ;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums ;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening ;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates ;
using ZB.MOM.WW.ScadaBridge.Commons.Validation ;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase ;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis ;
@@ -450,6 +451,10 @@ public sealed class BundleImporter : IBundleImporter
var content = ReadSessionContent ( session ) ;
// Review 2026-10-21 04/P4 + 08/P1: the template library is read once per
// preview through body-free projections, never as the full tracked graph.
var library = new TemplateLibrary ( _templateRepo ) ;
var items = new List < ImportPreviewItem > ( ) ;
// ---- TemplateFolders ----
@@ -473,8 +478,7 @@ public sealed class BundleImporter : IBundleImporter
. ConfigureAwait ( false ) ;
var hydratedByName = hydratedTemplates
. ToDictionary ( t = > t . Name , t = > t , StringComparer . Ordinal ) ;
var allTemplateStubs = await _templateRepo . GetAllTemplatesAsync ( ct ) . ConfigureAwait ( false ) ;
var templateNameById = allTemplateStubs . ToDictionary ( t = > t . Id , t = > t . Name ) ;
var templateNameById = await library . GetNameByIdAsync ( ct ) . ConfigureAwait ( false ) ;
foreach ( var tDto in content . Templates )
{
hydratedByName . TryGetValue ( tDto . Name , out var existing ) ;
@@ -482,16 +486,26 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- SharedScripts ----
// One read of each library (04/P4); DetectBlockersAsync reuses the same lists
// for its known-resolvable name sets instead of reading them again.
var allSharedScripts = await _templateRepo . GetAllSharedScriptsAsync ( ct ) . ConfigureAwait ( false ) ;
var sharedScriptByName = allSharedScripts
. GroupBy ( x = > x . Name , StringComparer . Ordinal )
. ToDictionary ( g = > g . Key , g = > g . First ( ) , StringComparer . Ordinal ) ;
foreach ( var s in content . SharedScripts )
{
var existing = await _templateRepo . GetSharedScriptByNameAsync ( s . Name , ct ) . ConfigureAwait ( false ) ;
sharedScriptByName . TryGetValue ( s . Name , out var existing ) ;
items . Add ( _diff . CompareSharedScript ( s , existing ) ) ;
}
// ---- ExternalSystems (+ their methods) ----
var allExternalSystems = await _externalRepo . GetAllExternalSystemsAsync ( ct ) . ConfigureAwait ( false ) ;
var externalSystemByName = allExternalSystems
. GroupBy ( x = > x . Name , StringComparer . Ordinal )
. ToDictionary ( g = > g . Key , g = > g . First ( ) , StringComparer . Ordinal ) ;
foreach ( var es in content . ExternalSystems )
{
var existing = await _externalRepo . GetExternalSystemByNameAsync ( es . Name , ct ) . ConfigureAwait ( false ) ;
externalSystemByName . TryGetValue ( es . Name , out var existing ) ;
IReadOnlyList < Commons . Entities . ExternalSystems . ExternalSystemMethod > ? methods = null ;
if ( existing is not null )
{
@@ -622,15 +636,25 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
// GetInstancesByUniqueNamesAsync eagerly Includes all four child nav
// collections (AttributeOverrides / AlarmOverrides / ConnectionBindings /
// NativeAlarmSourceOverrides), so the entity handed to CompareInstance is
// HYDRATED — its children diff correctly instead of every incoming child
// reading as an addition (review item I2). Review 2026-10-21 04/P4: one
// batched read for every bundled instance, and the template / site / area
// names come from the library projection, one site list and one area list
// per site — not four queries per instance.
var existingInstanceByName = content . Instances . Count = = 0
? new Dictionary < string , Instance > ( StringComparer . Ordinal )
: ( await _templateRepo
. GetInstancesByUniqueNamesAsync ( content . Instances . Select ( i = > i . UniqueName ) , ct )
. ConfigureAwait ( false ) )
. ToDictionary ( i = > i . UniqueName , i = > i , StringComparer . Ordinal ) ;
Dictionary < int , string > ? siteIdentifierById = null ;
var areaNameByIdBySite = new Dictionary < int , IReadOnlyDictionary < int , string > > ( ) ;
foreach ( var instDto in content . Instances )
{
// GetInstanceByUniqueNameAsync eagerly Includes all four child nav
// collections (AttributeOverrides / AlarmOverrides / ConnectionBindings /
// NativeAlarmSourceOverrides), so the entity handed to CompareInstance is
// HYDRATED — its children diff correctly instead of every incoming child
// reading as an addition (review item I2).
var existing = await _templateRepo
. GetInstanceByUniqueNameAsync ( instDto . UniqueName , ct ) . ConfigureAwait ( false ) ;
existingInstanceByName . TryGetValue ( instDto . UniqueName , out var existing ) ;
string? existingTemplateName = null ;
string? existingSiteIdentifier = null ;
@@ -640,16 +664,21 @@ public sealed class BundleImporter : IBundleImporter
// The entity stores template/site/area as numeric FKs that can't be
// compared cross-environment, so resolve each to the same stable
// string identity the incoming DTO carries.
var tmpl = await _templateRepo . GetTemplateByIdAsync ( existing . TemplateId , ct ) . ConfigureAwait ( false ) ;
existingTemplateName = tmpl ? . Name ;
existingTemplateName = templateNameById . TryGetValue ( existing . TemplateId , out var tn ) ? tn : null ;
var site = await _siteRepo . GetSiteByIdAsync ( existing . SiteId , ct ) . ConfigureAwait ( false ) ;
existingSiteIdentifier = site ? . SiteIdentifier ;
siteIdentifierById ? ? = ( await _siteRepo . GetAllSitesAsync ( ct ) . ConfigureAwait ( false ) )
. ToDictionary ( x = > x . Id , x = > x . SiteIdentifier ) ;
existingSiteIdentifier = siteIdentifierById . TryGetValue ( existing . SiteId , out var si ) ? si : null ;
if ( existing . AreaId is int areaId )
{
var area = await _templateRepo . GetAreaByIdAsync ( areaId , ct ) . ConfigureAwait ( false ) ;
existingAreaName = area ? . Name ;
if ( ! areaNameByIdBySite . TryGetValue ( existing . SiteId , out var areaNames ) )
{
areaNames = ( await _templateRepo . GetAreasBySiteIdAsync ( existing . SiteId , ct ) . ConfigureAwait ( false ) )
. ToDictionary ( a = > a . Id , a = > a . Name ) ;
areaNameByIdBySite [ existing . SiteId ] = areaNames ;
}
existingAreaName = areaNames . TryGetValue ( areaId , out var an ) ? an : null ;
}
}
@@ -662,7 +691,8 @@ public sealed class BundleImporter : IBundleImporter
content , ResolveTargetSiteAsync , ResolveTargetConnectionsAsync ) . ConfigureAwait ( false ) ;
// ---- Blocker detection ----
items . AddRange ( await DetectBlockersAsync ( content , ResolveTargetConnectionsAsync , ct ) . ConfigureAwait ( false ) ) ;
items . AddRange ( await DetectBlockersAsync (
content , ResolveTargetConnectionsAsync , library , allSharedScripts , allExternalSystems , ct ) . ConfigureAwait ( false ) ) ;
return new ImportPreview ( sessionId , items , requiredSites , requiredConnections ) ;
}
@@ -776,6 +806,9 @@ public sealed class BundleImporter : IBundleImporter
private async Task < IReadOnlyList < ImportPreviewItem > > DetectBlockersAsync (
BundleContentDto content ,
Func < string , Task < IReadOnlyList < DataConnection > > > resolveTargetConnections ,
TemplateLibrary library ,
IReadOnlyList < SharedScript > allSharedScripts ,
IReadOnlyList < ExternalSystemDefinition > allExternalSystems ,
CancellationToken ct )
{
var blockers = new List < ImportPreviewItem > ( ) ;
@@ -790,7 +823,9 @@ public sealed class BundleImporter : IBundleImporter
// its new name, but at preview time no resolution has been chosen yet, so
// the in-bundle name is the original DTO name — which is what an instance
// references. (Explicit rename remap is a D-wave apply-time concern.)
var targetTemplates = await _templateRepo . GetAllTemplatesAsync ( ct ) . ConfigureAwait ( false ) ;
// Members + lock flags are needed below (CollectLockedOverrideWarnings) but
// never script bodies — the body-free, untracked analysis projection (04/P4).
var targetTemplates = await library . GetAnalysisAsync ( ct ) . ConfigureAwait ( false ) ;
var targetTemplateNames = new HashSet < string > ( StringComparer . Ordinal ) ;
foreach ( var t in targetTemplates )
{
@@ -863,8 +898,6 @@ public sealed class BundleImporter : IBundleImporter
}
// Known-resolvable names = (in-bundle) ∪ (already-in-target).
var allSharedScripts = await _templateRepo . GetAllSharedScriptsAsync ( ct ) . ConfigureAwait ( false ) ;
var allExternalSystems = await _externalRepo . GetAllExternalSystemsAsync ( ct ) . ConfigureAwait ( false ) ;
var sharedScriptNames = new HashSet < string > ( StringComparer . Ordinal ) ;
foreach ( var s in content . SharedScripts ) sharedScriptNames . Add ( s . Name ) ;
@@ -1723,8 +1756,16 @@ public sealed class BundleImporter : IBundleImporter
// Skip-resolved DTOs are excluded from the in-bundle name set so
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
// Review 2026-10-21 04/P4 + 08/P1: the template library is read ONCE per
// apply attempt — here, before anything is staged — as the untracked,
// body-free analysis projection, and threaded through every pass that only
// needs ids / names / members. The full tracked graph is loaded only for the
// templates this bundle Overwrites (ApplyTemplatesAsync).
var library = new TemplateLibrary ( _templateRepo ) ;
await library . GetAnalysisAsync ( ct ) . ConfigureAwait ( false ) ;
var ( validationErrors , validationWarnings ) =
await RunSemanticValidationAsync ( content , resolutionMap , ct ) . ConfigureAwait ( false ) ;
await RunSemanticValidationAsync ( content , resolutionMap , library , ct ) . ConfigureAwait ( false ) ;
// Validate every site / connection / template reference the
// site-instance payload depends on BEFORE any row is staged. Running
// this in the validation phase (not as an apply-pass guard) preserves
@@ -1735,7 +1776,7 @@ public sealed class BundleImporter : IBundleImporter
// guards, but in normal operation those never fire.
validationErrors = validationErrors . Count > 0
? validationErrors
: await ValidateSiteInstanceReferencesAsync ( content , resolutionMap , nameMap , ct ) . ConfigureAwait ( false ) ;
: await ValidateSiteInstanceReferencesAsync ( content , resolutionMap , nameMap , library , ct ) . ConfigureAwait ( false ) ;
// Review 2026-10-21 03/R-1 + lane-F follow-up: values the runtime later
// trusts blindly — site gRPC endpoints and names that become actor names —
@@ -1818,8 +1859,8 @@ public sealed class BundleImporter : IBundleImporter
// write itself.
await _dbContext . SaveChangesAsync ( ct ) . ConfigureAwait ( false ) ;
await ResolveAlarmScriptLinksAsync ( 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 ResolveCompositionEdgesAsync ( content . Templates , resolutionMap , library , user , ct ) . ConfigureAwait ( false ) ;
await ResolveInheritanceEdgesAsync ( content . Templates , resolutionMap , library , 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
@@ -1842,7 +1883,7 @@ public sealed class BundleImporter : IBundleImporter
// catch below rolls the whole import back (ChangeTracker.Clear on the
// in-memory provider) BEFORE any of it is persisted; deliberately no
// SaveChanges precedes this check so nothing survives that rollback.
EnsureNoTemplateGraphCycles ( ) ;
EnsureNoTemplateGraphCycles ( await library . GetAnalysisAsync ( ct ) . ConfigureAwait ( false ) ) ;
// ---- Site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
@@ -1857,7 +1898,7 @@ public sealed class BundleImporter : IBundleImporter
// rewires every name-keyed FK to the target environment's ids.
await ApplyInstancesAsync (
content , resolutionMap , siteBySourceIdentifier , connectionMaps ,
user , summary , ct ) . ConfigureAwait ( false ) ;
library , user , summary , ct ) . ConfigureAwait ( false ) ;
// ---- Pre-commit point: stale-instance computation ----
// Everything the import will write is staged on the change tracker at
@@ -1869,7 +1910,7 @@ public sealed class BundleImporter : IBundleImporter
// 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 ) ;
content , resolutionMap , library , ct ) . ConfigureAwait ( false ) ;
await _auditService . LogAsync (
user : user ,
@@ -2012,14 +2053,21 @@ public sealed class BundleImporter : IBundleImporter
/// <summary>
/// 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
/// built from it , so a deployed instance's freshly-computed revision hash
/// will no longer match the hash captured in its <c>DeployedConfigSnapshot</c>
/// at deploy time — it surfaces as "stale" on the Deployments page. This walk s
/// the templates THIS import OVERWROTE, finds each one's deployed instances
/// at deploy time — it surfaces as "stale" on the Deployments page. This start s
/// from the templates THIS import OVERWROTE and — review 2026-10-21 04/C2 —
/// expands that set over the post-apply template graph to every template whose
/// flattened config includes an overwritten one: descendants through
/// <c>ParentTemplateId</c> and templates composing it through
/// <c>TemplateComposition.ComposedTemplateId</c>, transitively (a template that
/// composes a derived template of an overwritten base is affected too). It then
/// finds each affected template's deployed instances
/// (State <see cref="InstanceState.Enabled"/>/<see cref="InstanceState.Disabled"/>
/// with a snapshot), recomputes their current hash through
/// <see cref="IStaleInstanceProbe"/> (which reads the just-staged template
/// rows on the shared change tracker), and collects the ids whose hash drifted.
/// with a snapshot), recomputes their current hash through ONE batch of
/// <see cref="IStaleInstanceProbe"/> (a single flatten session, so a shared
/// template chain is walked once — the probe reads the just-staged template rows
/// on the shared change tracker), and collects the ids whose hash drifted.
/// <para>
/// Freshly-IMPORTED instances are excluded structurally: they enter the target
/// <see cref="InstanceState.NotDeployed"/> (no snapshot), so they're "new", not
@@ -2032,6 +2080,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task < IReadOnlyList < int > > ComputeStaleInstanceIdsAsync (
BundleContentDto content ,
Dictionary < ( string EntityType , string Name ) , ImportResolution > resolutionMap ,
TemplateLibrary library ,
CancellationToken ct )
{
// No flattening pipeline registered → can't recompute hashes. Staleness is
@@ -2043,11 +2092,10 @@ public sealed class BundleImporter : IBundleImporter
// 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 ) ;
// of that name already existed (otherwise the Overwrite branch falls through
// to Add — see ApplyTemplatesAsync). The pre-write library maps those names
// to their (unchanged) target ids.
var targetTemplateIdByName = await library . GetIdByNameAsync ( ct ) . ConfigureAwait ( false ) ;
var overwrittenTemplateIds = new HashSet < int > ( ) ;
foreach ( var dto in content . Templates )
@@ -2065,57 +2113,118 @@ public sealed class BundleImporter : IBundleImporter
return Array . Empty < int > ( ) ;
}
var staleIds = new List < int > ( ) ;
foreach ( var templateId in overwrittenTemplateIds )
var affectedTemplateIds = ExpandToDependentTemplates (
overwrittenTemplateIds , MergeTemplateGraph ( await library . GetAnalysisAsync ( ct ) . ConfigureAwait ( false ) ) ) ;
// Candidate instances: deployed instances of any affected template. The
// repository read is tracked, so an instance this import just reset to
// NotDeployed reads back with its staged state and is skipped as "new".
var candidates = new List < ( int InstanceId , int TemplateId ) > ( ) ;
foreach ( var templateId in affectedTemplateIds )
{
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 ) )
if ( instance . State is 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 ) ;
candidates . Add ( ( instance . Id , templateId ) ) ;
}
}
}
if ( candidates . Count = = 0 )
{
return Array . Empty < int > ( ) ;
}
// Latest snapshot hash per candidate in one projected read (no config JSON).
// A deployed instance has a snapshot; its absence means the instance is not
// actually deployed (defensive — State already gated this), so there is no
// baseline to compare against.
var candidateIds = candidates . Select ( c = > c . InstanceId ) . ToList ( ) ;
var snapshotRows = await _dbContext . DeployedConfigSnapshots
. AsNoTracking ( )
. Where ( s = > candidateIds . Contains ( s . InstanceId ) )
. Select ( s = > new { s . InstanceId , s . DeployedAt , s . RevisionHash } )
. ToListAsync ( ct )
. ConfigureAwait ( false ) ;
var deployedHashByInstance = snapshotRows
. GroupBy ( r = > r . InstanceId )
. ToDictionary ( g = > g . Key , g = > g . OrderByDescending ( r = > r . DeployedAt ) . First ( ) . RevisionHash ) ;
var probe = _staleInstanceProbe . BeginBatch ( ) ;
var staleIds = new List < int > ( ) ;
foreach ( var ( instanceId , templateId ) in candidates )
{
if ( ! deployedHashByInstance . TryGetValue ( instanceId , out var deployedHash ) ) continue ;
try
{
var currentHash = await probe . GetCurrentRevisionHashAsync ( instanceId , ct ) . ConfigureAwait ( false ) ;
// null => hash indeterminate; don't claim staleness either way.
if ( currentHash is null ) continue ;
if ( ! string . Equals ( currentHash , deployedHash , StringComparison . Ordinal ) )
{
staleIds . Add ( instanceId ) ;
}
}
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." ,
instanceId , templateId ) ;
}
}
return staleIds ;
}
/// <summary>
/// Review 2026-10-21 04/C2: closes <paramref name="seedTemplateIds"/> over the
/// "my flattened config includes yours" relation — a template depends on its
/// base (<c>ParentTemplateId</c>) and on every module it composes — so the result
/// is every template whose flattened output can change when a seed changes.
/// </summary>
/// <param name="seedTemplateIds">The templates whose content changed.</param>
/// <param name="graph">The post-apply template graph.</param>
/// <returns>The seeds plus every transitive dependent.</returns>
internal static HashSet < int > ExpandToDependentTemplates (
IEnumerable < int > seedTemplateIds , IReadOnlyList < Template > graph )
{
var dependentsOf = new Dictionary < int , List < int > > ( ) ;
void AddEdge ( int dependency , int dependent )
{
if ( ! dependentsOf . TryGetValue ( dependency , out var list ) )
{
list = new List < int > ( ) ;
dependentsOf [ dependency ] = list ;
}
list . Add ( dependent ) ;
}
foreach ( var t in graph )
{
if ( t . ParentTemplateId is int parentId ) AddEdge ( parentId , t . Id ) ;
foreach ( var c in t . Compositions ) AddEdge ( c . ComposedTemplateId , t . Id ) ;
}
var affected = new HashSet < int > ( seedTemplateIds ) ;
var queue = new Queue < int > ( affected ) ;
while ( queue . Count > 0 )
{
if ( ! dependentsOf . TryGetValue ( queue . Dequeue ( ) , out var dependents ) ) continue ;
foreach ( var d in dependents )
{
if ( affected . Add ( d ) ) queue . Enqueue ( d ) ;
}
}
return affected ;
}
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
private sealed class ImportSummary
{
@@ -2205,8 +2314,17 @@ public sealed class BundleImporter : IBundleImporter
CancellationToken ct )
{
if ( dtos . Count = = 0 ) return ;
var stubs = await _templateRepo . GetAllTemplatesAsync ( ct ) . ConfigureAwait ( false ) ;
var byName = stubs . ToDictionary ( t = > t . Name , t = > t , StringComparer . Ordinal ) ;
// Only an Overwrite needs the existing target row, and it needs it tracked
// and fully hydrated (the child syncs mutate it). Load exactly those rows in
// one bulk read instead of the whole library (review 2026-10-21 04/P4).
var overwriteNames = dtos
. Where ( d = > ResolveOrDefault ( map , "Template" , d . Name ) . Action = = ResolutionAction . Overwrite )
. Select ( d = > d . Name )
. ToList ( ) ;
var byName = ( overwriteNames . Count = = 0
? Array . Empty < Template > ( )
: await _templateRepo . GetTemplatesWithChildrenAsync ( overwriteNames , ct ) . ConfigureAwait ( false ) )
. ToDictionary ( t = > t . Name , t = > t , StringComparer . Ordinal ) ;
// Folder ids must be materialised before we wire templates' FolderId
// FKs. ApplyTemplateFoldersAsync staged the rows via AddFolderAsync
@@ -2985,6 +3103,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task ResolveCompositionEdgesAsync (
IReadOnlyList < TemplateDto > dtos ,
Dictionary < ( string , string ) , ImportResolution > resolutionMap ,
TemplateLibrary library ,
string user ,
CancellationToken ct )
{
@@ -3045,14 +3164,17 @@ public sealed class BundleImporter : IBundleImporter
// tracked Local set (which includes anything just imported,
// pre-flush or post-flush), then against the wider target DB
// (pre-existing rows not staged in this transaction).
// The fallback is a dictionary lookup into the per-apply library
// snapshot (04/P4) — a name not in Local is a pre-existing target
// template this apply has not touched, so its pre-write id is current.
var composedStub = _dbContext . Templates . Local . FirstOrDefault ( t = >
string . Equals ( t . Name , compDto . ComposedTemplateName , StringComparison . Ordinal ) ) ;
int? composedId = composedStub ? . Id ;
if ( composedId is null )
if ( composedId is null
& & ( await library . GetIdByNameAsync ( ct ) . ConfigureAwait ( false ) )
. TryGetValue ( compDto . ComposedTemplateName , out var libraryId ) )
{
var allTargets = await _templateRepo . GetAllTemplatesAsync ( ct ) . ConfigureAwait ( false ) ;
composedId = allTargets
. FirstOrDefault ( t = > string . Equals ( t . Name , compDto . ComposedTemplateName , StringComparison . Ordinal ) ) ? . Id ;
composedId = libraryId ;
}
if ( composedId is null )
@@ -3097,6 +3219,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task ResolveInheritanceEdgesAsync (
IReadOnlyList < TemplateDto > dtos ,
Dictionary < ( string , string ) , ImportResolution > resolutionMap ,
TemplateLibrary library ,
string user ,
CancellationToken ct )
{
@@ -3136,11 +3259,10 @@ public sealed class BundleImporter : IBundleImporter
var baseStub = _dbContext . Templates . Local . FirstOrDefault ( t = >
string . Equals ( t . Name , baseName , StringComparison . Ordinal ) ) ;
int? baseId = baseStub ? . Id ;
if ( baseId is null )
if ( baseId is null
& & ( await library . GetIdByNameAsync ( ct ) . ConfigureAwait ( false ) ) . TryGetValue ( baseName , out var libraryId ) )
{
var allTargets = await _templateRepo . GetAllTemplatesAsync ( ct ) . ConfigureAwait ( false ) ;
baseId = allTargets
. FirstOrDefault ( t = > string . Equals ( t . Name , baseName , StringComparison . Ordinal ) ) ? . Id ;
baseId = libraryId ;
}
if ( baseId is null )
@@ -3179,19 +3301,19 @@ public sealed class BundleImporter : IBundleImporter
/// on the first cycle found; the caller's catch rolls the whole import back.
/// </summary>
/// <remarks>
/// The template list is taken from the change tracker rather than a fresh query
/// so it reflects the staged edges without a premature flush. It is complete for
/// cycle detection: any edge that references a pre-existing target template forces
/// a full <c>GetAllTe mpla tesAsync</c> load in the rewire passes (tracking every
/// template), and any all-imported cycle consists solely of tracked (Local)
/// templates — so every node of any candidate cycle is present.
/// The graph is <see cref="MergeTemplateGraph"/>: the change tracker's templates
/// (staged, not-yet-saved edges included, without a premature flush) overlaid on
/// the per-apply library snapshot for every template the apply has not touched.
/// Review 2026-10-21 04/P4: co mple teness used to rest on a full tracked
/// <c>GetAllTemplatesAsync</c> load putting every template on the change
/// tracker; the untouched part of the graph now comes from the snapshot instead,
/// whose edges are current because the apply never stages a change to a template
/// it has not loaded.
/// </remarks>
private void EnsureNoTemplateGraphCycles ( )
/// <param name="library">The pre-write analysis snapshot of the template library.</param>
private void EnsureNoTemplateGraphCycles ( IReadOnlyList < Template > library )
{
var templates = _dbContext . ChangeTracker . Entries < Template > ( )
. Where ( e = > e . State ! = EntityState . Deleted )
. Select ( e = > e . Entity )
. ToList ( ) ;
var templates = MergeTemplateGraph ( library ) ;
if ( templates . Count = = 0 ) return ;
foreach ( var t in templates )
@@ -3223,6 +3345,87 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <summary>
/// The post-apply template graph: every non-deleted template on the change
/// tracker (staged edges included) plus every library-snapshot template that is
/// not tracked. Used by the acyclicity guard and the stale-instance expansion.
/// </summary>
/// <param name="library">The pre-write analysis snapshot of the template library.</param>
/// <returns>One <see cref="Template"/> per id.</returns>
private List < Template > MergeTemplateGraph ( IReadOnlyList < Template > library )
{
var templates = _dbContext . ChangeTracker . Entries < Template > ( )
. Where ( e = > e . State ! = EntityState . Deleted )
. Select ( e = > e . Entity )
. ToList ( ) ;
var trackedIds = templates . Select ( t = > t . Id ) . ToHashSet ( ) ;
templates . AddRange ( library . Where ( t = > ! trackedIds . Contains ( t . Id ) ) ) ;
return templates ;
}
/// <summary>
/// Review 2026-10-21 04/P4 + 08/P1: the per-session view of the TARGET template
/// library. One import used to call the full tracked <c>GetAllTemplatesAsync</c>
/// graph (attributes, alarms, scripts WITH bodies, compositions, native sources)
/// up to nine times, two of them inside per-template loops. Each projection here
/// is read at most once per instance of this class — one per
/// <c>PreviewAsync</c>, one per apply attempt — and every consumer that needs only
/// ids, names, edges or member lock flags reads it instead.
/// </summary>
private sealed class TemplateLibrary
{
private readonly ITemplateEngineRepository _repo ;
private IReadOnlyList < Template > ? _analysis ;
private IReadOnlyList < TemplateStub > ? _stubs ;
private IReadOnlyDictionary < string , int > ? _idByName ;
private IReadOnlyDictionary < int , string > ? _nameById ;
public TemplateLibrary ( ITemplateEngineRepository repo ) = > _repo = repo ;
/// <summary>Untracked templates with members, edges and lock flags, but no script bodies.</summary>
public async Task < IReadOnlyList < Template > > GetAnalysisAsync ( CancellationToken ct )
{
_analysis ? ? = await _repo . GetAllTemplatesForAnalysisAsync ( ct ) . ConfigureAwait ( false ) ;
return _analysis ;
}
/// <summary>Target template name → id (first wins on a duplicate name).</summary>
public async Task < IReadOnlyDictionary < string , int > > GetIdByNameAsync ( CancellationToken ct )
{
if ( _idByName is null )
{
var pairs = await GetPairsAsync ( ct ) . ConfigureAwait ( false ) ;
_idByName = pairs
. GroupBy ( p = > p . Name , StringComparer . Ordinal )
. ToDictionary ( g = > g . Key , g = > g . First ( ) . Id , StringComparer . Ordinal ) ;
}
return _idByName ;
}
/// <summary>Target template id → name.</summary>
public async Task < IReadOnlyDictionary < int , string > > GetNameByIdAsync ( CancellationToken ct )
{
if ( _nameById is null )
{
var pairs = await GetPairsAsync ( ct ) . ConfigureAwait ( false ) ;
_nameById = pairs . ToDictionary ( p = > p . Id , p = > p . Name ) ;
}
return _nameById ;
}
// Id/name pairs from whichever projection is already in hand: the analysis
// load when a caller has made it, else the lighter stub projection.
private async Task < IReadOnlyList < ( int Id , string Name ) > > GetPairsAsync ( CancellationToken ct )
{
if ( _analysis is not null )
{
return _analysis . Select ( t = > ( t . Id , t . Name ) ) . ToList ( ) ;
}
_stubs ? ? = await _repo . GetTemplateStubsAsync ( ct ) . ConfigureAwait ( false ) ;
return _stubs . Select ( t = > ( t . Id , t . Name ) ) . ToList ( ) ;
}
}
private async Task ApplySharedScriptsAsync (
IReadOnlyList < SharedScriptDto > dtos ,
Dictionary < ( string , string ) , ImportResolution > map ,
@@ -4380,17 +4583,39 @@ public sealed class BundleImporter : IBundleImporter
Dictionary < ( string , string ) , ImportResolution > resolutionMap ,
Dictionary < string , Site > siteBySourceIdentifier ,
ResolvedConnectionMaps connectionMaps ,
TemplateLibrary library ,
string user ,
ImportSummary summary ,
CancellationToken ct )
{
if ( content . Instances . Count = = 0 ) return ;
// Build a target-template name→id map once (just-imported templates were
// flushed before this pass; pre-existing target templates count too).
var templateIdByName = ( await _templateRepo . GetAllTemplatesAsync ( ct ) . ConfigureAwait ( false ) )
. GroupBy ( t = > t . Name , StringComparer . Ordinal )
. ToDictionary ( g = > g . Key , g = > g . First ( ) . Id , StringComparer . Ordinal ) ;
// Build a target-template name→id map once: the pre-write library snapshot
// for pre-existing templates, overlaid with the tracked (Local) templates —
// which include everything this apply added or renamed, flushed (so their ids
// are real) before this pass (review 2026-10-21 04/P4).
var templateIdByName = new Dictionary < string , int > (
await library . GetIdByNameAsync ( ct ) . ConfigureAwait ( false ) , StringComparer . Ordinal ) ;
foreach ( var local in _dbContext . Templates . Local )
{
templateIdByName [ local . Name ] = local . Id ;
}
// One batched, tracked, hydrated read of every existing target instance this
// pass may touch — the bundle's names plus every Rename target — instead of a
// GetInstanceByUniqueNameAsync per instance (04/P4).
var lookupNames = new HashSet < string > ( StringComparer . Ordinal ) ;
foreach ( var dto in content . Instances )
{
var r = ResolveOrDefault ( resolutionMap , "Instance" , dto . UniqueName ) ;
if ( r . Action = = ResolutionAction . Skip ) continue ;
lookupNames . Add ( dto . UniqueName ) ;
if ( r . Action = = ResolutionAction . Rename & & ! string . IsNullOrEmpty ( r . RenameTo ) ) lookupNames . Add ( r . RenameTo ) ;
}
var existingByName = lookupNames . Count = = 0
? new Dictionary < string , Instance > ( StringComparer . Ordinal )
: ( await _templateRepo . GetInstancesByUniqueNamesAsync ( lookupNames , ct ) . ConfigureAwait ( false ) )
. ToDictionary ( i = > i . UniqueName , i = > i , StringComparer . Ordinal ) ;
// Memoise area resolution per (siteId, areaName) so two instances under the
// same area don't each create a duplicate row.
@@ -4431,7 +4656,7 @@ public sealed class BundleImporter : IBundleImporter
case ResolutionAction . Rename :
{
var name = resolution . RenameTo ? ? dto . UniqueName ;
if ( await _templateRepo . GetInstanceByUniqueNameAsync ( name , ct ) . ConfigureAwait ( false ) is not null )
if ( existingByName . ContainsKey ( name ) )
throw new InvalidOperationException ( $"An instance named '{name}' already exists. Choose a different rename target." ) ;
var inst = BuildInstance (
dto , name , templateId , targetSite , areaId , connectionMaps ) ;
@@ -4444,8 +4669,7 @@ public sealed class BundleImporter : IBundleImporter
}
case ResolutionAction . Overwrite :
{
var existing = await _templateRepo
. GetInstanceByUniqueNameAsync ( dto . UniqueName , ct ) . ConfigureAwait ( false ) ;
existingByName . TryGetValue ( dto . UniqueName , out var existing ) ;
if ( existing is null )
{
// Overwrite chosen but no target row — treat as Add (the
@@ -4716,6 +4940,7 @@ public sealed class BundleImporter : IBundleImporter
BundleContentDto content ,
Dictionary < ( string , string ) , ImportResolution > resolutionMap ,
BundleNameMap nameMap ,
TemplateLibrary library ,
CancellationToken ct )
{
if ( content . Instances . Count = = 0 & & content . DataConnections . Count = = 0 & & content . Sites . Count = = 0 )
@@ -4819,7 +5044,7 @@ public sealed class BundleImporter : IBundleImporter
bundleTemplateNames . Add ( resolution . RenameTo ) ;
}
var targetTemplateNames = new HashSet < string > (
( await _templateRepo . GetAllTemplatesAsync ( ct ) . ConfigureAwait ( false ) ) . Select ( t = > t . Name ) ,
( await library . GetIdByNameAsync ( ct ) . ConfigureAwait ( false ) ) . Keys ,
StringComparer . Ordinal ) ;
foreach ( var inst in content . Instances )
@@ -4869,6 +5094,7 @@ public sealed class BundleImporter : IBundleImporter
private async Task < ( IReadOnlyList < string > Errors , IReadOnlyList < string > Warnings ) > RunSemanticValidationAsync (
BundleContentDto content ,
Dictionary < ( string , string ) , ImportResolution > resolutionMap ,
TemplateLibrary library ,
CancellationToken ct )
{
var errors = new List < string > ( ) ;
@@ -4913,7 +5139,7 @@ public sealed class BundleImporter : IBundleImporter
// N4: advisory warnings for instance overrides that target a LOCKED template
// member. Best-effort, non-blocking (rides ImportResult.Warnings); the row is
// still written (bundle fidelity) — the flattener is the behavioural authority.
var lockScanTemplates = await _templateRepo . GetAllTemplatesAsync ( ct ) . ConfigureAwait ( false ) ;
var lockScanTemplates = await library . GetAnalysisAsync ( ct ) . ConfigureAwait ( false ) ;
foreach ( var ( _ , message ) in CollectLockedOverrideWarnings ( content , resolutionMap , lockScanTemplates ) )
{
warnings . Add ( message ) ;