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:
43
src/ScadaLink.Commons/Types/Flattening/ConfigurationDiff.cs
Normal file
43
src/ScadaLink.Commons/Types/Flattening/ConfigurationDiff.cs
Normal 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
|
||||
}
|
||||
61
src/ScadaLink.Commons/Types/Flattening/DeploymentPackage.cs
Normal file
61
src/ScadaLink.Commons/Types/Flattening/DeploymentPackage.cs
Normal 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; }
|
||||
}
|
||||
111
src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs
Normal file
111
src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs
Normal 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";
|
||||
}
|
||||
64
src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs
Normal file
64
src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user