feat: wire SQLite replication between site nodes and fix ConfigurationDatabase tests
Add SiteReplicationActor (runs on every site node) to replicate deployed configs and store-and-forward buffer operations to the standby peer via cluster member discovery and fire-and-forget Tell. Wire ReplicationService handler and pass replication actor to DeploymentManagerActor singleton. Fix 5 pre-existing ConfigurationDatabase test failures: RowVersion NOT NULL on SQLite, stale migration name assertion, and seed data count mismatch.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
@@ -34,4 +36,27 @@ public record ExternalCallResult(
|
||||
bool Success,
|
||||
string? ResponseJson,
|
||||
string? ErrorMessage,
|
||||
bool WasBuffered = false);
|
||||
bool WasBuffered = false)
|
||||
{
|
||||
private dynamic? _response;
|
||||
private bool _responseParsed;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed response as a dynamic object. Returns null if ResponseJson is null or empty.
|
||||
/// Access properties directly: result.Response.result, result.Response.items[0].name, etc.
|
||||
/// </summary>
|
||||
public dynamic? Response
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_responseParsed)
|
||||
{
|
||||
_response = string.IsNullOrEmpty(ResponseJson)
|
||||
? null
|
||||
: new DynamicJsonElement(System.Text.Json.JsonDocument.Parse(ResponseJson).RootElement);
|
||||
_responseParsed = true;
|
||||
}
|
||||
return _response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,10 @@ public record GetExternalSystemCommand(int ExternalSystemId);
|
||||
public record CreateExternalSystemCommand(string Name, string EndpointUrl, string AuthType, string? AuthConfiguration);
|
||||
public record UpdateExternalSystemCommand(int ExternalSystemId, string Name, string EndpointUrl, string AuthType, string? AuthConfiguration);
|
||||
public record DeleteExternalSystemCommand(int ExternalSystemId);
|
||||
|
||||
// External System Methods
|
||||
public record ListExternalSystemMethodsCommand(int ExternalSystemId);
|
||||
public record GetExternalSystemMethodCommand(int MethodId);
|
||||
public record CreateExternalSystemMethodCommand(int ExternalSystemId, string Name, string HttpMethod, string Path, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
||||
public record UpdateExternalSystemMethodCommand(int MethodId, string? Name = null, string? HttpMethod = null, string? Path = null, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
||||
public record DeleteExternalSystemMethodCommand(int MethodId);
|
||||
|
||||
@@ -14,8 +14,8 @@ public record DeleteTemplateAttributeCommand(int AttributeId);
|
||||
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
|
||||
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
|
||||
public record DeleteTemplateAlarmCommand(int AlarmId);
|
||||
public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked);
|
||||
public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked);
|
||||
public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
||||
public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
||||
public record DeleteTemplateScriptCommand(int ScriptId);
|
||||
public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId);
|
||||
public record DeleteTemplateCompositionCommand(int CompositionId);
|
||||
|
||||
92
src/ScadaLink.Commons/Types/DynamicJsonElement.cs
Normal file
92
src/ScadaLink.Commons/Types/DynamicJsonElement.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a JsonElement as a dynamic object for convenient property access in scripts.
|
||||
/// Supports property access (obj.name), indexing (obj.items[0]), and ToString().
|
||||
/// </summary>
|
||||
public class DynamicJsonElement : DynamicObject
|
||||
{
|
||||
private readonly JsonElement _element;
|
||||
|
||||
public DynamicJsonElement(JsonElement element)
|
||||
{
|
||||
_element = element;
|
||||
}
|
||||
|
||||
public override bool TryGetMember(GetMemberBinder binder, out object? result)
|
||||
{
|
||||
if (_element.ValueKind == JsonValueKind.Object &&
|
||||
_element.TryGetProperty(binder.Name, out var prop))
|
||||
{
|
||||
result = Wrap(prop);
|
||||
return true;
|
||||
}
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object? result)
|
||||
{
|
||||
if (_element.ValueKind == JsonValueKind.Array &&
|
||||
indexes.Length == 1 && indexes[0] is int index)
|
||||
{
|
||||
var arrayLength = _element.GetArrayLength();
|
||||
if (index >= 0 && index < arrayLength)
|
||||
{
|
||||
result = Wrap(_element[index]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override bool TryConvert(ConvertBinder binder, out object? result)
|
||||
{
|
||||
result = ConvertTo(binder.Type);
|
||||
return result != null || binder.Type == typeof(object);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return _element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => _element.GetString() ?? "",
|
||||
JsonValueKind.Number => _element.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "",
|
||||
_ => _element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private object? ConvertTo(Type type)
|
||||
{
|
||||
if (type == typeof(string)) return ToString();
|
||||
if (type == typeof(int) && _element.ValueKind == JsonValueKind.Number) return _element.GetInt32();
|
||||
if (type == typeof(long) && _element.ValueKind == JsonValueKind.Number) return _element.GetInt64();
|
||||
if (type == typeof(double) && _element.ValueKind == JsonValueKind.Number) return _element.GetDouble();
|
||||
if (type == typeof(decimal) && _element.ValueKind == JsonValueKind.Number) return _element.GetDecimal();
|
||||
if (type == typeof(bool) && (_element.ValueKind == JsonValueKind.True || _element.ValueKind == JsonValueKind.False))
|
||||
return _element.GetBoolean();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? Wrap(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => new DynamicJsonElement(element),
|
||||
JsonValueKind.Array => new DynamicJsonElement(element),
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -60,5 +60,6 @@ public enum ValidationCategory
|
||||
ReturnTypeMismatch,
|
||||
TriggerOperandType,
|
||||
OnTriggerScriptNotFound,
|
||||
CrossCallViolation
|
||||
CrossCallViolation,
|
||||
MissingMetadata
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user