feat(templates): phase 1 — derived-template schema (additive)

Phase 1 of the design at
docs/plans/2026-05-12-derive-on-compose-design.md.

Additive schema only — no behavior changes. Existing data and code
paths continue to work; subsequent phases will start writing the
new fields.

Template gains:
  IsDerived            true when this row was auto-created to back
                       a composition slot
  OwnerCompositionId   back-ref to the owning TemplateComposition
                       (plain int, not an EF nav property — managed
                       by TemplateService for cascade-delete)

TemplateAttribute / TemplateScript each gain:
  IsInherited          row copied from base and not yet overridden;
                       changes to the base flow downward
  LockedInDerived      on a base, blocks derived from overriding;
                       enforced at the service layer in later phases

EF Core migration AddDerivedTemplateFields adds four columns:
  Templates.IsDerived              bit NOT NULL DEFAULT 0
  Templates.OwnerCompositionId     int NULL
  TemplateAttributes.IsInherited   bit NOT NULL DEFAULT 0
  TemplateAttributes.LockedInDerived bit NOT NULL DEFAULT 0
  TemplateScripts.IsInherited      bit NOT NULL DEFAULT 0
  TemplateScripts.LockedInDerived  bit NOT NULL DEFAULT 0

Existing rows get the defaults. Tests across SiteRuntime / TemplateEngine
/ CentralUI suites stay green (129 / 199 / 159).

Next: phase 2 — wire AddCompositionAsync to derive on compose for
new compositions. Old data still flows the direct-reference path
until phase 3's migration script.
This commit is contained in:
Joseph Doherty
2026-05-12 08:16:24 -04:00
parent a968cefbc2
commit 5615f3d0c7
6 changed files with 1446 additions and 0 deletions

View File

@@ -12,6 +12,21 @@ public class Template
public ICollection<TemplateScript> Scripts { get; set; } = new List<TemplateScript>();
public ICollection<TemplateComposition> Compositions { get; set; } = new List<TemplateComposition>();
/// <summary>
/// True when this template was auto-derived to back a TemplateComposition
/// slot. Derived templates inherit from a base (see <see cref="ParentTemplateId"/>),
/// are owned by their composition row (see <see cref="OwnerCompositionId"/>),
/// and are hidden from the main template tree by default.
/// </summary>
public bool IsDerived { get; set; }
/// <summary>
/// Back-reference to the <see cref="TemplateComposition"/> that owns this
/// derived template. Non-null only when <see cref="IsDerived"/>; cascade-
/// delete when the composition is removed. Always null on base templates.
/// </summary>
public int? OwnerCompositionId { get; set; }
public Template(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));

View File

@@ -13,6 +13,21 @@ public class TemplateAttribute
public string? Description { get; set; }
public string? DataSourceReference { get; set; }
/// <summary>
/// True when this row was copied from the base template and has not been
/// overridden on the derived template. Changes to the base flow downward
/// for inherited rows; an explicit override flips this to false.
/// Always false on base (non-derived) templates.
/// </summary>
public bool IsInherited { get; set; }
/// <summary>
/// Set on a base attribute. When true, derived templates may not override
/// the value — the row is rendered readonly with a 🔒 in the derived UI,
/// and any attempt to update it through the API is rejected.
/// </summary>
public bool LockedInDerived { get; set; }
public TemplateAttribute(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));

View File

@@ -13,6 +13,21 @@ public class TemplateScript
public string? ReturnDefinition { get; set; }
public TimeSpan? MinTimeBetweenRuns { get; set; }
/// <summary>
/// True when this row was copied from the base template and has not been
/// overridden on the derived template. Changes to the base flow downward
/// for inherited rows; an explicit override flips this to false.
/// Always false on base (non-derived) templates.
/// </summary>
public bool IsInherited { get; set; }
/// <summary>
/// Set on a base script. When true, derived templates may not override
/// the script body — the row is rendered readonly with a 🔒 in the derived
/// UI, and any attempt to update it through the API is rejected.
/// </summary>
public bool LockedInDerived { get; set; }
public TemplateScript(string name, string code)
{
Name = name ?? throw new ArgumentNullException(nameof(name));

View File

@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddDerivedTemplateFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsInherited",
table: "TemplateScripts",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LockedInDerived",
table: "TemplateScripts",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsDerived",
table: "Templates",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "OwnerCompositionId",
table: "Templates",
type: "int",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsInherited",
table: "TemplateAttributes",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LockedInDerived",
table: "TemplateAttributes",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsInherited",
table: "TemplateScripts");
migrationBuilder.DropColumn(
name: "LockedInDerived",
table: "TemplateScripts");
migrationBuilder.DropColumn(
name: "IsDerived",
table: "Templates");
migrationBuilder.DropColumn(
name: "OwnerCompositionId",
table: "Templates");
migrationBuilder.DropColumn(
name: "IsInherited",
table: "TemplateAttributes");
migrationBuilder.DropColumn(
name: "LockedInDerived",
table: "TemplateAttributes");
}
}
}

View File

@@ -848,11 +848,17 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<int?>("FolderId")
.HasColumnType("int");
b.Property<bool>("IsDerived")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int?>("OwnerCompositionId")
.HasColumnType("int");
b.Property<int?>("ParentTemplateId")
.HasColumnType("int");
@@ -935,9 +941,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<bool>("IsInherited")
.HasColumnType("bit");
b.Property<bool>("IsLocked")
.HasColumnType("bit");
b.Property<bool>("LockedInDerived")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -1027,9 +1039,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsInherited")
.HasColumnType("bit");
b.Property<bool>("IsLocked")
.HasColumnType("bit");
b.Property<bool>("LockedInDerived")
.HasColumnType("bit");
b.Property<TimeSpan?>("MinTimeBetweenRuns")
.HasColumnType("time");