feat(templates): phase 2 — derive-on-compose for new compositions

AddCompositionAsync creates a derived Template ("<parent>.<slot>") that
inherits from the base via ParentTemplateId. Base attributes and scripts
are copied with IsInherited=true so the derived template carries its own
override-able rows. The composition row points at the derived template,
and the derived's OwnerCompositionId back-refs the composition for cascade
delete.

DeleteCompositionAsync cascade-deletes the owned derived template.
DeleteTemplateAsync blocks direct deletion of derived templates and
distinguishes derivatives from regular children, listing slot owners
("'Pump' (as 'TempSensor')") in the error.

Composing a derived template is rejected — only bases can be composed.
Existing compositions still resolve until phase 3 migrates them.
This commit is contained in:
Joseph Doherty
2026-05-12 08:27:13 -04:00
parent 91b786eb1c
commit fa86750717
3 changed files with 262 additions and 20 deletions

View File

@@ -30,6 +30,11 @@ public class TemplateDeletionService
if (template == null)
return Result<bool>.Failure($"Template with ID {templateId} not found.");
if (template.IsDerived)
return Result<bool>.Failure(
$"Cannot delete template '{template.Name}': it is a derived template. " +
"Remove the owning composition on its parent template instead.");
var errors = new List<string>();
// Check 1: Instances reference this template
@@ -40,16 +45,33 @@ public class TemplateDeletionService
errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")}).");
}
// Check 2: Child templates reference it as parent
// Check 2: Child templates reference it as parent. Split derived vs. regular.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var childTemplates = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
if (childTemplates.Count > 0)
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
if (regularChildren.Count > 0)
{
var names = string.Join(", ", childTemplates.Select(t => t.Name).Take(10));
errors.Add($"Cannot delete template '{template.Name}': {childTemplates.Count} child template(s) inherit from it ({names}{(childTemplates.Count > 10 ? "..." : "")}).");
var names = string.Join(", ", regularChildren.Select(t => t.Name).Take(10));
errors.Add($"Cannot delete template '{template.Name}': {regularChildren.Count} child template(s) inherit from it ({names}{(regularChildren.Count > 10 ? "..." : "")}).");
}
// Check 3: Other templates compose it
if (derivatives.Count > 0)
{
var compIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
var ownerLookup = allTemplates
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
.Where(x => compIds.Contains(x.Composition.Id))
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
var details = string.Join(", ", derivatives.Take(10).Select(d =>
d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
? label
: $"'{d.Name}'"));
errors.Add($"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in {details}{(derivatives.Count > 10 ? "..." : "")}. Remove those compositions first.");
}
// Check 3: Other templates compose it directly (e.g., pre-Phase-3 data).
var composingTemplates = new List<(string TemplateName, string InstanceName)>();
foreach (var t in allTemplates)
{