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:
Joseph Doherty
2026-03-18 08:28:02 -04:00
parent f063fb1ca3
commit eb8ead58d2
23 changed files with 707 additions and 33 deletions

View 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()
};
}
}

View File

@@ -60,5 +60,6 @@ public enum ValidationCategory
ReturnTypeMismatch,
TriggerOperandType,
OnTriggerScriptNotFound,
CrossCallViolation
CrossCallViolation,
MissingMetadata
}