f2efeb37b7
Tasks 5 and 6 of the Phase 2 plan, committed together because their test
fallout is entangled — several fixtures construct both stores.
StoreAndForwardStorage and SiteStorageService now take ILocalDb. Connections
come from ILocalDb.CreateConnection(), which hands out an already-open,
pragma-configured connection carrying the zb_hlc_next() UDF the capture triggers
call; a raw connection would lack the UDF and every write to a replicated table
would fail closed. Deleted with the connection strings: S&F's
EnsureDatabaseDirectoryExists and its per-open busy_timeout pragma, and the site
service's BusyTimeoutFloorSeconds normalization — LocalDb owns all of it now.
DI: AddSiteRuntime's string overload is gone (nothing left to supply), so the
Host calls the no-arg form. ScadaBridge:Database:SiteDbPath and
StoreAndForwardOptions.SqliteDbPath survive only as the migrator's source
locations in Tasks 8/9.
Two things the plan did not anticipate, both worth reading:
1. FOUND A REAL LATENT DEFECT, from Phase 1, now fixed. The plan assumed
directory creation simply moved to LocalDb along with file ownership. It did
not: the LocalDb library never creates the parent directory, and
SqliteLocalDb opens the file eagerly in its constructor — so a missing
directory is a hard boot failure ("SQLite Error 14: unable to open database
file"), not a degraded start. The default site config points at the RELATIVE
path ./data/site-localdb.db, so any site node without a pre-existing data/
directory fails to boot. The docker rig escapes only because its volume mount
happens to create /app/data — a coincidence that would have hidden this until
a bare-metal or fresh deployment. This has been latent since Phase 1 made
LocalDb:Path required; deleting S&F's EnsureDatabaseDirectoryExists here
would have widened it. Re-established the guarantee at the layer that now
owns the path (SiteLocalDbDirectory.Ensure, called before AddZbLocalDb) and
pinned it with SiteLocalDbDirectoryTests. Non-vacuity is not assumed: two
tests written against the wrong assumption failed with exactly this
SQLite Error 14 before the fix existed.
2. Test fallout was ~7x the plan's estimate. The plan named "fixtures" in one
project; the constructor change actually reaches 40 files across 7 test
projects, and most used Mode=Memory;Cache=Shared — which LocalDb has no
equivalent for, so every one had to move to a real temp file. Rather than
copy the Phase 1 TestLocalDb fixture into 7 projects, added a shared
tests/ZB.MOM.WW.ScadaBridge.TestSupport library (not a test project) so the
WAL-sidecar cleanup and the "real, not stubbed" rationale live in one place.
Retargeted rather than deleted, in both directions: the S&F WAL test now asserts
against the LocalDb-backed store (WAL genuinely is LocalDb's job), while the
directory-creation test moved to Host.Tests (that guarantee is NOT LocalDb's).
SiteStorageServiceTests.Initialize_EnablesWalJournalMode got the same treatment.
DeploymentManagerMediumFindingsTests induced a persistence failure via an
unopenable path, which no longer reaches the assertion since the fixture now
throws first; it induces the same failure shape via an uninitialized store.
Verified: full solution build 0 warnings; SiteRuntime 532, Host 318,
AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97,
StoreAndForward 153 — 1597 passed, 0 failed.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
344 lines
14 KiB
C#
344 lines
14 KiB
C#
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)
|
|
{
|
|
// Already open — SiteStorageService.CreateConnection now hands out a
|
|
// LocalDb-managed connection. Opening it again would throw.
|
|
await using var connection = CreateConnection();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
SELECT name, endpoint_url, auth_type, auth_configuration, timeout_seconds
|
|
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)
|
|
{
|
|
// Already open — SiteStorageService.CreateConnection now hands out a
|
|
// LocalDb-managed connection. Opening it again would throw.
|
|
await using var connection = CreateConnection();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
SELECT name, endpoint_url, auth_type, auth_configuration, timeout_seconds
|
|
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>();
|
|
|
|
// Already open — SiteStorageService.CreateConnection now hands out a
|
|
// LocalDb-managed connection. Opening it again would throw.
|
|
await using var connection = CreateConnection();
|
|
|
|
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)
|
|
{
|
|
// Already open — SiteStorageService.CreateConnection now hands out a
|
|
// LocalDb-managed connection. Opening it again would throw.
|
|
await using var connection = CreateConnection();
|
|
|
|
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)
|
|
{
|
|
// Already open — SiteStorageService.CreateConnection now hands out a
|
|
// LocalDb-managed connection. Opening it again would throw.
|
|
await using var connection = CreateConnection();
|
|
|
|
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"/>. 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),
|
|
TimeoutSeconds = reader.IsDBNull(4) ? 0 : reader.GetInt32(4)
|
|
};
|
|
}
|
|
|
|
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.
|
|
/// 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; }
|
|
}
|
|
}
|