Phase 2 WP-1–13+23: Template Engine CRUD, composition, overrides, locking, collision detection, acyclicity

- WP-23: ITemplateEngineRepository full EF Core implementation
- WP-1: Template CRUD with deletion constraints (instances, children, compositions)
- WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity
- WP-5: Shared script CRUD with syntax validation
- WP-6–7: Composition with recursive nesting and canonical naming
- WP-8–11: Override granularity, locking rules, inheritance/composition scope
- WP-12: Naming collision detection on canonical names (recursive)
- WP-13: Graph acyclicity (inheritance + composition cycles)
Core services: TemplateService, SharedScriptService, TemplateResolver,
LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
This commit is contained in:
Joseph Doherty
2026-03-16 20:10:34 -04:00
parent 84ad6bb77d
commit faef2d0de6
47 changed files with 7741 additions and 11 deletions

View File

@@ -0,0 +1,35 @@
using ScadaLink.Commons.Entities.Instances;
using ScadaLink.Commons.Entities.Sites;
namespace ScadaLink.Commons.Interfaces.Repositories;
/// <summary>
/// Repository interface for site and data connection management.
/// </summary>
public interface ISiteRepository
{
// Sites
Task<Site?> GetSiteByIdAsync(int id, CancellationToken cancellationToken = default);
Task<Site?> GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default);
Task AddSiteAsync(Site site, CancellationToken cancellationToken = default);
Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default);
Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default);
// Data Connections
Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
// Site-Connection Assignments
Task<SiteDataConnectionAssignment?> GetSiteDataConnectionAssignmentAsync(int siteId, int dataConnectionId, CancellationToken cancellationToken = default);
Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default);
Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default);
// Instances (for deletion constraint checks)
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,4 +1,5 @@
using ScadaLink.Commons.Entities.Instances;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
namespace ScadaLink.Commons.Interfaces.Repositories;
@@ -70,5 +71,13 @@ public interface ITemplateEngineRepository
Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default);
Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default);
// SharedScript
Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default);
Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default);
Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default);
Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default);
Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default);
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,43 @@
namespace ScadaLink.Commons.Types.Flattening;
/// <summary>
/// Represents the difference between two FlattenedConfigurations (typically deployed vs current).
/// Used for incremental deployment decisions and change review.
/// </summary>
public sealed record ConfigurationDiff
{
public string InstanceUniqueName { get; init; } = string.Empty;
public string? OldRevisionHash { get; init; }
public string? NewRevisionHash { get; init; }
public bool HasChanges => AttributeChanges.Count > 0 || AlarmChanges.Count > 0 || ScriptChanges.Count > 0;
public IReadOnlyList<DiffEntry<ResolvedAttribute>> AttributeChanges { get; init; } = [];
public IReadOnlyList<DiffEntry<ResolvedAlarm>> AlarmChanges { get; init; } = [];
public IReadOnlyList<DiffEntry<ResolvedScript>> ScriptChanges { get; init; } = [];
}
/// <summary>
/// A single diff entry showing what changed for a named entity.
/// </summary>
public sealed record DiffEntry<T>
{
public string CanonicalName { get; init; } = string.Empty;
public DiffChangeType ChangeType { get; init; }
/// <summary>
/// The previous value (null for Added entries).
/// </summary>
public T? OldValue { get; init; }
/// <summary>
/// The new value (null for Removed entries).
/// </summary>
public T? NewValue { get; init; }
}
public enum DiffChangeType
{
Added,
Removed,
Changed
}

View File

@@ -0,0 +1,61 @@
namespace ScadaLink.Commons.Types.Flattening;
/// <summary>
/// The complete deployment package for an instance, transmitted to site clusters and stored in SQLite.
/// Contains the flattened configuration, revision hash, and deployment metadata.
///
/// JSON serialization format:
/// {
/// "instanceUniqueName": "PumpStation1",
/// "deploymentId": "dep-abc123",
/// "revisionHash": "sha256:...",
/// "deployedBy": "admin@company.com",
/// "deployedAtUtc": "2026-03-16T12:00:00Z",
/// "configuration": { /* FlattenedConfiguration */ },
/// "diff": { /* ConfigurationDiff, null for first deployment */ },
/// "previousRevisionHash": null
/// }
/// </summary>
public sealed record DeploymentPackage
{
/// <summary>
/// The unique name of the instance being deployed.
/// </summary>
public string InstanceUniqueName { get; init; } = string.Empty;
/// <summary>
/// Unique deployment ID for idempotency (deployment ID + revision hash).
/// </summary>
public string DeploymentId { get; init; } = string.Empty;
/// <summary>
/// SHA-256 hash of the flattened configuration for staleness detection.
/// </summary>
public string RevisionHash { get; init; } = string.Empty;
/// <summary>
/// The user who initiated the deployment.
/// </summary>
public string DeployedBy { get; init; } = string.Empty;
/// <summary>
/// UTC timestamp when the deployment was created.
/// </summary>
public DateTimeOffset DeployedAtUtc { get; init; }
/// <summary>
/// The fully resolved configuration to deploy.
/// </summary>
public FlattenedConfiguration Configuration { get; init; } = new();
/// <summary>
/// Diff against the previously deployed configuration. Null for first-time deployments.
/// </summary>
public ConfigurationDiff? Diff { get; init; }
/// <summary>
/// The revision hash of the previously deployed configuration, if any.
/// Used for optimistic concurrency on deployment status records.
/// </summary>
public string? PreviousRevisionHash { get; init; }
}

View File

@@ -0,0 +1,111 @@
using System.Text.Json.Serialization;
namespace ScadaLink.Commons.Types.Flattening;
/// <summary>
/// The fully resolved configuration for a single instance, produced by the flattening algorithm.
/// All inheritance, composition, overrides, and connection bindings have been applied.
/// This is the canonical representation sent to sites for deployment.
/// </summary>
public sealed record FlattenedConfiguration
{
public string InstanceUniqueName { get; init; } = string.Empty;
public int TemplateId { get; init; }
public int SiteId { get; init; }
public int? AreaId { get; init; }
public IReadOnlyList<ResolvedAttribute> Attributes { get; init; } = [];
public IReadOnlyList<ResolvedAlarm> Alarms { get; init; } = [];
public IReadOnlyList<ResolvedScript> Scripts { get; init; } = [];
public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// A fully resolved attribute with its canonical name, value, data type, and optional data source binding.
/// </summary>
public sealed record ResolvedAttribute
{
/// <summary>
/// Path-qualified canonical name. For composed modules: "[ModuleInstanceName].[MemberName]".
/// For direct attributes: just the attribute name.
/// </summary>
public string CanonicalName { get; init; } = string.Empty;
public string? Value { get; init; }
public string DataType { get; init; } = string.Empty;
public bool IsLocked { get; init; }
public string? Description { get; init; }
/// <summary>
/// If data-sourced: the relative tag path within the connection.
/// </summary>
public string? DataSourceReference { get; init; }
/// <summary>
/// If data-sourced: the resolved connection ID from the instance binding.
/// </summary>
public int? BoundDataConnectionId { get; init; }
/// <summary>
/// If data-sourced: the resolved connection name.
/// </summary>
public string? BoundDataConnectionName { get; init; }
/// <summary>
/// If data-sourced: the connection protocol (e.g. "OpcUa").
/// </summary>
public string? BoundDataConnectionProtocol { get; init; }
/// <summary>
/// The source of this attribute value: "Template", "Inherited", "Composed", "Override".
/// </summary>
public string Source { get; init; } = "Template";
}
/// <summary>
/// A fully resolved alarm with trigger definition containing resolved attribute references.
/// </summary>
public sealed record ResolvedAlarm
{
public string CanonicalName { get; init; } = string.Empty;
public string? Description { get; init; }
public int PriorityLevel { get; init; }
public bool IsLocked { get; init; }
public string TriggerType { get; init; } = string.Empty;
/// <summary>
/// JSON trigger configuration with resolved attribute references (canonical names).
/// </summary>
public string? TriggerConfiguration { get; init; }
/// <summary>
/// Canonical name of the on-trigger script, if any.
/// </summary>
public string? OnTriggerScriptCanonicalName { get; init; }
public string Source { get; init; } = "Template";
}
/// <summary>
/// A fully resolved script with code, trigger config, parameters, and return definition.
/// </summary>
public sealed record ResolvedScript
{
public string CanonicalName { get; init; } = string.Empty;
public string Code { get; init; } = string.Empty;
public bool IsLocked { get; init; }
public string? TriggerType { get; init; }
public string? TriggerConfiguration { get; init; }
/// <summary>
/// JSON-serialized parameter definitions.
/// </summary>
public string? ParameterDefinitions { get; init; }
/// <summary>
/// JSON-serialized return type definition.
/// </summary>
public string? ReturnDefinition { get; init; }
public TimeSpan? MinTimeBetweenRuns { get; init; }
public string Source { get; init; } = "Template";
}

View File

@@ -0,0 +1,64 @@
namespace ScadaLink.Commons.Types.Flattening;
/// <summary>
/// Result of pre-deployment or on-demand validation with categorized errors and warnings.
/// </summary>
public sealed record ValidationResult
{
public bool IsValid => Errors.Count == 0;
public IReadOnlyList<ValidationEntry> Errors { get; init; } = [];
public IReadOnlyList<ValidationEntry> Warnings { get; init; } = [];
public static ValidationResult Success() => new();
public static ValidationResult FromErrors(params ValidationEntry[] errors) =>
new() { Errors = errors };
public static ValidationResult Merge(params ValidationResult[] results)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
foreach (var r in results)
{
errors.AddRange(r.Errors);
warnings.AddRange(r.Warnings);
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
}
/// <summary>
/// A single validation error or warning with category and context.
/// </summary>
public sealed record ValidationEntry
{
public ValidationCategory Category { get; init; }
public string Message { get; init; } = string.Empty;
/// <summary>
/// The canonical name of the entity that caused the validation issue, if applicable.
/// </summary>
public string? EntityName { get; init; }
public static ValidationEntry Error(ValidationCategory category, string message, string? entityName = null) =>
new() { Category = category, Message = message, EntityName = entityName };
public static ValidationEntry Warning(ValidationCategory category, string message, string? entityName = null) =>
new() { Category = category, Message = message, EntityName = entityName };
}
public enum ValidationCategory
{
FlatteningFailure,
NamingCollision,
ScriptCompilation,
AlarmTriggerReference,
ScriptTriggerReference,
ConnectionBinding,
CallTargetNotFound,
ParameterMismatch,
ReturnTypeMismatch,
TriggerOperandType,
OnTriggerScriptNotFound,
CrossCallViolation
}