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

@@ -15,6 +15,7 @@ public static class ExternalSystemCommands
command.Add(BuildCreate(contactPointsOption, formatOption)); command.Add(BuildCreate(contactPointsOption, formatOption));
command.Add(BuildUpdate(contactPointsOption, formatOption)); command.Add(BuildUpdate(contactPointsOption, formatOption));
command.Add(BuildDelete(contactPointsOption, formatOption)); command.Add(BuildDelete(contactPointsOption, formatOption));
command.Add(BuildMethodGroup(contactPointsOption, formatOption));
return command; return command;
} }
@@ -110,4 +111,121 @@ public static class ExternalSystemCommands
}); });
return cmd; return cmd;
} }
// ── Method subcommands ──
private static Command BuildMethodGroup(Option<string> contactPointsOption, Option<string> formatOption)
{
var group = new Command("method") { Description = "Manage external system methods" };
group.Add(BuildMethodList(contactPointsOption, formatOption));
group.Add(BuildMethodGet(contactPointsOption, formatOption));
group.Add(BuildMethodCreate(contactPointsOption, formatOption));
group.Add(BuildMethodUpdate(contactPointsOption, formatOption));
group.Add(BuildMethodDelete(contactPointsOption, formatOption));
return group;
}
private static Command BuildMethodList(Option<string> contactPointsOption, Option<string> formatOption)
{
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
var cmd = new Command("list") { Description = "List methods for an external system" };
cmd.Add(sysIdOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new ListExternalSystemMethodsCommand(result.GetValue(sysIdOption)));
});
return cmd;
}
private static Command BuildMethodGet(Option<string> contactPointsOption, Option<string> formatOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var cmd = new Command("get") { Description = "Get an external system method by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new GetExternalSystemMethodCommand(result.GetValue(idOption)));
});
return cmd;
}
private static Command BuildMethodCreate(Option<string> contactPointsOption, Option<string> formatOption)
{
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
var httpMethodOption = new Option<string>("--http-method") { Description = "HTTP method (GET, POST, PUT, DELETE)", Required = true };
var pathOption = new Option<string>("--path") { Description = "URL path (e.g. /api/Add)", Required = true };
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
var cmd = new Command("create") { Description = "Create an external system method" };
cmd.Add(sysIdOption);
cmd.Add(nameOption);
cmd.Add(httpMethodOption);
cmd.Add(pathOption);
cmd.Add(paramsOption);
cmd.Add(returnOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new CreateExternalSystemMethodCommand(
result.GetValue(sysIdOption),
result.GetValue(nameOption)!,
result.GetValue(httpMethodOption)!,
result.GetValue(pathOption)!,
result.GetValue(paramsOption),
result.GetValue(returnOption)));
});
return cmd;
}
private static Command BuildMethodUpdate(Option<string> contactPointsOption, Option<string> formatOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var nameOption = new Option<string?>("--name") { Description = "Method name" };
var httpMethodOption = new Option<string?>("--http-method") { Description = "HTTP method" };
var pathOption = new Option<string?>("--path") { Description = "URL path" };
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
var cmd = new Command("update") { Description = "Update an external system method" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(httpMethodOption);
cmd.Add(pathOption);
cmd.Add(paramsOption);
cmd.Add(returnOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new UpdateExternalSystemMethodCommand(
result.GetValue(idOption),
result.GetValue(nameOption),
result.GetValue(httpMethodOption),
result.GetValue(pathOption),
result.GetValue(paramsOption),
result.GetValue(returnOption)));
});
return cmd;
}
private static Command BuildMethodDelete(Option<string> contactPointsOption, Option<string> formatOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var cmd = new Command("delete") { Description = "Delete an external system method" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new DeleteExternalSystemMethodCommand(result.GetValue(idOption)));
});
return cmd;
}
} }

View File

@@ -300,6 +300,9 @@ public static class TemplateCommands
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" }; var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false; lockedOption.DefaultValueFactory = _ => false;
var paramsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
var addCmd = new Command("add") { Description = "Add a script to a template" }; var addCmd = new Command("add") { Description = "Add a script to a template" };
addCmd.Add(templateIdOption); addCmd.Add(templateIdOption);
addCmd.Add(nameOption); addCmd.Add(nameOption);
@@ -307,6 +310,8 @@ public static class TemplateCommands
addCmd.Add(triggerTypeOption); addCmd.Add(triggerTypeOption);
addCmd.Add(triggerConfigOption); addCmd.Add(triggerConfigOption);
addCmd.Add(lockedOption); addCmd.Add(lockedOption);
addCmd.Add(paramsOption);
addCmd.Add(returnOption);
addCmd.SetAction(async (ParseResult result) => addCmd.SetAction(async (ParseResult result) =>
{ {
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
@@ -317,7 +322,9 @@ public static class TemplateCommands
result.GetValue(codeOption)!, result.GetValue(codeOption)!,
result.GetValue(triggerTypeOption)!, result.GetValue(triggerTypeOption)!,
result.GetValue(triggerConfigOption), result.GetValue(triggerConfigOption),
result.GetValue(lockedOption))); result.GetValue(lockedOption),
result.GetValue(paramsOption),
result.GetValue(returnOption)));
}); });
group.Add(addCmd); group.Add(addCmd);
@@ -329,6 +336,9 @@ public static class TemplateCommands
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" }; var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false; updateLockedOption.DefaultValueFactory = _ => false;
var updateParamsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
var updateCmd = new Command("update") { Description = "Update a template script" }; var updateCmd = new Command("update") { Description = "Update a template script" };
updateCmd.Add(updateIdOption); updateCmd.Add(updateIdOption);
updateCmd.Add(updateNameOption); updateCmd.Add(updateNameOption);
@@ -336,6 +346,8 @@ public static class TemplateCommands
updateCmd.Add(updateTriggerTypeOption); updateCmd.Add(updateTriggerTypeOption);
updateCmd.Add(updateTriggerConfigOption); updateCmd.Add(updateTriggerConfigOption);
updateCmd.Add(updateLockedOption); updateCmd.Add(updateLockedOption);
updateCmd.Add(updateParamsOption);
updateCmd.Add(updateReturnOption);
updateCmd.SetAction(async (ParseResult result) => updateCmd.SetAction(async (ParseResult result) =>
{ {
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
@@ -346,7 +358,9 @@ public static class TemplateCommands
result.GetValue(updateCodeOption)!, result.GetValue(updateCodeOption)!,
result.GetValue(updateTriggerTypeOption)!, result.GetValue(updateTriggerTypeOption)!,
result.GetValue(updateTriggerConfigOption), result.GetValue(updateTriggerConfigOption),
result.GetValue(updateLockedOption))); result.GetValue(updateLockedOption),
result.GetValue(updateParamsOption),
result.GetValue(updateReturnOption)));
}); });
group.Add(updateCmd); group.Add(updateCmd);

View File

@@ -11,15 +11,11 @@
@inject ITemplateEngineRepository TemplateEngineRepository @inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository @inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService @inject CommunicationService CommunicationService
@inject IJSRuntime JS
@implements IDisposable @implements IDisposable
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <h4 class="mb-3">Debug View</h4>
<h4 class="mb-0">Debug View</h4>
<div class="alert alert-info py-1 px-2 mb-0 small">
Debug view streams are lost on failover. Re-open if connection drops.
</div>
</div>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
@@ -182,6 +178,24 @@
_loading = false; _loading = false;
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
var storedSiteId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.siteId");
var storedInstanceName = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.instanceName");
if (!string.IsNullOrEmpty(storedSiteId) && int.TryParse(storedSiteId, out var siteId)
&& !string.IsNullOrEmpty(storedInstanceName))
{
_selectedSiteId = siteId;
await LoadInstancesForSite();
_selectedInstanceName = storedInstanceName;
StateHasChanged();
await Connect();
}
}
private async Task LoadInstancesForSite() private async Task LoadInstancesForSite()
{ {
_siteInstances.Clear(); _siteInstances.Clear();
@@ -224,6 +238,12 @@
} }
_connected = true; _connected = true;
// Persist selection to localStorage for auto-reconnect on refresh
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteId", _selectedSiteId.ToString());
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.instanceName", _selectedInstanceName);
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteIdentifier", site.SiteIdentifier);
_toast.ShowSuccess($"Connected to {_selectedInstanceName}"); _toast.ShowSuccess($"Connected to {_selectedInstanceName}");
// Periodic refresh (simulating SignalR push by re-subscribing) // Periodic refresh (simulating SignalR push by re-subscribing)
@@ -253,7 +273,7 @@
_connecting = false; _connecting = false;
} }
private void Disconnect() private async Task Disconnect()
{ {
_refreshTimer?.Dispose(); _refreshTimer?.Dispose();
_refreshTimer = null; _refreshTimer = null;
@@ -268,6 +288,11 @@
} }
} }
// Clear persisted selection — user explicitly disconnected
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteId");
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceName");
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteIdentifier");
_connected = false; _connected = false;
_snapshot = null; _snapshot = null;
_attributeValues.Clear(); _attributeValues.Clear();

View File

@@ -4,6 +4,7 @@
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using static Microsoft.AspNetCore.Components.Web.RenderMode @using static Microsoft.AspNetCore.Components.Web.RenderMode
@using ScadaLink.CentralUI @using ScadaLink.CentralUI
@using ScadaLink.CentralUI.Components.Layout @using ScadaLink.CentralUI.Components.Layout

View File

@@ -1,3 +1,5 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Interfaces.Services; namespace ScadaLink.Commons.Interfaces.Services;
/// <summary> /// <summary>
@@ -34,4 +36,27 @@ public record ExternalCallResult(
bool Success, bool Success,
string? ResponseJson, string? ResponseJson,
string? ErrorMessage, 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;
}
}
}

View File

@@ -5,3 +5,10 @@ public record GetExternalSystemCommand(int ExternalSystemId);
public record CreateExternalSystemCommand(string Name, string EndpointUrl, string AuthType, string? AuthConfiguration); 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 UpdateExternalSystemCommand(int ExternalSystemId, string Name, string EndpointUrl, string AuthType, string? AuthConfiguration);
public record DeleteExternalSystemCommand(int ExternalSystemId); 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);

View File

@@ -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 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 UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
public record DeleteTemplateAlarmCommand(int AlarmId); public record DeleteTemplateAlarmCommand(int AlarmId);
public record AddTemplateScriptCommand(int TemplateId, 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); 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 DeleteTemplateScriptCommand(int ScriptId);
public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId); public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId);
public record DeleteTemplateCompositionCommand(int CompositionId); public record DeleteTemplateCompositionCommand(int CompositionId);

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, ReturnTypeMismatch,
TriggerOperandType, TriggerOperandType,
OnTriggerScriptNotFound, OnTriggerScriptNotFound,
CrossCallViolation CrossCallViolation,
MissingMetadata
} }

View File

@@ -83,8 +83,18 @@ public class FlatteningPipeline : IFlatteningPipeline
var config = flattenResult.Value; var config = flattenResult.Value;
// Load shared scripts for semantic validation
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList();
// Validate // Validate
var validation = _validationService.Validate(config); var validation = _validationService.Validate(config, resolvedSharedScripts);
// Compute revision hash // Compute revision hash
var hash = _revisionHashService.ComputeHash(config); var hash = _revisionHashService.ComputeHash(config);

View File

@@ -11,9 +11,11 @@ using ScadaLink.Communication.Actors;
using ScadaLink.Host.Actors; using ScadaLink.Host.Actors;
using ScadaLink.SiteRuntime; using ScadaLink.SiteRuntime;
using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Messages;
using ScadaLink.SiteRuntime.Persistence; using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts; using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.SiteRuntime.Streaming; using ScadaLink.SiteRuntime.Streaming;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host.Actors; namespace ScadaLink.Host.Actors;
@@ -231,6 +233,26 @@ akka {{
// Resolve the health collector for the Deployment Manager // Resolve the health collector for the Deployment Manager
var siteHealthCollector = _serviceProvider.GetService<ScadaLink.HealthMonitoring.ISiteHealthCollector>(); var siteHealthCollector = _serviceProvider.GetService<ScadaLink.HealthMonitoring.ISiteHealthCollector>();
// Create SiteReplicationActor on every node (not a singleton)
var sfStorage = _serviceProvider.GetRequiredService<StoreAndForwardStorage>();
var replicationService = _serviceProvider.GetRequiredService<ReplicationService>();
var replicationLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<SiteReplicationActor>();
var replicationActor = _actorSystem!.ActorOf(
Props.Create(() => new SiteReplicationActor(
storage, sfStorage, replicationService, siteRole, replicationLogger)),
"site-replication");
// Wire S&F replication handler to forward operations via the replication actor
replicationService.SetReplicationHandler(op =>
{
replicationActor.Tell(new ReplicateStoreAndForward(op));
return Task.CompletedTask;
});
_logger.LogInformation("SiteReplicationActor created and S&F replication handler wired");
// Create the Deployment Manager as a cluster singleton // Create the Deployment Manager as a cluster singleton
var singletonProps = ClusterSingletonManager.Props( var singletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new DeploymentManagerActor( singletonProps: Props.Create(() => new DeploymentManagerActor(
@@ -241,6 +263,7 @@ akka {{
siteRuntimeOptionsValue, siteRuntimeOptionsValue,
dmLogger, dmLogger,
dclManager, dclManager,
replicationActor,
siteHealthCollector, siteHealthCollector,
_serviceProvider)), _serviceProvider)),
terminationMessage: PoisonPill.Instance, terminationMessage: PoisonPill.Instance,
@@ -267,6 +290,9 @@ akka {{
dmProxy)), dmProxy)),
"site-communication"); "site-communication");
// Register local handlers with SiteCommunicationActor
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.Artifacts, dmProxy));
// Register SiteCommunicationActor with ClusterClientReceptionist so central ClusterClients can reach it // Register SiteCommunicationActor with ClusterClientReceptionist so central ClusterClients can reach it
ClusterClientReceptionist.Get(_actorSystem).RegisterService(siteCommActor); ClusterClientReceptionist.Get(_actorSystem).RegisterService(siteCommActor);

View File

@@ -90,6 +90,8 @@ public class ManagementActor : ReceiveActor
or ValidateTemplateCommand or ValidateTemplateCommand
or CreateExternalSystemCommand or UpdateExternalSystemCommand or CreateExternalSystemCommand or UpdateExternalSystemCommand
or DeleteExternalSystemCommand or DeleteExternalSystemCommand
or CreateExternalSystemMethodCommand or UpdateExternalSystemMethodCommand
or DeleteExternalSystemMethodCommand
or CreateNotificationListCommand or UpdateNotificationListCommand or CreateNotificationListCommand or UpdateNotificationListCommand
or DeleteNotificationListCommand or DeleteNotificationListCommand
or UpdateSmtpConfigCommand or UpdateSmtpConfigCommand
@@ -178,6 +180,11 @@ public class ManagementActor : ReceiveActor
CreateExternalSystemCommand cmd => await HandleCreateExternalSystem(sp, cmd), CreateExternalSystemCommand cmd => await HandleCreateExternalSystem(sp, cmd),
UpdateExternalSystemCommand cmd => await HandleUpdateExternalSystem(sp, cmd), UpdateExternalSystemCommand cmd => await HandleUpdateExternalSystem(sp, cmd),
DeleteExternalSystemCommand cmd => await HandleDeleteExternalSystem(sp, cmd), DeleteExternalSystemCommand cmd => await HandleDeleteExternalSystem(sp, cmd),
ListExternalSystemMethodsCommand cmd => await HandleListExternalSystemMethods(sp, cmd),
GetExternalSystemMethodCommand cmd => await HandleGetExternalSystemMethod(sp, cmd),
CreateExternalSystemMethodCommand cmd => await HandleCreateExternalSystemMethod(sp, cmd),
UpdateExternalSystemMethodCommand cmd => await HandleUpdateExternalSystemMethod(sp, cmd),
DeleteExternalSystemMethodCommand cmd => await HandleDeleteExternalSystemMethod(sp, cmd),
// Notification Lists // Notification Lists
ListNotificationListsCommand => await HandleListNotificationLists(sp), ListNotificationListsCommand => await HandleListNotificationLists(sp),
@@ -568,6 +575,55 @@ public class ManagementActor : ReceiveActor
return true; return true;
} }
private static async Task<object?> HandleListExternalSystemMethods(IServiceProvider sp, ListExternalSystemMethodsCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetMethodsByExternalSystemIdAsync(cmd.ExternalSystemId);
}
private static async Task<object?> HandleGetExternalSystemMethod(IServiceProvider sp, GetExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId);
}
private static async Task<object?> HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var method = new ExternalSystemMethod(cmd.Name, cmd.HttpMethod, cmd.Path)
{
ExternalSystemDefinitionId = cmd.ExternalSystemId,
ParameterDefinitions = cmd.ParameterDefinitions,
ReturnDefinition = cmd.ReturnDefinition
};
await repo.AddExternalSystemMethodAsync(method);
await repo.SaveChangesAsync();
return method;
}
private static async Task<object?> HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var method = await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId)
?? throw new InvalidOperationException($"ExternalSystemMethod with ID {cmd.MethodId} not found.");
if (cmd.Name != null) method.Name = cmd.Name;
if (cmd.HttpMethod != null) method.HttpMethod = cmd.HttpMethod;
if (cmd.Path != null) method.Path = cmd.Path;
if (cmd.ParameterDefinitions != null) method.ParameterDefinitions = cmd.ParameterDefinitions;
if (cmd.ReturnDefinition != null) method.ReturnDefinition = cmd.ReturnDefinition;
await repo.UpdateExternalSystemMethodAsync(method);
await repo.SaveChangesAsync();
return method;
}
private static async Task<object?> HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
await repo.DeleteExternalSystemMethodAsync(cmd.MethodId);
await repo.SaveChangesAsync();
return true;
}
// ======================================================================== // ========================================================================
// Notification handlers // Notification handlers
// ======================================================================== // ========================================================================
@@ -850,7 +906,9 @@ public class ManagementActor : ReceiveActor
{ {
TriggerType = cmd.TriggerType, TriggerType = cmd.TriggerType,
TriggerConfiguration = cmd.TriggerConfiguration, TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked IsLocked = cmd.IsLocked,
ParameterDefinitions = cmd.ParameterDefinitions,
ReturnDefinition = cmd.ReturnDefinition
}; };
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user); var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error); return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
@@ -863,7 +921,9 @@ public class ManagementActor : ReceiveActor
{ {
TriggerType = cmd.TriggerType, TriggerType = cmd.TriggerType,
TriggerConfiguration = cmd.TriggerConfiguration, TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked IsLocked = cmd.IsLocked,
ParameterDefinitions = cmd.ParameterDefinitions,
ReturnDefinition = cmd.ReturnDefinition
}; };
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user); var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error); return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);

View File

@@ -6,6 +6,7 @@ using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Enums;
using ScadaLink.HealthMonitoring; using ScadaLink.HealthMonitoring;
using ScadaLink.SiteRuntime.Messages;
using ScadaLink.SiteRuntime.Persistence; using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts; using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.SiteRuntime.Streaming; using ScadaLink.SiteRuntime.Streaming;
@@ -31,6 +32,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly SiteRuntimeOptions _options; private readonly SiteRuntimeOptions _options;
private readonly ILogger<DeploymentManagerActor> _logger; private readonly ILogger<DeploymentManagerActor> _logger;
private readonly IActorRef? _dclManager; private readonly IActorRef? _dclManager;
private readonly IActorRef? _replicationActor;
private readonly ISiteHealthCollector? _healthCollector; private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider; private readonly IServiceProvider? _serviceProvider;
private readonly Dictionary<string, IActorRef> _instanceActors = new(); private readonly Dictionary<string, IActorRef> _instanceActors = new();
@@ -46,6 +48,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
SiteRuntimeOptions options, SiteRuntimeOptions options,
ILogger<DeploymentManagerActor> logger, ILogger<DeploymentManagerActor> logger,
IActorRef? dclManager = null, IActorRef? dclManager = null,
IActorRef? replicationActor = null,
ISiteHealthCollector? healthCollector = null, ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null) IServiceProvider? serviceProvider = null)
{ {
@@ -55,6 +58,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_streamManager = streamManager; _streamManager = streamManager;
_options = options; _options = options;
_dclManager = dclManager; _dclManager = dclManager;
_replicationActor = replicationActor;
_healthCollector = healthCollector; _healthCollector = healthCollector;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
@@ -238,6 +242,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Static overrides are reset on redeployment per design decision // Static overrides are reset on redeployment per design decision
await _storage.ClearStaticOverridesAsync(instanceName); await _storage.ClearStaticOverridesAsync(instanceName);
// Replicate to standby node
_replicationActor?.Tell(new ReplicateConfigDeploy(
instanceName, command.FlattenedConfigurationJson,
command.DeploymentId, command.RevisionHash, true));
return new DeployPersistenceResult(command.DeploymentId, instanceName, true, null, sender); return new DeployPersistenceResult(command.DeploymentId, instanceName, true, null, sender);
}).ContinueWith(t => }).ContinueWith(t =>
{ {
@@ -285,6 +294,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var sender = Sender; var sender = Sender;
_storage.SetInstanceEnabledAsync(instanceName, false).ContinueWith(t => _storage.SetInstanceEnabledAsync(instanceName, false).ContinueWith(t =>
{ {
if (t.IsCompletedSuccessfully)
_replicationActor?.Tell(new ReplicateConfigSetEnabled(instanceName, false));
return new InstanceLifecycleResponse( return new InstanceLifecycleResponse(
command.CommandId, command.CommandId,
instanceName, instanceName,
@@ -308,6 +320,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Task.Run(async () => Task.Run(async () =>
{ {
await _storage.SetInstanceEnabledAsync(instanceName, true); await _storage.SetInstanceEnabledAsync(instanceName, true);
_replicationActor?.Tell(new ReplicateConfigSetEnabled(instanceName, true));
var configs = await _storage.GetAllDeployedConfigsAsync(); var configs = await _storage.GetAllDeployedConfigsAsync();
var config = configs.FirstOrDefault(c => c.InstanceUniqueName == instanceName); var config = configs.FirstOrDefault(c => c.InstanceUniqueName == instanceName);
return new EnableResult(command, config, null, sender); return new EnableResult(command, config, null, sender);
@@ -365,6 +378,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var sender = Sender; var sender = Sender;
_storage.RemoveDeployedConfigAsync(instanceName).ContinueWith(t => _storage.RemoveDeployedConfigAsync(instanceName).ContinueWith(t =>
{ {
if (t.IsCompletedSuccessfully)
_replicationActor?.Tell(new ReplicateConfigRemove(instanceName));
return new InstanceLifecycleResponse( return new InstanceLifecycleResponse(
command.CommandId, command.CommandId,
instanceName, instanceName,
@@ -548,6 +564,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
} }
} }
// Replicate artifacts to standby node
_replicationActor?.Tell(new ReplicateArtifacts(command));
return new ArtifactDeploymentResponse( return new ArtifactDeploymentResponse(
command.DeploymentId, "", true, null, DateTimeOffset.UtcNow); command.DeploymentId, "", true, null, DateTimeOffset.UtcNow);
} }

View File

@@ -261,6 +261,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
"interval" => ParseIntervalTrigger(triggerConfigJson), "interval" => ParseIntervalTrigger(triggerConfigJson),
"valuechange" => ParseValueChangeTrigger(triggerConfigJson), "valuechange" => ParseValueChangeTrigger(triggerConfigJson),
"conditional" => ParseConditionalTrigger(triggerConfigJson), "conditional" => ParseConditionalTrigger(triggerConfigJson),
"call" => null, // No automatic trigger — invoked only via Instance.CallScript()
_ => null _ => null
}; };
} }

View File

@@ -0,0 +1,214 @@
using Akka.Actor;
using Akka.Cluster;
using Akka.Event;
using Microsoft.Extensions.Logging;
using ScadaLink.SiteRuntime.Messages;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.StoreAndForward;
namespace ScadaLink.SiteRuntime.Actors;
/// <summary>
/// Runs on every site node (not a singleton). Handles both config and S&amp;F replication
/// between site cluster peers.
///
/// Outbound: receives local replication requests and forwards to peer via ActorSelection.
/// Inbound: receives replicated operations from peer and applies to local SQLite.
/// Uses fire-and-forget (Tell) — no ack wait per design.
/// </summary>
public class SiteReplicationActor : ReceiveActor
{
private readonly SiteStorageService _storage;
private readonly StoreAndForwardStorage _sfStorage;
private readonly ReplicationService _replicationService;
private readonly string _siteRole;
private readonly ILogger<SiteReplicationActor> _logger;
private readonly Cluster _cluster;
private Address? _peerAddress;
public SiteReplicationActor(
SiteStorageService storage,
StoreAndForwardStorage sfStorage,
ReplicationService replicationService,
string siteRole,
ILogger<SiteReplicationActor> logger)
{
_storage = storage;
_sfStorage = sfStorage;
_replicationService = replicationService;
_siteRole = siteRole;
_logger = logger;
_cluster = Cluster.Get(Context.System);
// Cluster member events
Receive<ClusterEvent.MemberUp>(HandleMemberUp);
Receive<ClusterEvent.MemberRemoved>(HandleMemberRemoved);
Receive<ClusterEvent.CurrentClusterState>(HandleCurrentClusterState);
// Outbound — forward to peer
Receive<ReplicateConfigDeploy>(msg => SendToPeer(new ApplyConfigDeploy(
msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled)));
Receive<ReplicateConfigRemove>(msg => SendToPeer(new ApplyConfigRemove(msg.InstanceName)));
Receive<ReplicateConfigSetEnabled>(msg => SendToPeer(new ApplyConfigSetEnabled(
msg.InstanceName, msg.IsEnabled)));
Receive<ReplicateArtifacts>(msg => SendToPeer(new ApplyArtifacts(msg.Command)));
Receive<ReplicateStoreAndForward>(msg => SendToPeer(new ApplyStoreAndForward(msg.Operation)));
// Inbound — apply from peer
Receive<ApplyConfigDeploy>(HandleApplyConfigDeploy);
Receive<ApplyConfigRemove>(HandleApplyConfigRemove);
Receive<ApplyConfigSetEnabled>(HandleApplyConfigSetEnabled);
Receive<ApplyArtifacts>(HandleApplyArtifacts);
Receive<ApplyStoreAndForward>(HandleApplyStoreAndForward);
}
protected override void PreStart()
{
base.PreStart();
_cluster.Subscribe(Self, ClusterEvent.SubscriptionInitialStateMode.InitialStateAsSnapshot,
typeof(ClusterEvent.MemberUp),
typeof(ClusterEvent.MemberRemoved));
_logger.LogInformation("SiteReplicationActor started, subscribing to cluster events for role {Role}", _siteRole);
}
protected override void PostStop()
{
_cluster.Unsubscribe(Self);
base.PostStop();
}
private void HandleCurrentClusterState(ClusterEvent.CurrentClusterState state)
{
foreach (var member in state.Members)
{
if (member.Status == MemberStatus.Up)
TryTrackPeer(member);
}
}
private void HandleMemberUp(ClusterEvent.MemberUp evt)
{
TryTrackPeer(evt.Member);
}
private void HandleMemberRemoved(ClusterEvent.MemberRemoved evt)
{
if (evt.Member.Address.Equals(_peerAddress))
{
_logger.LogInformation("Peer node removed: {Address}", _peerAddress);
_peerAddress = null;
}
}
private void TryTrackPeer(Member member)
{
// Must have our site role, and must not be self
if (member.HasRole(_siteRole) && !member.Address.Equals(_cluster.SelfAddress))
{
_peerAddress = member.Address;
_logger.LogInformation("Peer node tracked: {Address}", _peerAddress);
}
}
private void SendToPeer(object message)
{
if (_peerAddress == null)
{
_logger.LogDebug("No peer available, dropping replication message {Type}", message.GetType().Name);
return;
}
var path = new RootActorPath(_peerAddress) / "user" / "site-replication";
Context.ActorSelection(path).Tell(message);
}
// ── Inbound handlers ──
private void HandleApplyConfigDeploy(ApplyConfigDeploy msg)
{
_logger.LogInformation("Applying replicated config deploy for {Instance}", msg.InstanceName);
_storage.StoreDeployedConfigAsync(
msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated deploy for {Instance}", msg.InstanceName);
});
}
private void HandleApplyConfigRemove(ApplyConfigRemove msg)
{
_logger.LogInformation("Applying replicated config remove for {Instance}", msg.InstanceName);
_storage.RemoveDeployedConfigAsync(msg.InstanceName)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated remove for {Instance}", msg.InstanceName);
});
}
private void HandleApplyConfigSetEnabled(ApplyConfigSetEnabled msg)
{
_logger.LogInformation("Applying replicated set-enabled={Enabled} for {Instance}", msg.IsEnabled, msg.InstanceName);
_storage.SetInstanceEnabledAsync(msg.InstanceName, msg.IsEnabled)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated set-enabled for {Instance}", msg.InstanceName);
});
}
private void HandleApplyArtifacts(ApplyArtifacts msg)
{
var command = msg.Command;
_logger.LogInformation("Applying replicated artifacts, deploymentId={DeploymentId}", command.DeploymentId);
Task.Run(async () =>
{
try
{
if (command.SharedScripts != null)
foreach (var s in command.SharedScripts)
await _storage.StoreSharedScriptAsync(s.Name, s.Code, s.ParameterDefinitions, s.ReturnDefinition);
if (command.ExternalSystems != null)
foreach (var es in command.ExternalSystems)
await _storage.StoreExternalSystemAsync(es.Name, es.EndpointUrl, es.AuthType, es.AuthConfiguration, es.MethodDefinitionsJson);
if (command.DatabaseConnections != null)
foreach (var db in command.DatabaseConnections)
await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString, db.MaxRetries, db.RetryDelay);
if (command.NotificationLists != null)
foreach (var nl in command.NotificationLists)
await _storage.StoreNotificationListAsync(nl.Name, nl.RecipientEmails);
if (command.DataConnections != null)
foreach (var dc in command.DataConnections)
await _storage.StoreDataConnectionDefinitionAsync(dc.Name, dc.Protocol, dc.ConfigurationJson);
if (command.SmtpConfigurations != null)
foreach (var smtp in command.SmtpConfigurations)
await _storage.StoreSmtpConfigurationAsync(smtp.Name, smtp.Server, smtp.Port, smtp.AuthMode,
smtp.FromAddress, smtp.Username, smtp.Password, smtp.OAuthConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply replicated artifacts");
}
});
}
private void HandleApplyStoreAndForward(ApplyStoreAndForward msg)
{
_logger.LogDebug("Applying replicated S&F operation {OpType} for message {Id}",
msg.Operation.OperationType, msg.Operation.MessageId);
_replicationService.ApplyReplicatedOperationAsync(msg.Operation, _sfStorage)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated S&F operation {Id}", msg.Operation.MessageId);
});
}
}

View File

@@ -0,0 +1,34 @@
using ScadaLink.Commons.Messages.Artifacts;
using ScadaLink.StoreAndForward;
namespace ScadaLink.SiteRuntime.Messages;
/// <summary>
/// Outbound messages — sent by local DeploymentManagerActor/S&amp;F service
/// to the local SiteReplicationActor for forwarding to the peer node.
/// </summary>
public record ReplicateConfigDeploy(
string InstanceName, string ConfigJson, string DeploymentId, string RevisionHash, bool IsEnabled);
public record ReplicateConfigRemove(string InstanceName);
public record ReplicateConfigSetEnabled(string InstanceName, bool IsEnabled);
public record ReplicateArtifacts(DeployArtifactsCommand Command);
public record ReplicateStoreAndForward(ReplicationOperation Operation);
/// <summary>
/// Inbound messages — received from the peer's SiteReplicationActor
/// and applied to local SQLite storage.
/// </summary>
public record ApplyConfigDeploy(
string InstanceName, string ConfigJson, string DeploymentId, string RevisionHash, bool IsEnabled);
public record ApplyConfigRemove(string InstanceName);
public record ApplyConfigSetEnabled(string InstanceName, bool IsEnabled);
public record ApplyArtifacts(DeployArtifactsCommand Command);
public record ApplyStoreAndForward(ReplicationOperation Operation);

View File

@@ -23,6 +23,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" /> <ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" /> <ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -108,7 +108,9 @@ public class ScriptCompilationService
.WithReferences( .WithReferences(
typeof(object).Assembly, typeof(object).Assembly,
typeof(Enumerable).Assembly, typeof(Enumerable).Assembly,
typeof(Math).Assembly) typeof(Math).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.DynamicJsonElement).Assembly)
.WithImports( .WithImports(
"System", "System",
"System.Collections.Generic", "System.Collections.Generic",

View File

@@ -104,6 +104,20 @@ public class SemanticValidator
} }
} }
// Validate Call-type scripts have parameter definitions
foreach (var script in configuration.Scripts)
{
if (string.Equals(script.TriggerType, "Call", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.MissingMetadata,
$"Call-type script '{script.CanonicalName}' has no parameter definitions.",
script.CanonicalName));
}
}
}
// Validate alarm trigger operand types // Validate alarm trigger operand types
foreach (var alarm in configuration.Alarms) foreach (var alarm in configuration.Alarms)
{ {

View File

@@ -22,12 +22,15 @@ public class ConcurrencyTestDbContext : ScadaLinkDbContext
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
// Replace the SQL Server RowVersion with an explicit concurrency token for SQLite // Replace the SQL Server RowVersion with an explicit concurrency token for SQLite.
// Remove the shadow RowVersion property and add a visible ConcurrencyStamp // SQLite can't auto-generate rowversion, so disable it and use Status as the token instead.
modelBuilder.Entity<DeploymentRecord>(builder => modelBuilder.Entity<DeploymentRecord>(builder =>
{ {
// The shadow RowVersion property from the base config doesn't work in SQLite. builder.Property(d => d.RowVersion)
// Instead, use Status as a concurrency token for the test. .IsRequired(false)
.IsConcurrencyToken(false)
.ValueGeneratedNever();
builder.Property(d => d.Status).IsConcurrencyToken(); builder.Property(d => d.Status).IsConcurrencyToken();
}); });
} }

View File

@@ -68,8 +68,9 @@ public class SecurityRepositoryTests : IDisposable
await _repository.SaveChangesAsync(); await _repository.SaveChangesAsync();
var designMappings = await _repository.GetMappingsByRoleAsync("Design"); var designMappings = await _repository.GetMappingsByRoleAsync("Design");
Assert.Single(designMappings); // Seed data includes "SCADA-Designers" with role "Design", plus the one we added
Assert.Equal("Designers", designMappings[0].LdapGroupName); Assert.Equal(2, designMappings.Count);
Assert.Contains(designMappings, m => m.LdapGroupName == "Designers");
} }
[Fact] [Fact]

View File

@@ -1,13 +1,15 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using ScadaLink.Commons.Entities.Deployment;
using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.ConfigurationDatabase.Tests; namespace ScadaLink.ConfigurationDatabase.Tests;
/// <summary> /// <summary>
/// Test DbContext that maps DateTimeOffset to a sortable string format for SQLite. /// Test DbContext that adapts SQL Server-specific features for SQLite:
/// EF Core 10 SQLite provider does not support ORDER BY on DateTimeOffset columns. /// - Maps DateTimeOffset to sortable ISO 8601 strings (SQLite has no native DateTimeOffset ORDER BY)
/// - Replaces SQL Server RowVersion with a nullable byte[] column (SQLite can't auto-generate rowversion)
/// </summary> /// </summary>
public class SqliteTestDbContext : ScadaLinkDbContext public class SqliteTestDbContext : ScadaLinkDbContext
{ {
@@ -19,6 +21,16 @@ public class SqliteTestDbContext : ScadaLinkDbContext
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
// SQLite cannot auto-generate SQL Server rowversion values.
// Replace with a nullable byte[] column so inserts don't fail with NOT NULL constraint.
modelBuilder.Entity<DeploymentRecord>(builder =>
{
builder.Property(d => d.RowVersion)
.IsRequired(false)
.IsConcurrencyToken(false)
.ValueGeneratedNever();
});
// Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works // Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works
var converter = new ValueConverter<DateTimeOffset, string>( var converter = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("o"), v => v.UtcDateTime.ToString("o"),

View File

@@ -21,13 +21,7 @@ public class DbContextTests : IDisposable
public DbContextTests() public DbContextTests()
{ {
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>() _context = SqliteTestHelper.CreateInMemoryContext();
.UseSqlite("DataSource=:memory:")
.Options;
_context = new ScadaLinkDbContext(options);
_context.Database.OpenConnection();
_context.Database.EnsureCreated();
} }
public void Dispose() public void Dispose()
@@ -429,6 +423,6 @@ public class MigrationHelperTests : IDisposable
{ {
// Verify the InitialCreate migration is detected as pending // Verify the InitialCreate migration is detected as pending
var pending = _context.Database.GetPendingMigrations().ToList(); var pending = _context.Database.GetPendingMigrations().ToList();
Assert.Contains(pending, m => m.Contains("InitialCreate")); Assert.Contains(pending, m => m.Contains("InitialSchema"));
} }
} }