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
@@ -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;
}