diff --git a/docs/plans/2026-05-18-contained-template-names-design.md b/docs/plans/2026-05-18-contained-template-names-design.md
index ad9aa4c..5f37fc8 100644
--- a/docs/plans/2026-05-18-contained-template-names-design.md
+++ b/docs/plans/2026-05-18-contained-template-names-design.md
@@ -1,7 +1,7 @@
# Contained Names for Composition-Derived Templates — Design
**Date:** 2026-05-18
-**Status:** Approved (brainstorming) — implementation to follow
+**Status:** Implemented
## Context
diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md
index bdfb196..61a231f 100644
--- a/docs/requirements/Component-TemplateEngine.md
+++ b/docs/requirements/Component-TemplateEngine.md
@@ -81,6 +81,18 @@ When a template composes a feature module, members from that module are addresse
- The composing template's own members (not from a module) have no prefix — they are top-level names.
- Naming collision detection operates on canonical names, so two modules can define the same member name as long as their module instance names differ.
+### Derived template naming
+
+A composition slot is materialized as its own *derived* template. A derived
+template stores a **contained name** — the composition slot's instance name
+(e.g. `Pump`), unique only within its owner. The **qualified name**
+(`Motor Controller.Pump`, or `Motor Controller.Pump.TempSensor` when nested) is
+*computed* on read by walking the owner-composition chain — it is not stored.
+Only base (user-authored) templates are globally unique by name; a derived
+template's uniqueness is the slot-name uniqueness within its owner. The Central
+UI shows the contained name as the template title and the qualified path as a
+breadcrumb.
+
## Override Granularity
Override and lock rules apply per entity type at the following granularity:
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor
index 64c8840..6732540 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor
@@ -236,7 +236,7 @@
@_baseTemplate.Name
@if (_ownerTemplate != null && _ownerComposition != null)
{
- — composed inside @_ownerTemplate.Name as @_ownerComposition.InstanceName.
+ — composed inside @QualifiedTemplateName(_ownerTemplate) as @_ownerComposition.InstanceName.
}
@@ -248,6 +248,12 @@
{
inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)
}
+ @if (_selectedTemplate.IsDerived)
+ {
+ @* Derived templates store a contained name; show the full
+ qualified path as a breadcrumb subtitle. *@
+
@QualifiedTemplateName(_selectedTemplate)
+ }
@@ -1701,6 +1707,18 @@
else { _toast.ShowError(result.Error); }
}
+ ///
+ /// Computes a template's qualified (hierarchical) name from the loaded
+ /// template set — the stored name for a base template, the dotted
+ /// owner-chain path for a composition-derived one.
+ ///
+ private string QualifiedTemplateName(Template template)
+ {
+ var byId = _templates.ToDictionary(t => t.Id);
+ var compById = _templates.SelectMany(t => t.Compositions).ToDictionary(c => c.Id);
+ return TemplateNaming.QualifiedName(template, byId, compById);
+ }
+
// ---- Editor metadata builders ----
private async Task> BuildChildContextsAsync(
diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs
index 4c58ac9..a568d8f 100644
--- a/src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs
+++ b/src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs
@@ -17,7 +17,12 @@ public class TemplateConfiguration : IEntityTypeConfiguration
builder.Property(t => t.Description)
.HasMaxLength(2000);
- builder.HasIndex(t => t.Name).IsUnique();
+ // Only base (user-authored) templates are globally unique by name.
+ // Derived templates store their *contained* name (the composition slot's
+ // InstanceName), unique only within the owner — enforced by the
+ // (TemplateId, InstanceName) index on TemplateComposition — so they are
+ // excluded from this index via a filter.
+ builder.HasIndex(t => t.Name).IsUnique().HasFilter("[IsDerived] = 0");
// Self-referencing parent template (inheritance)
builder.HasOne()
diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.Designer.cs
new file mode 100644
index 0000000..59622e1
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.Designer.cs
@@ -0,0 +1,1351 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using ScadaLink.ConfigurationDatabase;
+
+#nullable disable
+
+namespace ScadaLink.ConfigurationDatabase.Migrations
+{
+ [DbContext(typeof(ScadaLinkDbContext))]
+ [Migration("20260518214444_ContainedDerivedTemplateNames")]
+ partial class ContainedDerivedTemplateNames
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Xml")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("AfterStateJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("EntityId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("EntityName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("User")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Action");
+
+ b.HasIndex("EntityId");
+
+ b.HasIndex("EntityType");
+
+ b.HasIndex("Timestamp");
+
+ b.HasIndex("User");
+
+ b.ToTable("AuditLogEntries");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConfigurationJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeploymentId");
+
+ b.HasIndex("InstanceId")
+ .IsUnique();
+
+ b.ToTable("DeployedConfigSnapshots");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedBy")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("DeploymentId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ErrorMessage")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("RevisionHash")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeployedAt");
+
+ b.HasIndex("DeploymentId")
+ .IsUnique();
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("DeploymentRecords");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ArtifactType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("DeployedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeployedBy")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("PerSiteStatus")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeployedAt");
+
+ b.ToTable("SystemArtifactDeploymentRecords");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConnectionString")
+ .IsRequired()
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("DatabaseConnectionDefinitions");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AuthConfiguration")
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AuthType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("EndpointUrl")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ExternalSystemDefinitions");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ExternalSystemDefinitionId")
+ .HasColumnType("int");
+
+ b.Property("HttpMethod")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExternalSystemDefinitionId", "Name")
+ .IsUnique();
+
+ b.ToTable("ExternalSystemMethods");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("IsEnabled")
+ .HasColumnType("bit");
+
+ b.Property("KeyHash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("KeyHash")
+ .IsUnique();
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ApprovedApiKeyIds")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Script")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TimeoutSeconds")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ApiMethods");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParentAreaId")
+ .HasColumnType("int");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentAreaId");
+
+ b.HasIndex("SiteId", "ParentAreaId", "Name")
+ .IsUnique()
+ .HasFilter("[ParentAreaId] IS NOT NULL");
+
+ b.ToTable("Areas");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AreaId")
+ .HasColumnType("int");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("TemplateId")
+ .HasColumnType("int");
+
+ b.Property("UniqueName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AreaId");
+
+ b.HasIndex("TemplateId");
+
+ b.HasIndex("SiteId", "UniqueName")
+ .IsUnique();
+
+ b.ToTable("Instances");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlarmCanonicalName")
+ .IsRequired()
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("PriorityLevelOverride")
+ .HasColumnType("int");
+
+ b.Property("TriggerConfigurationOverride")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "AlarmCanonicalName")
+ .IsUnique();
+
+ b.ToTable("InstanceAlarmOverrides");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AttributeName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.Property("OverrideValue")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "AttributeName")
+ .IsUnique();
+
+ b.ToTable("InstanceAttributeOverrides");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AttributeName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("DataConnectionId")
+ .HasColumnType("int");
+
+ b.Property("InstanceId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DataConnectionId");
+
+ b.HasIndex("InstanceId", "AttributeName")
+ .IsUnique();
+
+ b.ToTable("InstanceConnectionBindings");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("NotificationLists");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("EmailAddress")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("NotificationListId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NotificationListId");
+
+ b.ToTable("NotificationRecipients");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AuthType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ConnectionTimeoutSeconds")
+ .HasColumnType("int");
+
+ b.Property("Credentials")
+ .HasMaxLength(8000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("FromAddress")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("MaxConcurrentConnections")
+ .HasColumnType("int");
+
+ b.Property("MaxRetries")
+ .HasColumnType("int");
+
+ b.Property("Port")
+ .HasColumnType("int");
+
+ b.Property("RetryDelay")
+ .HasColumnType("time");
+
+ b.Property("TlsMode")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.ToTable("SmtpConfigurations");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("SharedScripts");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("LdapGroupName")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LdapGroupName")
+ .IsUnique();
+
+ b.ToTable("LdapGroupMappings");
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ LdapGroupName = "SCADA-Admins",
+ Role = "Admin"
+ },
+ new
+ {
+ Id = 2,
+ LdapGroupName = "SCADA-Designers",
+ Role = "Design"
+ },
+ new
+ {
+ Id = 3,
+ LdapGroupName = "SCADA-Deploy-All",
+ Role = "Deployment"
+ },
+ new
+ {
+ Id = 4,
+ LdapGroupName = "SCADA-Deploy-SiteA",
+ Role = "Deployment"
+ });
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("LdapGroupMappingId")
+ .HasColumnType("int");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SiteId");
+
+ b.HasIndex("LdapGroupMappingId", "SiteId")
+ .IsUnique();
+
+ b.ToTable("SiteScopeRules");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("BackupConfiguration")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("FailoverRetryCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(3);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("PrimaryConfiguration")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("Protocol")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("SiteId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SiteId", "Name")
+ .IsUnique();
+
+ b.ToTable("DataConnections");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("GrpcNodeAAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("GrpcNodeBAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("NodeAAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("NodeBAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("SiteIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("SiteIdentifier")
+ .IsUnique();
+
+ b.ToTable("Sites");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("FolderId")
+ .HasColumnType("int");
+
+ b.Property("IsDerived")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("OwnerCompositionId")
+ .HasColumnType("int");
+
+ b.Property("ParentTemplateId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FolderId");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasFilter("[IsDerived] = 0");
+
+ b.HasIndex("ParentTemplateId");
+
+ b.ToTable("Templates");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("IsInherited")
+ .HasColumnType("bit");
+
+ b.Property("IsLocked")
+ .HasColumnType("bit");
+
+ b.Property("LockedInDerived")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("OnTriggerScriptId")
+ .HasColumnType("int");
+
+ b.Property("PriorityLevel")
+ .HasColumnType("int");
+
+ b.Property("TemplateId")
+ .HasColumnType("int");
+
+ b.Property("TriggerConfiguration")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("TriggerType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TemplateId", "Name")
+ .IsUnique();
+
+ b.ToTable("TemplateAlarms");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("DataSourceReference")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("IsInherited")
+ .HasColumnType("bit");
+
+ b.Property("IsLocked")
+ .HasColumnType("bit");
+
+ b.Property("LockedInDerived")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("TemplateId")
+ .HasColumnType("int");
+
+ b.Property("Value")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TemplateId", "Name")
+ .IsUnique();
+
+ b.ToTable("TemplateAttributes");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ComposedTemplateId")
+ .HasColumnType("int");
+
+ b.Property("InstanceName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("TemplateId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ComposedTemplateId");
+
+ b.HasIndex("TemplateId", "InstanceName")
+ .IsUnique();
+
+ b.ToTable("TemplateCompositions");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParentFolderId")
+ .HasColumnType("int");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentFolderId", "Name")
+ .IsUnique()
+ .HasFilter("[ParentFolderId] IS NOT NULL");
+
+ b.ToTable("TemplateFolders");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsInherited")
+ .HasColumnType("bit");
+
+ b.Property("IsLocked")
+ .HasColumnType("bit");
+
+ b.Property("LockedInDerived")
+ .HasColumnType("bit");
+
+ b.Property("MinTimeBetweenRuns")
+ .HasColumnType("time");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ParameterDefinitions")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ReturnDefinition")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("TemplateId")
+ .HasColumnType("int");
+
+ b.Property("TriggerConfiguration")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("TriggerType")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TemplateId", "Name")
+ .IsUnique();
+
+ b.ToTable("TemplateScripts");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
+ .WithMany()
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
+ .WithMany()
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null)
+ .WithMany()
+ .HasForeignKey("ExternalSystemDefinitionId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null)
+ .WithMany("Children")
+ .HasForeignKey("ParentAreaId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null)
+ .WithMany()
+ .HasForeignKey("SiteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null)
+ .WithMany()
+ .HasForeignKey("AreaId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null)
+ .WithMany()
+ .HasForeignKey("SiteId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null)
+ .WithMany()
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
+ .WithMany("AlarmOverrides")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
+ .WithMany("AttributeOverrides")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null)
+ .WithMany()
+ .HasForeignKey("DataConnectionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
+ .WithMany("ConnectionBindings")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null)
+ .WithMany("Recipients")
+ .HasForeignKey("NotificationListId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null)
+ .WithMany()
+ .HasForeignKey("LdapGroupMappingId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null)
+ .WithMany()
+ .HasForeignKey("SiteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null)
+ .WithMany()
+ .HasForeignKey("SiteId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null)
+ .WithMany()
+ .HasForeignKey("FolderId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null)
+ .WithMany()
+ .HasForeignKey("ParentTemplateId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null)
+ .WithMany("Alarms")
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null)
+ .WithMany("Attributes")
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null)
+ .WithMany()
+ .HasForeignKey("ComposedTemplateId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null)
+ .WithMany("Compositions")
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null)
+ .WithMany()
+ .HasForeignKey("ParentFolderId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b =>
+ {
+ b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null)
+ .WithMany("Scripts")
+ .HasForeignKey("TemplateId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b =>
+ {
+ b.Navigation("Children");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b =>
+ {
+ b.Navigation("AlarmOverrides");
+
+ b.Navigation("AttributeOverrides");
+
+ b.Navigation("ConnectionBindings");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b =>
+ {
+ b.Navigation("Recipients");
+ });
+
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b =>
+ {
+ b.Navigation("Alarms");
+
+ b.Navigation("Attributes");
+
+ b.Navigation("Compositions");
+
+ b.Navigation("Scripts");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.cs
new file mode 100644
index 0000000..486b7fc
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.cs
@@ -0,0 +1,81 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ScadaLink.ConfigurationDatabase.Migrations
+{
+ ///
+ /// Moves composition-derived templates to AVEVA-style contained names: a
+ /// derived template stores only its slot name (e.g. Pump ), not the
+ /// dotted qualified path (Motor Controller.Pump ). The qualified name
+ /// is computed on read by walking the OwnerComposition chain. The unique
+ /// index on Template.Name becomes filtered to base templates only —
+ /// derived templates' uniqueness is the (TemplateId, InstanceName) index on
+ /// TemplateComposition.
+ ///
+ public partial class ContainedDerivedTemplateNames : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // Drop the global unique index first: derived rows are about to be
+ // renamed to contained names that may duplicate one another or a
+ // base template.
+ migrationBuilder.DropIndex(
+ name: "IX_Templates_Name",
+ table: "Templates");
+
+ // Collapse every derived template's dotted name to its contained
+ // name — the owning composition slot's InstanceName.
+ migrationBuilder.Sql(@"
+ UPDATE t
+ SET t.Name = c.InstanceName
+ FROM Templates t
+ INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
+ WHERE t.IsDerived = 1;");
+
+ // Recreate the uniqueness guarantee for base templates only.
+ migrationBuilder.CreateIndex(
+ name: "IX_Templates_Name",
+ table: "Templates",
+ column: "Name",
+ unique: true,
+ filter: "[IsDerived] = 0");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Templates_Name",
+ table: "Templates");
+
+ // Rebuild the dotted qualified names so the global unique index can
+ // be restored — derived templates' contained names are not globally
+ // unique. The recursive CTE walks the OwnerComposition chain down
+ // from each base template.
+ migrationBuilder.Sql(@"
+ WITH q AS (
+ SELECT t.Id, CAST(t.Name AS NVARCHAR(MAX)) AS Qualified
+ FROM Templates t
+ WHERE t.IsDerived = 0
+ UNION ALL
+ SELECT t.Id, CAST(q.Qualified + N'.' + c.InstanceName AS NVARCHAR(MAX))
+ FROM Templates t
+ INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
+ INNER JOIN q ON q.Id = c.TemplateId
+ )
+ UPDATE t
+ SET t.Name = q.Qualified
+ FROM Templates t
+ INNER JOIN q ON q.Id = t.Id
+ WHERE t.IsDerived = 1;");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Templates_Name",
+ table: "Templates",
+ column: "Name",
+ unique: true);
+ }
+ }
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs
index 7ec844c..ab2b66e 100644
--- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs
+++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs
@@ -900,7 +900,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.HasIndex("FolderId");
b.HasIndex("Name")
- .IsUnique();
+ .IsUnique()
+ .HasFilter("[IsDerived] = 0");
b.HasIndex("ParentTemplateId");
diff --git a/src/ScadaLink.TemplateEngine/TemplateNaming.cs b/src/ScadaLink.TemplateEngine/TemplateNaming.cs
new file mode 100644
index 0000000..fbee9ee
--- /dev/null
+++ b/src/ScadaLink.TemplateEngine/TemplateNaming.cs
@@ -0,0 +1,53 @@
+using ScadaLink.Commons.Entities.Templates;
+
+namespace ScadaLink.TemplateEngine;
+
+///
+/// Resolves the hierarchical ("qualified") name of a composition-derived
+/// template. A derived template stores only its contained name — the
+/// owning composition slot's InstanceName , unique only within that owner.
+/// The qualified path (Owner.Slot.Slot… ) is computed on demand by
+/// walking the chain up to the base
+/// template.
+///
+public static class TemplateNaming
+{
+ ///
+ /// Returns the dotted hierarchical name of . For
+ /// a base (non-derived) template this is just its stored name. The walk is
+ /// null-safe: if any owner link is missing from the supplied lookups it
+ /// stops and falls back to the stored contained name, and a cycle (which
+ /// the composition graph should never contain) is broken defensively.
+ ///
+ public static string QualifiedName(
+ Template template,
+ IReadOnlyDictionary byId,
+ IReadOnlyDictionary compById)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+ ArgumentNullException.ThrowIfNull(byId);
+ ArgumentNullException.ThrowIfNull(compById);
+
+ return Resolve(template, byId, compById, new HashSet());
+ }
+
+ private static string Resolve(
+ Template template,
+ IReadOnlyDictionary byId,
+ IReadOnlyDictionary compById,
+ HashSet visited)
+ {
+ // Base template, broken owner link, or a cycle → the stored name is the
+ // best (and contained) answer.
+ if (!template.IsDerived
+ || template.OwnerCompositionId is not { } compId
+ || !compById.TryGetValue(compId, out var composition)
+ || !byId.TryGetValue(composition.TemplateId, out var owner)
+ || !visited.Add(template.Id))
+ {
+ return template.Name;
+ }
+
+ return $"{Resolve(owner, byId, compById, visited)}.{composition.InstanceName}";
+ }
+}
diff --git a/src/ScadaLink.TemplateEngine/TemplateService.cs b/src/ScadaLink.TemplateEngine/TemplateService.cs
index fcb74c1..dbfd4fd 100644
--- a/src/ScadaLink.TemplateEngine/TemplateService.cs
+++ b/src/ScadaLink.TemplateEngine/TemplateService.cs
@@ -598,20 +598,9 @@ public class TemplateService
if (collisions.Count > 0)
return Result.Failure(string.Join(" ", collisions));
- // Derived template name uses dot-separated path: ".". The
- // cascade may create additional derived templates one level per slot
- // (composing $Sensor with a Probe1 slot into $Pump produces both
- // $Pump.TempSensor and $Pump.TempSensor.Probe1). Pre-check every name
- // the cascade is about to introduce so a deep collision aborts before
- // any rows mutate.
- var byId = allTemplates.ToDictionary(t => t.Id);
- var cascadeNames = EnumerateCascadeNames(template.Name, instanceName, baseTemplate, byId).ToList();
- var existingNames = allTemplates.Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
- var nameCollision = cascadeNames.FirstOrDefault(n => existingNames.Contains(n));
- if (nameCollision != null)
- return Result.Failure(
- $"Cannot create derived template '{nameCollision}': a template with that name already exists.");
-
+ // No global name pre-check: derived templates store their contained
+ // (slot) name, which need only be unique within the owner — and that is
+ // already enforced above and by the (TemplateId, InstanceName) index.
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
@@ -633,8 +622,10 @@ public class TemplateService
string user,
CancellationToken cancellationToken)
{
- var derivedName = $"{outerTemplate.Name}.{instanceName}";
- var derived = BuildDerivedTemplate(source, derivedName);
+ // A derived template stores its contained name — the slot's instance
+ // name, unique within its owner. The qualified path is computed on read
+ // (see TemplateNaming.QualifiedName).
+ var derived = BuildDerivedTemplate(source, instanceName);
await _repository.AddTemplateAsync(derived, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
@@ -664,19 +655,6 @@ public class TemplateService
return composition;
}
- private static IEnumerable EnumerateCascadeNames(
- string outerName, string instanceName, Template source, IReadOnlyDictionary byId)
- {
- var derivedName = $"{outerName}.{instanceName}";
- yield return derivedName;
- foreach (var comp in source.Compositions)
- {
- if (!byId.TryGetValue(comp.ComposedTemplateId, out var child)) continue;
- foreach (var name in EnumerateCascadeNames(derivedName, comp.InstanceName, child, byId))
- yield return name;
- }
- }
-
public async Task> RenameCompositionAsync(
int compositionId,
string newInstanceName,
@@ -700,33 +678,15 @@ public class TemplateService
return Result.Failure(
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
+ // The derived template stores the slot's contained name, so renaming
+ // the slot renames just that one template. Nested derived templates
+ // keep their own contained names — an ancestor slot rename never
+ // touches them, and the qualified path is recomputed on read.
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
{
- var newDerivedName = $"{owner.Name}.{newInstanceName}";
- var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
-
- // The cascade of derived templates created by AddComposition follows a
- // dotted path (Pump.TempSensor and the nested Pump.TempSensor.Probe1).
- // Renaming the slot must rename every derived template in that cascade
- // so the dotted-path naming invariant holds — pre-check every new name
- // the cascade will introduce before any row mutates.
- var renames = new List<(Template Template, string NewName)>();
- await CollectCascadeRenamesAsync(derived, newDerivedName, renames, cancellationToken);
-
- var renamedIds = renames.Select(r => r.Template.Id).ToHashSet();
- foreach (var (_, newName) in renames)
- {
- if (allTemplates.Any(t => !renamedIds.Contains(t.Id) && t.Name == newName))
- return Result.Failure(
- $"Cannot rename derived template to '{newName}': a template with that name already exists.");
- }
-
- foreach (var (template, newName) in renames)
- {
- template.Name = newName;
- await _repository.UpdateTemplateAsync(template, cancellationToken);
- }
+ derived.Name = newInstanceName;
+ await _repository.UpdateTemplateAsync(derived, cancellationToken);
}
composition.InstanceName = newInstanceName;
@@ -763,30 +723,6 @@ public class TemplateService
return Result.Success(true);
}
- ///
- /// Recursively collects the (template, new name) pairs for a renamed derived
- /// template and every cascaded inner derived template beneath it. Each inner
- /// derived's new name is re-derived from its renamed parent and the slot's
- /// instance name (mirroring the cascade
- /// builds and the recursion in ).
- ///
- private async Task CollectCascadeRenamesAsync(
- Template derived,
- string newName,
- List<(Template Template, string NewName)> renames,
- CancellationToken cancellationToken)
- {
- renames.Add((derived, newName));
-
- foreach (var child in derived.Compositions.ToList())
- {
- var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
- if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
- await CollectCascadeRenamesAsync(
- childDerived, $"{newName}.{child.InstanceName}", renames, cancellationToken);
- }
- }
-
///
/// Recursively deletes a derived template along with the cascade of inner
/// derived templates the compose flow created. Each composition row on the
diff --git a/tests/ScadaLink.TemplateEngine.Tests/TemplateNamingTests.cs b/tests/ScadaLink.TemplateEngine.Tests/TemplateNamingTests.cs
new file mode 100644
index 0000000..a3ab081
--- /dev/null
+++ b/tests/ScadaLink.TemplateEngine.Tests/TemplateNamingTests.cs
@@ -0,0 +1,87 @@
+using ScadaLink.Commons.Entities.Templates;
+
+namespace ScadaLink.TemplateEngine.Tests;
+
+///
+/// Coverage for — the computed
+/// hierarchical name of a composition-derived template. Derived templates store
+/// only their contained name (the composition slot's InstanceName ); the
+/// dotted path is resolved on read by walking the OwnerCompositionId chain.
+///
+public class TemplateNamingTests
+{
+ private static (Dictionary byId, Dictionary compById)
+ BuildGraph(params Template[] templates)
+ {
+ var byId = templates.ToDictionary(t => t.Id);
+ var compById = templates
+ .SelectMany(t => t.Compositions)
+ .ToDictionary(c => c.Id);
+ return (byId, compById);
+ }
+
+ [Fact]
+ public void QualifiedName_BaseTemplate_IsJustItsName()
+ {
+ var motorController = new Template("Motor Controller") { Id = 4 };
+ var (byId, compById) = BuildGraph(motorController);
+
+ Assert.Equal("Motor Controller", TemplateNaming.QualifiedName(motorController, byId, compById));
+ }
+
+ [Fact]
+ public void QualifiedName_OneLevelDerived_PrefixesTheOwner()
+ {
+ // Motor Controller composes the Pump template into a slot named "Pump".
+ var motorController = new Template("Motor Controller") { Id = 4 };
+ motorController.Compositions.Add(
+ new TemplateComposition("Pump") { Id = 1014, TemplateId = 4, ComposedTemplateId = 2018 });
+ var derivedPump = new Template("Pump")
+ {
+ Id = 2018, IsDerived = true, OwnerCompositionId = 1014
+ };
+ var (byId, compById) = BuildGraph(motorController, derivedPump);
+
+ Assert.Equal("Motor Controller.Pump", TemplateNaming.QualifiedName(derivedPump, byId, compById));
+ }
+
+ [Fact]
+ public void QualifiedName_NestedDerived_WalksTheWholeChain()
+ {
+ // Motor Controller -> Pump slot -> TempSensor slot.
+ var motorController = new Template("Motor Controller") { Id = 4 };
+ motorController.Compositions.Add(
+ new TemplateComposition("Pump") { Id = 1014, TemplateId = 4, ComposedTemplateId = 2018 });
+
+ var derivedPump = new Template("Pump")
+ {
+ Id = 2018, IsDerived = true, OwnerCompositionId = 1014
+ };
+ derivedPump.Compositions.Add(
+ new TemplateComposition("TempSensor") { Id = 1015, TemplateId = 2018, ComposedTemplateId = 2019 });
+
+ var derivedTempSensor = new Template("TempSensor")
+ {
+ Id = 2019, IsDerived = true, OwnerCompositionId = 1015
+ };
+ var (byId, compById) = BuildGraph(motorController, derivedPump, derivedTempSensor);
+
+ Assert.Equal(
+ "Motor Controller.Pump.TempSensor",
+ TemplateNaming.QualifiedName(derivedTempSensor, byId, compById));
+ }
+
+ [Fact]
+ public void QualifiedName_DerivedWithMissingOwnerLink_FallsBackToStoredName()
+ {
+ // Defensive: a derived template whose owner composition is not in the
+ // lookup must not throw — it falls back to the stored contained name.
+ var orphan = new Template("TempSensor")
+ {
+ Id = 2019, IsDerived = true, OwnerCompositionId = 9999
+ };
+ var (byId, compById) = BuildGraph(orphan);
+
+ Assert.Equal("TempSensor", TemplateNaming.QualifiedName(orphan, byId, compById));
+ }
+}
diff --git a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs
index bd1f10c..2a19df9 100644
--- a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs
+++ b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs
@@ -359,7 +359,7 @@ public class TemplateServiceTests
Assert.Equal("myModule", result.Value.InstanceName);
Assert.NotNull(captured);
Assert.True(captured!.IsDerived);
- Assert.Equal("Parent.myModule", captured.Name);
+ Assert.Equal("myModule", captured.Name); // contained (slot) name, not the qualified path
Assert.Equal(2, captured.ParentTemplateId);
Assert.Single(captured.Attributes);
Assert.True(captured.Attributes.First().IsInherited);
@@ -372,13 +372,13 @@ public class TemplateServiceTests
[Fact]
public async Task AddComposition_CascadesChildCompositions()
{
- // $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1"
- // Composing $Sensor into $Pump as "TempSensor" should produce:
- // $Pump.TempSensor (derived from $Sensor)
- // $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1)
- // plus a composition row on $Pump.TempSensor pointing at the new inner derived.
+ // $Probe (base) → $Sensor's "Probe1" derived ← $Sensor composes "Probe1"
+ // Composing $Sensor into $Pump as "TempSensor" should produce two derived
+ // templates whose stored names are their *contained* names (the slot
+ // names) — "TempSensor" and "Probe1" — plus a composition row on the
+ // TempSensor derived pointing at the new inner derived.
var probe = new Template("Probe") { Id = 10 };
- var sensorProbe1 = new Template("Sensor.Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
+ var sensorProbe1 = new Template("Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
var sensor = new Template("Sensor") { Id = 2 };
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
var pump = new Template("Pump") { Id = 1 };
@@ -403,9 +403,9 @@ public class TemplateServiceTests
Assert.True(result.IsSuccess);
Assert.Equal(2, captured.Count);
- Assert.Equal("Pump.TempSensor", captured[0].Name);
+ Assert.Equal("TempSensor", captured[0].Name);
Assert.Equal(2, captured[0].ParentTemplateId);
- Assert.Equal("Pump.TempSensor.Probe1", captured[1].Name);
+ Assert.Equal("Probe1", captured[1].Name);
Assert.Equal(11, captured[1].ParentTemplateId);
Assert.Equal(2, capturedCompositions.Count);
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
@@ -428,9 +428,13 @@ public class TemplateServiceTests
}
[Fact]
- public async Task AddComposition_DerivedNameCollision_Fails()
+ public async Task AddComposition_DerivedContainedName_NeedNotBeGloballyUnique()
{
- var existing = new Template("Parent.myModule") { Id = 99 };
+ // A derived template's stored name is its contained (slot) name, which
+ // is only unique within its owner — it may freely coincide with an
+ // unrelated base template's name. Composing a module as the slot
+ // "myModule" succeeds even though a base template "myModule" exists.
+ var existing = new Template("myModule") { Id = 99 };
var moduleTemplate = new Template("Module") { Id = 2 };
var template = new Template("Parent") { Id = 1 };
@@ -439,92 +443,67 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny()))
.ReturnsAsync(new List { template, moduleTemplate, existing });
+ var captured = new List();
+ _repoMock.Setup(r => r.AddTemplateAsync(It.IsAny(), It.IsAny()))
+ .Callback((t, _) => captured.Add(t))
+ .Returns(Task.CompletedTask);
+
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
- Assert.True(result.IsFailure);
- Assert.Contains("already exists", result.Error);
+ Assert.True(result.IsSuccess);
+ Assert.Single(captured);
+ Assert.Equal("myModule", captured[0].Name);
}
[Fact]
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
{
+ // The derived template's stored name is the slot's contained name, so
+ // renaming the slot renames the derived template to the new short name.
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
- var derived = new Template("Pump.OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
+ var derived = new Template("OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny())).ReturnsAsync(derived);
- _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny()))
- .ReturnsAsync(new List { owner, derived });
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("NewSlot", result.Value.InstanceName);
- Assert.Equal("Pump.NewSlot", derived.Name);
+ Assert.Equal("NewSlot", derived.Name);
}
[Fact]
- public async Task RenameComposition_CascadesRenameToNestedDerivedTemplates()
+ public async Task RenameComposition_RenamesOnlyTheSlotsOwnDerivedTemplate()
{
- // Pump.TempSensor is the slot-owned derived; Pump.TempSensor.Probe1 is a
- // cascaded inner derived under it. Renaming the TempSensor slot to
- // MainSensor must rename BOTH derived templates so the dotted-path
- // naming invariant holds: Pump.MainSensor and Pump.MainSensor.Probe1.
+ // With contained names, a nested derived template's name is its own
+ // slot name and is unaffected by renaming an ancestor slot. Renaming
+ // the TempSensor slot to MainSensor renames only that derived template;
+ // the inner "Probe1" derived is left untouched.
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
- var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
+ var innerDerived = new Template("Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
- var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
+ var derived = new Template("TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
derived.Compositions.Add(innerComp);
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny())).ReturnsAsync(innerDerived);
- _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny()))
- .ReturnsAsync(new List { owner, derived, innerDerived });
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("MainSensor", result.Value.InstanceName);
- Assert.Equal("Pump.MainSensor", derived.Name);
- Assert.Equal("Pump.MainSensor.Probe1", innerDerived.Name);
+ Assert.Equal("MainSensor", derived.Name);
+ Assert.Equal("Probe1", innerDerived.Name);
_repoMock.Verify(r => r.UpdateTemplateAsync(
- It.Is(t => t.Id == 78), It.IsAny()), Times.Once);
- }
-
- [Fact]
- public async Task RenameComposition_NestedCascadeNameCollision_Fails()
- {
- // A pre-existing template occupies the name the nested cascade would
- // produce (Pump.MainSensor.Probe1). The rename must abort before any
- // row mutates, so the full cascade name set must be pre-checked.
- var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
- var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
- var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
- var owner = new Template("Pump") { Id = 1 };
- owner.Compositions.Add(composition);
- var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
- derived.Compositions.Add(innerComp);
- var collider = new Template("Pump.MainSensor.Probe1") { Id = 99 };
-
- _repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny())).ReturnsAsync(composition);
- _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(owner);
- _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny())).ReturnsAsync(derived);
- _repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny())).ReturnsAsync(innerDerived);
- _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny()))
- .ReturnsAsync(new List