refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,337 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
/// <summary>
/// Site-side read-only implementation of <see cref="IExternalSystemRepository"/>
/// backed by the local SQLite database via <see cref="SiteStorageService"/>.
/// Write operations throw <see cref="NotSupportedException"/> because site-local
/// artifacts are managed exclusively through deployment from Central.
/// </summary>
public class SiteExternalSystemRepository : IExternalSystemRepository
{
private readonly SiteStorageService _storage;
/// <summary>
/// Initializes a new site-side external system repository.
/// </summary>
/// <param name="storage">Storage service providing database access.</param>
public SiteExternalSystemRepository(SiteStorageService storage)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
}
// ── ExternalSystemDefinition (read) ──
/// <inheritdoc />
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(
CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT name, endpoint_url, auth_type, auth_configuration
FROM external_systems";
var results = new List<ExternalSystemDefinition>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(MapExternalSystem(reader));
}
return results;
}
/// <inheritdoc />
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(
int id, CancellationToken cancellationToken = default)
{
// The SQLite table is keyed by name, not by integer ID.
// Scan all rows and match on the synthetic ID derived from the name.
var all = await GetAllExternalSystemsAsync(cancellationToken);
return all.FirstOrDefault(e => e.Id == id);
}
/// <inheritdoc />
public async Task<ExternalSystemDefinition?> GetExternalSystemByNameAsync(
string name, CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT name, endpoint_url, auth_type, auth_configuration
FROM external_systems
WHERE name = @name";
command.Parameters.AddWithValue("@name", name);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return null;
return MapExternalSystem(reader);
}
// ── ExternalSystemMethod (read) ──
/// <inheritdoc />
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(
int externalSystemId, CancellationToken cancellationToken = default)
{
// Find the parent system to get its name, then parse its method_definitions JSON.
var system = await GetExternalSystemByIdAsync(externalSystemId, cancellationToken);
if (system is null)
return Array.Empty<ExternalSystemMethod>();
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT method_definitions
FROM external_systems
WHERE name = @name";
command.Parameters.AddWithValue("@name", system.Name);
var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<ExternalSystemMethod>();
return ParseMethodDefinitions(json, externalSystemId);
}
/// <inheritdoc />
public async Task<ExternalSystemMethod?> GetMethodByNameAsync(
int externalSystemId, string methodName, CancellationToken cancellationToken = default)
{
var methods = await GetMethodsByExternalSystemIdAsync(externalSystemId, cancellationToken);
return methods.FirstOrDefault(
m => m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase));
}
/// <inheritdoc />
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(
int id, CancellationToken cancellationToken = default)
{
// Scan all systems and their methods to find the matching synthetic ID.
var systems = await GetAllExternalSystemsAsync(cancellationToken);
foreach (var system in systems)
{
var methods = await GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken);
var match = methods.FirstOrDefault(m => m.Id == id);
if (match is not null)
return match;
}
return null;
}
// ── DatabaseConnectionDefinition (read) ──
/// <inheritdoc />
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(
CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT name, connection_string, max_retries, retry_delay_ms
FROM database_connections";
var results = new List<DatabaseConnectionDefinition>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var def = new DatabaseConnectionDefinition(
name: reader.GetString(0),
connectionString: reader.GetString(1))
{
Id = GenerateSyntheticId(reader.GetString(0)),
MaxRetries = reader.GetInt32(2),
RetryDelay = TimeSpan.FromMilliseconds(reader.GetInt64(3))
};
results.Add(def);
}
return results;
}
/// <inheritdoc />
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(
int id, CancellationToken cancellationToken = default)
{
var all = await GetAllDatabaseConnectionsAsync(cancellationToken);
return all.FirstOrDefault(d => d.Id == id);
}
/// <inheritdoc />
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByNameAsync(
string name, CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT name, connection_string, max_retries, retry_delay_ms
FROM database_connections
WHERE name = @name";
command.Parameters.AddWithValue("@name", name);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return null;
return new DatabaseConnectionDefinition(
name: reader.GetString(0),
connectionString: reader.GetString(1))
{
Id = GenerateSyntheticId(reader.GetString(0)),
MaxRetries = reader.GetInt32(2),
RetryDelay = TimeSpan.FromMilliseconds(reader.GetInt64(3))
};
}
// ── Write operations (not supported on site) ──
/// <inheritdoc />
public Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
// ── Private helpers ──
/// <summary>
/// Creates a new SQLite connection against the site database via
/// <see cref="SiteStorageService.CreateConnection"/> (SiteRuntime-006). The
/// connection string is owned by <see cref="SiteStorageService"/>; the repository
/// no longer reaches into its private state via reflection.
/// </summary>
private SqliteConnection CreateConnection() => _storage.CreateConnection();
private static ExternalSystemDefinition MapExternalSystem(SqliteDataReader reader)
{
var name = reader.GetString(0);
return new ExternalSystemDefinition(
name: name,
endpointUrl: reader.GetString(1),
authType: reader.GetString(2))
{
Id = GenerateSyntheticId(name),
AuthConfiguration = reader.IsDBNull(3) ? null : reader.GetString(3)
};
}
private static IReadOnlyList<ExternalSystemMethod> ParseMethodDefinitions(
string json, int externalSystemId)
{
try
{
var methods = JsonSerializer.Deserialize<List<MethodDefinitionDto>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (methods is null)
return Array.Empty<ExternalSystemMethod>();
return methods.Select(m => new ExternalSystemMethod(
name: m.Name ?? string.Empty,
httpMethod: m.HttpMethod ?? "GET",
path: m.Path ?? string.Empty)
{
Id = GenerateSyntheticId($"{externalSystemId}:{m.Name}"),
ExternalSystemDefinitionId = externalSystemId,
ParameterDefinitions = m.ParameterDefinitions,
ReturnDefinition = m.ReturnDefinition
}).ToList();
}
catch (JsonException)
{
return Array.Empty<ExternalSystemMethod>();
}
}
/// <summary>
/// Generates a stable positive integer ID from a string name (SiteRuntime-007).
/// Uses a deterministic FNV-1a hash rather than <see cref="string.GetHashCode()"/>,
/// which is randomized per process on .NET Core and would therefore change every
/// time the process restarts — breaking any caller that stored an ID and later
/// looks the entity up by that ID.
/// </summary>
private static int GenerateSyntheticId(string name) => SyntheticId.From(name);
/// <summary>
/// DTO for deserializing individual method entries from the method_definitions JSON column.
/// </summary>
private sealed class MethodDefinitionDto
{
/// <summary>
/// Name of the external system method.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// HTTP method (GET, POST, PUT, DELETE, etc.) for the API call.
/// </summary>
public string? HttpMethod { get; set; }
/// <summary>
/// Path component of the endpoint URL.
/// </summary>
public string? Path { get; set; }
/// <summary>
/// JSON-serialized parameter definitions for the method.
/// </summary>
public string? ParameterDefinitions { get; set; }
/// <summary>
/// JSON-serialized return value definition for the method.
/// </summary>
public string? ReturnDefinition { get; set; }
}
}
@@ -0,0 +1,274 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
/// <summary>
/// Site-side read-only implementation of <see cref="INotificationRepository"/>
/// backed by the local SQLite database via <see cref="SiteStorageService"/>.
/// Write operations throw <see cref="NotSupportedException"/> because site-local
/// artifacts are managed exclusively through deployment from Central.
/// </summary>
public class SiteNotificationRepository : INotificationRepository
{
private readonly SiteStorageService _storage;
/// <summary>
/// Initializes a new instance of the <see cref="SiteNotificationRepository"/> class.
/// </summary>
/// <param name="storage">The site storage service for database access.</param>
public SiteNotificationRepository(SiteStorageService storage)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
}
// ── NotificationList (read) ──
/// <inheritdoc />
public async Task<NotificationList?> GetListByNameAsync(
string name, CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT name, recipient_emails
FROM notification_lists
WHERE name = @name";
command.Parameters.AddWithValue("@name", name);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return null;
return MapNotificationList(reader);
}
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(
CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = "SELECT name, recipient_emails FROM notification_lists";
var results = new List<NotificationList>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(MapNotificationList(reader));
}
return results;
}
/// <inheritdoc />
public async Task<NotificationList?> GetNotificationListByIdAsync(
int id, CancellationToken cancellationToken = default)
{
// The SQLite table is keyed by name, not by integer ID.
// Scan all rows and match on the synthetic ID derived from the name.
var all = await GetAllNotificationListsAsync(cancellationToken);
return all.FirstOrDefault(l => l.Id == id);
}
// ── NotificationRecipient (read) ──
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(
int notificationListId, CancellationToken cancellationToken = default)
{
// Find the parent list to get its name, then parse the recipient_emails JSON.
var list = await GetNotificationListByIdAsync(notificationListId, cancellationToken);
if (list is null)
return Array.Empty<NotificationRecipient>();
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT recipient_emails
FROM notification_lists
WHERE name = @name";
command.Parameters.AddWithValue("@name", list.Name);
var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<NotificationRecipient>();
return ParseRecipientEmails(json, notificationListId);
}
/// <inheritdoc />
public async Task<NotificationRecipient?> GetRecipientByIdAsync(
int id, CancellationToken cancellationToken = default)
{
// Scan all lists and their recipients to find the matching synthetic ID.
var lists = await GetAllNotificationListsAsync(cancellationToken);
foreach (var list in lists)
{
var recipients = await GetRecipientsByListIdAsync(list.Id, cancellationToken);
var match = recipients.FirstOrDefault(r => r.Id == id);
if (match is not null)
return match;
}
return null;
}
// ── SmtpConfiguration (read) ──
/// <inheritdoc />
public async Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(
CancellationToken cancellationToken = default)
{
await using var connection = CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT name, server, port, auth_mode, from_address, username, password, oauth_config
FROM smtp_configurations";
var results = new List<SmtpConfiguration>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(MapSmtpConfiguration(reader));
}
return results;
}
/// <inheritdoc />
public async Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(
int id, CancellationToken cancellationToken = default)
{
var all = await GetAllSmtpConfigurationsAsync(cancellationToken);
return all.FirstOrDefault(s => s.Id == id);
}
// ── Write operations (not supported on site) ──
/// <inheritdoc />
public Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
/// <inheritdoc />
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Managed via artifact deployment from Central");
// ── Private helpers ──
/// <summary>
/// Creates a new SQLite connection against the site database via
/// <see cref="SiteStorageService.CreateConnection"/> (SiteRuntime-006) instead of
/// reaching into its private connection-string field via reflection.
/// </summary>
private SqliteConnection CreateConnection() => _storage.CreateConnection();
private static NotificationList MapNotificationList(SqliteDataReader reader)
{
var name = reader.GetString(0);
var list = new NotificationList(name)
{
Id = GenerateSyntheticId(name)
};
// Eagerly populate Recipients from the JSON column.
var emailsJson = reader.GetString(1);
var recipients = ParseRecipientEmails(emailsJson, list.Id);
foreach (var r in recipients)
{
list.Recipients.Add(r);
}
return list;
}
private static IReadOnlyList<NotificationRecipient> ParseRecipientEmails(
string json, int notificationListId)
{
try
{
var emails = JsonSerializer.Deserialize<List<string>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (emails is null)
return Array.Empty<NotificationRecipient>();
return emails.Select(email => new NotificationRecipient(
name: email,
emailAddress: email)
{
Id = GenerateSyntheticId($"{notificationListId}:{email}"),
NotificationListId = notificationListId
}).ToList();
}
catch (JsonException)
{
return Array.Empty<NotificationRecipient>();
}
}
private static SmtpConfiguration MapSmtpConfiguration(SqliteDataReader reader)
{
var name = reader.GetString(0);
return new SmtpConfiguration(
host: reader.GetString(1),
authType: reader.GetString(3),
fromAddress: reader.GetString(4))
{
Id = GenerateSyntheticId(name),
Port = reader.GetInt32(2),
Credentials = reader.IsDBNull(5) ? null : reader.GetString(5),
TlsMode = reader.IsDBNull(7) ? null : reader.GetString(7)
};
}
/// <summary>
/// Generates a stable positive integer ID from a string name (SiteRuntime-007).
/// Uses a deterministic FNV-1a hash rather than <see cref="string.GetHashCode()"/>,
/// which is randomized per process on .NET Core and would change every restart.
/// </summary>
private static int GenerateSyntheticId(string name) => SyntheticId.From(name);
}
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
/// <summary>
/// SiteRuntime-007: deterministic synthetic-ID generation for site-local artifacts.
///
/// The site SQLite tables are keyed by name rather than an auto-increment integer, but
/// the shared repository contracts (<c>IExternalSystemRepository</c>,
/// <c>INotificationRepository</c>) expose integer-keyed lookups. A synthetic integer ID
/// is therefore derived from the entity name. It MUST be stable across process restarts
/// — <see cref="string.GetHashCode()"/> is randomized per process on .NET Core and so
/// cannot be used.
/// </summary>
internal static class SyntheticId
{
// FNV-1a 32-bit constants.
private const uint FnvOffsetBasis = 2166136261;
private const uint FnvPrime = 16777619;
/// <summary>
/// Computes a deterministic, process-stable positive 31-bit integer ID for the
/// given name using the FNV-1a hash over its UTF-8 bytes.
/// </summary>
/// <param name="name">The string to hash into a synthetic integer ID.</param>
public static int From(string name)
{
var hash = FnvOffsetBasis;
foreach (var b in System.Text.Encoding.UTF8.GetBytes(name))
{
hash ^= b;
hash *= FnvPrime;
}
// Mask to a positive 31-bit value so the ID is always non-negative.
return (int)(hash & 0x7FFFFFFF);
}
}