feat(templateengine+centralui): resolve follow-ups #1/#2 — inherited-member propagation & resync

Derived templates store IsInherited placeholder rows mirroring inherited
members, but a base member added/changed/removed AFTER a child was derived
never reached the child — leaving the editor's editable tabs incomplete (#1)
and stored rows drifted from the resolved set (#2).

Fix (one order-independent reconcile, two entry points):
- Auto-propagation: every attribute/alarm/script add/update/delete now
  reconciles the template's derived subtree (TemplateService.ReconcileDescendantsAsync),
  hooked into all member-mutating paths incl. native-alarm-source CRUD in the
  ManagementActor.
- Resync: ResyncInheritedMembersAsync repairs a template + its subtree on
  demand — materialize missing placeholders, re-sync drifted ones, remove
  orphans, across attributes/alarms/scripts/native sources. Exposed as
  management ResyncInheritedMembersCommand (Designer-gated, audited) → CLI
  `template resync-members` → a Resync button on the editor's staleness banner.

Reconcile drives off TemplateInheritanceResolver (same precedence + HiLo merge
as deploy), only ever touches IsInherited placeholders (never an authored
override), and matches the staleness comparison keys so the banner clears.
BuildDerivedTemplate now also materializes native-source placeholders at
compose time (previously omitted → any inherited native source was perpetually
stale).

Tests: +8 TemplateServiceTests (materialize / drift-update / orphan-remove /
override-untouched / base-cascade / multi-type / direct-propagate / end-to-end
add) + 1 ManagementService test fix (native-source add resolves TemplateService).
Affected suites green: TemplateEngine 446, ManagementService 230, CentralUI 866,
CLI 333, Transport 127, ConfigurationDatabase 307; full solution builds 0/0.

Docs: Component-TemplateEngine.md "Inherited-Member Propagation & Resync";
CLI README `template resync-members`; known-issues tracker #1/#2 resolved.
This commit is contained in:
Joseph Doherty
2026-06-24 15:51:26 -04:00
parent b3f6833b36
commit 2b5949320c
11 changed files with 873 additions and 19 deletions
@@ -29,10 +29,29 @@ public static class TemplateCommands
command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildResyncMembers(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true };
var cmd = new Command("resync-members")
{
Description = "Resync inherited members onto a template and its derived subtree " +
"(materialize missing, re-sync drifted, remove orphaned)"
};
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ResyncInheritedMembersCommand(id));
});
return cmd;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all templates" };
+12
View File
@@ -403,6 +403,18 @@ scadabridge --url <url> template composition delete --id <int>
|--------|----------|-------------|
| `--id` | yes | Composition ID to remove |
#### `template resync-members`
Reconcile a template's stored *inherited* member rows (and those of its whole derived subtree) with the resolved effective inherited set: materialize missing inherited placeholders (e.g. base members added after a derived template was created), re-sync drifted ones to the live base value, and remove orphaned ones. After a resync the template editor's member tabs are complete and the "Base template changed" banner clears. Authored overrides are never touched. Base member changes auto-propagate going forward; this command repairs templates that drifted before that was in place. Returns the per-subtree change counts (templates changed, members added/updated/removed).
```sh
scadabridge --url <url> template resync-members --id <int>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Template ID (its derived subtree is included) |
---
### `instance` — Manage instances
@@ -288,18 +288,29 @@
</div>
}
@* M9-T26b: read-only base-changed banner. Informational only — surfaced
when the freshly-resolved inherited set differs from this template's
stored copy (a multi-level inherited member, or a base member added
after this template was created). No action button: a deploy already
resolves fresh, so this is purely an authoring heads-up. *@
@* Base-changed banner (follow-up #1/#2). Surfaced when the freshly-resolved
inherited set differs from this template's stored copy (a base member
added/changed/removed after this template was created, or a multi-level
inherited member). The Resync action materializes the missing inherited
rows, re-syncs drifted ones, and removes orphans on this template and its
derived subtree — so the editable tabs below become complete and the
banner clears. (A deploy already resolves fresh regardless.) *@
@if (_resolved?.Staleness.IsStale == true)
{
<div class="alert alert-info py-2 mb-3" role="status">
<i class="bi bi-info-circle me-1"></i>
<strong>Base template changed</strong> —
@_resolved.Staleness.DifferingMemberCount inherited member(s) differ from this template's stored copy.
The effective set above reflects the live base; a deploy resolves fresh.
<div class="alert alert-info d-flex align-items-center justify-content-between py-2 mb-3" role="status">
<div>
<i class="bi bi-info-circle me-1"></i>
<strong>Base template changed</strong> —
@_resolved.Staleness.DifferingMemberCount inherited member(s) differ from this template's stored copy.
Resync to bring this template's stored members in line with the base.
</div>
<button class="btn btn-sm btn-primary ms-3 text-nowrap" @onclick="ResyncInheritedMembers" disabled="@_resyncing">
@if (_resyncing)
{
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
Resync inherited members
</button>
</div>
}
@@ -944,6 +955,38 @@
else _toast.ShowError(result.Error);
}
private bool _resyncing;
// Follow-up #1/#2: materialize/refresh this template's (and its subtree's)
// inherited member rows so the editable tabs are complete and the
// base-changed banner clears. In-process TemplateService call (the page is
// already RequireDesign-gated, matching the management command's role gate).
private async Task ResyncInheritedMembers()
{
if (_resyncing) return;
_resyncing = true;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.ResyncInheritedMembersAsync(Id, user);
if (result.IsSuccess)
{
var r = result.Value;
_toast.ShowSuccess(
$"Resynced inherited members — {r.MembersAdded} added, {r.MembersUpdated} updated, {r.MembersRemoved} removed across {r.TemplatesChanged} template(s).");
await LoadAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
finally
{
_resyncing = false;
}
}
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
@@ -105,6 +105,23 @@ public sealed record ResolvedTemplateMemberInfo
public string? EffectiveTriggerConfiguration { get; init; }
}
/// <summary>
/// Result of a <see cref="ResyncInheritedMembersCommand"/>: how many derived
/// templates were brought into sync and how many inherited member rows were
/// added / updated / removed across the reconciled subtree.
/// </summary>
public sealed record ResyncInheritedMembersResult
{
/// <summary>Number of templates in the subtree that had at least one inherited-row change.</summary>
public int TemplatesChanged { get; init; }
/// <summary>Inherited placeholder rows materialized (members missing from the stored set).</summary>
public int MembersAdded { get; init; }
/// <summary>Inherited placeholder rows re-synced to the live effective value.</summary>
public int MembersUpdated { get; init; }
/// <summary>Orphaned inherited placeholder rows removed (the base no longer defines them).</summary>
public int MembersRemoved { get; init; }
}
/// <summary>
/// Staleness summary comparing a template's STORED member rows against the
/// freshly-resolved inherited member set.
@@ -17,6 +17,18 @@ public record ValidateTemplateCommand(int TemplateId);
/// </summary>
public record GetResolvedTemplateMembersCommand(int TemplateId);
/// <summary>
/// Reconciles a template's STORED inherited member rows with the resolved
/// effective inherited set, for the template and its whole derived subtree:
/// materializes missing inherited placeholders (e.g. base members added after a
/// derived template was created), re-syncs drifted placeholders to the live
/// effective value, and removes orphaned inherited rows. After a resync the
/// editor's editable member tabs are complete and the "base changed" staleness
/// banner clears. Designer-gated, audited. Response:
/// <see cref="ResyncInheritedMembersResult"/>.
/// </summary>
public record ResyncInheritedMembersCommand(int TemplateId);
// Template member operations
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
@@ -200,6 +200,7 @@ public class ManagementActor : ReceiveActor
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
or ResyncInheritedMembersCommand
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
@@ -253,6 +254,7 @@ public class ManagementActor : ReceiveActor
DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user.Username),
ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd),
GetResolvedTemplateMembersCommand cmd => await HandleGetResolvedTemplateMembers(sp, cmd),
ResyncInheritedMembersCommand cmd => await HandleResyncInheritedMembers(sp, cmd, user.Username),
// Template members
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
@@ -261,9 +263,9 @@ public class ManagementActor : ReceiveActor
AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username),
UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username),
DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username),
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd),
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd),
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd),
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd, user.Username),
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd, user.Username),
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd, user.Username),
ListTemplateNativeAlarmSourcesCommand cmd => await HandleListNativeAlarmSources(sp, cmd),
AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username),
UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username),
@@ -645,6 +647,20 @@ public class ManagementActor : ReceiveActor
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
}
/// <summary>
/// Resync inherited members (follow-up #1/#2): reconciles a template's stored
/// inherited rows (and its derived subtree's) with the resolved effective set —
/// materializing missing placeholders, re-syncing drifted ones, and removing
/// orphans — so the editor's editable tabs are complete and the staleness
/// banner clears. Designer-gated; the service owns the audit row.
/// </summary>
private static async Task<object?> HandleResyncInheritedMembers(IServiceProvider sp, ResyncInheritedMembersCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var result = await svc.ResyncInheritedMembersAsync(cmd.TemplateId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
// Template folder handlers
// ========================================================================
@@ -2226,7 +2242,7 @@ public class ManagementActor : ReceiveActor
// ── Native alarm source bindings (read-only mirror; repository-direct CRUD) ──
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd)
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd, string user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var source = new TemplateNativeAlarmSource(cmd.Name)
@@ -2240,10 +2256,13 @@ public class ManagementActor : ReceiveActor
};
await repo.AddTemplateNativeAlarmSourceAsync(source);
await repo.SaveChangesAsync();
// Propagate the new source to derived descendants (#1/#2). Native-source
// CRUD lives here (not TemplateService), so call the propagation directly.
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(cmd.TemplateId, user);
return source;
}
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd)
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd, string user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId)
@@ -2256,14 +2275,21 @@ public class ManagementActor : ReceiveActor
source.IsLocked = cmd.IsLocked;
await repo.UpdateTemplateNativeAlarmSourceAsync(source);
await repo.SaveChangesAsync();
// Propagate the changed source to derived descendants (#1/#2).
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
return source;
}
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd)
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd, string user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
// Capture the owning template before delete so descendants can be reconciled.
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId);
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
await repo.SaveChangesAsync();
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
if (source != null)
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
return cmd.NativeAlarmSourceId;
}
@@ -1,6 +1,7 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -302,6 +303,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateAttribute>.Success(attribute);
}
@@ -396,6 +400,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateAttribute>.Success(existing);
}
@@ -431,6 +438,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(attribute.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -526,6 +536,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateAlarm>.Success(alarm);
}
@@ -608,6 +621,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateAlarm>.Success(existing);
}
@@ -642,6 +658,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(alarm.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -687,6 +706,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateScript>.Success(script);
}
@@ -770,6 +792,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateScript>.Success(existing);
}
@@ -804,6 +829,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(script.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -1090,9 +1118,450 @@ public class TemplateService
});
}
// Native alarm sources follow the same inherit/override/lock model as the
// other member types (TemplateNativeAlarmSource), and the inheritance
// resolver + staleness summary count them — so materialize their inherited
// placeholders here too, or a freshly-composed derived template reports
// stale for every inherited native source (follow-up #1/#2).
foreach (var src in baseTemplate.NativeAlarmSources)
{
derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource(src.Name)
{
Description = src.Description,
ConnectionName = src.ConnectionName,
SourceReference = src.SourceReference,
ConditionFilter = src.ConditionFilter,
IsLocked = src.IsLocked,
IsInherited = true,
LockedInDerived = false,
});
}
return derived;
}
// ========================================================================
// Inherited-member propagation & resync (follow-up #1/#2)
// ========================================================================
//
// A derived template stores IsInherited placeholder rows mirroring the
// members it inherits. BuildDerivedTemplate materializes them at compose time,
// but a member ADDED / UPDATED / REMOVED on a base AFTER the derivation
// existed never reached the children — so derived row-sets drifted incomplete
// (the editor's editable tabs missed inherited members; #1) and stored rows
// diverged from the resolved set (#2). Two mechanisms keep them in sync:
// • ReconcileDescendantsAsync — called automatically after every base member
// mutation, propagates the change to the whole derived subtree.
// • ResyncInheritedMembersAsync — an explicit operator action (CLI + UI)
// that repairs an already-stale template and its subtree.
// Both delegate to one order-independent reconcile that only ever touches
// IsInherited placeholder rows (never an authored override), which the
// inheritance resolver ignores when picking winners — so reconciling one
// template never changes what another resolves.
/// <summary>
/// Reconciles the stored inherited member rows of a template and its entire
/// derived subtree against the resolved effective inherited set: materializes
/// missing placeholders, re-syncs drifted ones, and removes orphaned ones.
/// This is the operator-facing repair action behind the CLI
/// <c>template resync-members</c> command and the editor's "Resync" button.
/// </summary>
/// <param name="templateId">The template to resync (its subtree is included).</param>
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result carrying the per-subtree change counts.</returns>
public async Task<Result<ResyncInheritedMembersResult>> ResyncInheritedMembersAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var all = await _repository.GetAllTemplatesAsync(cancellationToken) ?? Array.Empty<Template>();
var byId = BuildTemplateLookup(all);
if (!byId.TryGetValue(templateId, out var root))
return Result<ResyncInheritedMembersResult>.Failure($"Template with ID {templateId} not found.");
// Reconcile the target itself AND its subtree: resyncing a base repairs
// every derivation; resyncing a leaf repairs just it.
var targets = new List<int> { templateId };
targets.AddRange(GetDescendantIdsBreadthFirst(templateId, all));
var total = new ReconcileCounts();
var changed = 0;
foreach (var id in targets)
{
var counts = await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken);
if (counts.Any) changed++;
total.Add(counts);
}
if (total.Any)
{
await _auditService.LogAsync(
user, "Resync", "Template", templateId.ToString(), root.Name,
new { templatesChanged = changed, total.Added, total.Updated, total.Removed },
cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
return Result<ResyncInheritedMembersResult>.Success(new ResyncInheritedMembersResult
{
TemplatesChanged = changed,
MembersAdded = total.Added,
MembersUpdated = total.Updated,
MembersRemoved = total.Removed
});
}
/// <summary>
/// Propagates a base member change to every derived descendant by reconciling
/// their inherited rows. Called automatically after a base member is added,
/// updated, or deleted. The base template itself is NOT reconciled — it is the
/// author of the change. A no-op (and a single cheap query) when the template
/// has no descendants, which is the common case.
/// </summary>
/// <param name="templateId">The template whose member set changed.</param>
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ReconcileDescendantsAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var all = await _repository.GetAllTemplatesAsync(cancellationToken);
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
if (descendants.Count == 0)
return;
var byId = BuildTemplateLookup(all);
var total = new ReconcileCounts();
foreach (var id in descendants)
total.Add(await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken));
if (total.Any)
{
var name = byId.TryGetValue(templateId, out var t) ? t.Name : templateId.ToString();
await _auditService.LogAsync(
user, "Resync", "Template", templateId.ToString(), name,
new { propagatedFrom = templateId, total.Added, total.Updated, total.Removed },
cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
}
/// <summary>
/// Reconciles ONE template's stored IsInherited rows against its resolved
/// effective inherited set (the same resolver the editor preview + staleness
/// banner use). Adds missing placeholders, re-syncs drifted ones, removes
/// orphans. Authored override rows (<c>IsInherited == false</c>) are never
/// touched. Repository writes are queued; the caller owns SaveChanges.
/// </summary>
private async Task<ReconcileCounts> ReconcileInheritedRowsAsync(
int templateId,
IReadOnlyList<Template> all,
Dictionary<int, Template> byId,
CancellationToken cancellationToken)
{
var counts = new ReconcileCounts();
if (!byId.TryGetValue(templateId, out var child) || child.ParentTemplateId == null)
return counts; // root templates inherit nothing
var resolved = TemplateInheritanceResolver.Resolve(templateId, all);
await ReconcileAttributesAsync(child, resolved.Attributes, byId, counts, cancellationToken);
await ReconcileAlarmsAsync(child, resolved.Alarms, byId, counts, cancellationToken);
await ReconcileScriptsAsync(child, resolved.Scripts, byId, counts, cancellationToken);
await ReconcileNativeSourcesAsync(child, resolved.NativeAlarmSources, byId, counts, cancellationToken);
return counts;
}
private async Task ReconcileAttributesAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue; // own / override rows are authoritative
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Attributes.FirstOrDefault(a => a.Name == m.Name);
if (win == null) continue;
var existing = child.Attributes.FirstOrDefault(a => a.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateAttributeAsync(new TemplateAttribute(m.Name)
{
TemplateId = child.Id,
Value = m.EffectiveValue,
DataType = win.DataType,
ElementDataType = win.ElementDataType,
Description = win.Description,
DataSourceReference = win.DataSourceReference,
IsLocked = win.IsLocked,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.Value != m.EffectiveValue ||
existing.DataType != win.DataType ||
existing.ElementDataType != win.ElementDataType ||
existing.Description != win.Description ||
existing.DataSourceReference != win.DataSourceReference ||
existing.IsLocked != win.IsLocked))
{
existing.Value = m.EffectiveValue;
existing.DataType = win.DataType;
existing.ElementDataType = win.ElementDataType;
existing.Description = win.Description;
existing.DataSourceReference = win.DataSourceReference;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateAttributeAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Attributes.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
{
await _repository.DeleteTemplateAttributeAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileAlarmsAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Alarms.FirstOrDefault(a => a.Name == m.Name);
if (win == null) continue;
// EffectiveTriggerConfiguration is the per-setpoint MERGED HiLo config
// (mirrors the flattener), so the placeholder matches what a deploy and
// the staleness comparison produce.
var existing = child.Alarms.FirstOrDefault(a => a.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateAlarmAsync(new TemplateAlarm(m.Name)
{
TemplateId = child.Id,
Description = win.Description,
PriorityLevel = win.PriorityLevel,
IsLocked = win.IsLocked,
TriggerType = win.TriggerType,
TriggerConfiguration = m.EffectiveTriggerConfiguration,
OnTriggerScriptId = win.OnTriggerScriptId,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.PriorityLevel != win.PriorityLevel ||
existing.TriggerConfiguration != m.EffectiveTriggerConfiguration ||
existing.Description != win.Description ||
existing.OnTriggerScriptId != win.OnTriggerScriptId ||
existing.TriggerType != win.TriggerType ||
existing.IsLocked != win.IsLocked))
{
existing.PriorityLevel = win.PriorityLevel;
existing.TriggerConfiguration = m.EffectiveTriggerConfiguration;
existing.Description = win.Description;
existing.OnTriggerScriptId = win.OnTriggerScriptId;
existing.TriggerType = win.TriggerType;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateAlarmAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Alarms.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
{
await _repository.DeleteTemplateAlarmAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileScriptsAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Scripts.FirstOrDefault(s => s.Name == m.Name);
if (win == null) continue;
var existing = child.Scripts.FirstOrDefault(s => s.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateScriptAsync(new TemplateScript(m.Name, win.Code)
{
TemplateId = child.Id,
IsLocked = win.IsLocked,
TriggerType = win.TriggerType,
TriggerConfiguration = win.TriggerConfiguration,
ParameterDefinitions = win.ParameterDefinitions,
ReturnDefinition = win.ReturnDefinition,
MinTimeBetweenRuns = win.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.Code != win.Code ||
existing.TriggerType != win.TriggerType ||
existing.TriggerConfiguration != win.TriggerConfiguration ||
existing.ParameterDefinitions != win.ParameterDefinitions ||
existing.ReturnDefinition != win.ReturnDefinition ||
existing.MinTimeBetweenRuns != win.MinTimeBetweenRuns ||
existing.ExecutionTimeoutSeconds != win.ExecutionTimeoutSeconds ||
existing.IsLocked != win.IsLocked))
{
existing.Code = win.Code;
existing.TriggerType = win.TriggerType;
existing.TriggerConfiguration = win.TriggerConfiguration;
existing.ParameterDefinitions = win.ParameterDefinitions;
existing.ReturnDefinition = win.ReturnDefinition;
existing.MinTimeBetweenRuns = win.MinTimeBetweenRuns;
existing.ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateScriptAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Scripts.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
{
await _repository.DeleteTemplateScriptAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileNativeSourcesAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
if (win == null) continue;
var existing = child.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateNativeAlarmSourceAsync(new TemplateNativeAlarmSource(m.Name)
{
TemplateId = child.Id,
Description = win.Description,
ConnectionName = win.ConnectionName,
SourceReference = win.SourceReference,
ConditionFilter = win.ConditionFilter,
IsLocked = win.IsLocked,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.ConnectionName != win.ConnectionName ||
existing.SourceReference != win.SourceReference ||
existing.ConditionFilter != win.ConditionFilter ||
existing.Description != win.Description ||
existing.IsLocked != win.IsLocked))
{
existing.ConnectionName = win.ConnectionName;
existing.SourceReference = win.SourceReference;
existing.ConditionFilter = win.ConditionFilter;
existing.Description = win.Description;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateNativeAlarmSourceAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.NativeAlarmSources.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
{
await _repository.DeleteTemplateNativeAlarmSourceAsync(orphan.Id, ct);
counts.Removed++;
}
}
/// <summary>
/// Breadth-first list of every template that (transitively) derives from
/// <paramref name="rootId"/>, excluding the root itself. Cycle-guarded.
/// </summary>
private static List<int> GetDescendantIdsBreadthFirst(int rootId, IReadOnlyList<Template> all)
{
if (all is null) return new List<int>();
var childrenByParent = new Dictionary<int, List<int>>();
foreach (var t in all)
{
if (t.ParentTemplateId is { } pid)
{
if (!childrenByParent.TryGetValue(pid, out var list))
childrenByParent[pid] = list = new List<int>();
list.Add(t.Id);
}
}
var result = new List<int>();
var seen = new HashSet<int> { rootId };
var queue = new Queue<int>();
if (childrenByParent.TryGetValue(rootId, out var direct))
foreach (var c in direct) queue.Enqueue(c);
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (!seen.Add(id)) continue;
result.Add(id);
if (childrenByParent.TryGetValue(id, out var kids))
foreach (var c in kids) queue.Enqueue(c);
}
return result;
}
private static Dictionary<int, Template> BuildTemplateLookup(IReadOnlyList<Template> all)
{
var byId = new Dictionary<int, Template>(all.Count);
foreach (var t in all) byId[t.Id] = t; // DB ids are unique; last-wins is harmless
return byId;
}
/// <summary>Running tally of inherited-row changes during a reconcile.</summary>
private sealed class ReconcileCounts
{
public int Added;
public int Updated;
public int Removed;
public bool Any => Added > 0 || Updated > 0 || Removed > 0;
public void Add(ReconcileCounts other)
{
Added += other.Added;
Updated += other.Updated;
Removed += other.Removed;
}
}
// ========================================================================
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
// ========================================================================