Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs
T
Joseph Doherty ac5eb12cce refactor(site): extract SiteStorageSchema.Apply from SiteStorageService
Task 4 of the Phase 2 plan. All nine table definitions plus MigrateSchemaAsync
and TryAddColumnAsync move into a static sync Apply(SqliteConnection) depending
only on Microsoft.Data.Sqlite, so the Host can apply the DDL to a LocalDb-managed
connection before RegisterReplicated installs the capture triggers.

Per the plan, PRAGMA journal_mode=WAL stays in InitializeAsync — LocalDb owns
the connection's pragmas, and the service still opens its own connections until
Task 6.

One deviation: TryAddColumnAsync's `catch (SqliteException) when
(ex.Message.Contains("duplicate column"))` becomes a PRAGMA table_info probe,
matching OperationTrackingSchema. The message-matching form depends on an error
string that is not part of SQLite's contract, and it swallowed every
SqliteException whose message happened to contain that substring. The probe also
drops the ILogger dependency, which is what let the class become static. Cost:
the per-column "Migrated: added column" info log is gone — it fired once per
column per legacy database and nothing consumes it.

Also added a test beyond the plan's specified one, for the same reason as Task 3:
the specified test asserts against a freshly-created database, where CREATE TABLE
already lists the migration columns, so it would pass with every ALTER deleted.
The added test starts from the pre-migration shapes with a row present and proves
the migration path runs and preserves data across a NOT NULL DEFAULT add.

SiteRuntime suite 532 passed / 0 failed; full solution build 0 warnings.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 02:20:50 -04:00

869 lines
42 KiB
C#

using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
/// <summary>
/// Direct SQLite persistence for site-local deployment state.
/// Stores deployed instance configurations (as JSON) and static attribute overrides.
/// This is NOT EF Core — uses Microsoft.Data.Sqlite directly for lightweight site storage.
/// </summary>
public class SiteStorageService
{
private readonly string _connectionString;
private readonly ILogger<SiteStorageService> _logger;
/// <summary>
/// Initializes a new instance of the SiteStorageService with the specified SQLite connection string and logger.
/// </summary>
/// <param name="connectionString">SQLite connection string for the site database.</param>
/// <param name="logger">Logger instance for diagnostic messages.</param>
public SiteStorageService(string connectionString, ILogger<SiteStorageService> logger)
{
// Normalize the connection string and pin a busy-timeout floor (S8). WAL lets a reader
// and a writer proceed concurrently, but two writers still contend; Microsoft.Data.Sqlite
// drives its busy handler off the command timeout, so a floor of 5 s means a briefly
// busy database is waited-on rather than failing fast with SQLITE_BUSY. Only raise a
// caller-supplied value that is lower than the floor (the library default of 30 s stays).
var builder = new SqliteConnectionStringBuilder(connectionString);
if (builder.DefaultTimeout < BusyTimeoutFloorSeconds)
builder.DefaultTimeout = BusyTimeoutFloorSeconds;
_connectionString = builder.ToString();
_logger = logger;
}
/// <summary>Busy-timeout floor in seconds applied to the site connection string (S8).</summary>
private const int BusyTimeoutFloorSeconds = 5;
/// <summary>
/// Creates a new (unopened) SQLite connection against the site database.
/// Exposed so site-local repositories can open their own connections without
/// reaching into private state via reflection. The caller owns
/// the connection and is responsible for opening and disposing it.
/// <para>
/// The database runs in WAL journal mode (set once in <see cref="InitializeAsync"/>) with a
/// busy-timeout floor (see the constructor), so connections handed out here inherit
/// concurrent-reader/writer behavior and wait out a briefly-busy database instead of
/// failing with SQLITE_BUSY.
/// </para>
/// </summary>
/// <returns>A new, unopened <see cref="SqliteConnection"/> against the site database.</returns>
public SqliteConnection CreateConnection() => new(_connectionString);
/// <summary>
/// Creates the SQLite tables if they do not exist.
/// Called once on site startup.
/// </summary>
/// <returns>A task that completes when all tables have been created or verified.</returns>
public async Task InitializeAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
// Switch to WAL journal mode (S8). WAL is persistent (database-level, survives across
// connections) so this one-time set is enough. It lets readers and a writer proceed
// concurrently instead of every access serializing on the rollback journal. A
// :memory: database or a filesystem that cannot support WAL falls back to its prior
// mode — we log the result rather than throwing.
await using (var walCommand = connection.CreateCommand())
{
walCommand.CommandText = "PRAGMA journal_mode=WAL;";
var resultingMode = (await walCommand.ExecuteScalarAsync()) as string ?? "unknown";
if (!string.Equals(resultingMode, "wal", StringComparison.OrdinalIgnoreCase))
_logger.LogWarning(
"Site SQLite could not enable WAL journal mode (got '{Mode}') — concurrent access may serialize",
resultingMode);
}
// The DDL itself lives in SiteStorageSchema so the Host can apply it to a
// LocalDb-managed connection before RegisterReplicated installs the capture
// triggers. The service still calls it, so a directly-constructed service
// (tests, tooling) remains self-sufficient.
SiteStorageSchema.Apply(connection);
_logger.LogInformation("Site SQLite storage initialized at {ConnectionString}", _connectionString);
}
// ── Deployed Configuration CRUD ──
/// <summary>
/// Returns all deployed instance configurations from SQLite.
/// </summary>
/// <returns>A task that resolves to the list of all deployed instance configurations.</returns>
public async Task<List<DeployedInstance>> GetAllDeployedConfigsAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at
FROM deployed_configurations";
var results = new List<DeployedInstance>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new DeployedInstance
{
InstanceUniqueName = reader.GetString(0),
ConfigJson = reader.GetString(1),
DeploymentId = reader.GetString(2),
RevisionHash = reader.GetString(3),
IsEnabled = reader.GetInt64(4) != 0,
DeployedAt = reader.GetString(5)
});
}
return results;
}
/// <summary>
/// Stores or updates a deployed instance configuration. Uses UPSERT semantics.
/// </summary>
/// <param name="instanceName">The unique name of the instance.</param>
/// <param name="configJson">The deployed configuration as JSON.</param>
/// <param name="deploymentId">The unique deployment identifier.</param>
/// <param name="revisionHash">The configuration revision hash for staleness detection.</param>
/// <param name="isEnabled">Whether the instance is enabled.</param>
/// <returns>A task that completes when the configuration has been stored or updated.</returns>
public async Task StoreDeployedConfigAsync(
string instanceName,
string configJson,
string deploymentId,
string revisionHash,
bool isEnabled)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO deployed_configurations (instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
ON CONFLICT(instance_unique_name) DO UPDATE SET
config_json = excluded.config_json,
deployment_id = excluded.deployment_id,
revision_hash = excluded.revision_hash,
is_enabled = excluded.is_enabled,
deployed_at = excluded.deployed_at";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@json", configJson);
command.Parameters.AddWithValue("@depId", deploymentId);
command.Parameters.AddWithValue("@hash", revisionHash);
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
command.Parameters.AddWithValue("@deployedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
_logger.LogDebug("Stored deployed config for {Instance}, deploymentId={DeploymentId}", instanceName, deploymentId);
}
/// <summary>
/// Stores a deployed instance configuration only if the incoming write is strictly
/// newer than any already-stored row for the same instance. If no row exists the
/// write is always performed (insert path). Uses SQLite's conditional
/// <c>ON CONFLICT … WHERE excluded.deployed_at &gt; deployed_configurations.deployed_at</c>
/// clause so the guard is atomic with no application-level read-modify-write.
/// </summary>
/// <remarks>
/// This is the standby-node write path for replicated configs. The active-node
/// apply path (<see cref="StoreDeployedConfigAsync"/>) remains unguarded and always
/// overwrites, because the active node's write is always authoritative.
/// <para>
/// <paramref name="deployedAtOverride"/> is exposed for testing so that the exact
/// <c>deployed_at</c> value can be controlled without sleeping between calls.
/// Production callers omit it and receive the default <see cref="DateTimeOffset.UtcNow"/>
/// semantics, identical to <see cref="StoreDeployedConfigAsync"/>.
/// </para>
/// </remarks>
/// <param name="instanceName">The unique name of the instance.</param>
/// <param name="configJson">The deployed configuration as JSON.</param>
/// <param name="deploymentId">The unique deployment identifier.</param>
/// <param name="revisionHash">The configuration revision hash for staleness detection.</param>
/// <param name="isEnabled">Whether the instance is enabled.</param>
/// <param name="deployedAtOverride">
/// Optional explicit <c>deployed_at</c> timestamp. Defaults to
/// <see cref="DateTimeOffset.UtcNow"/> when <see langword="null"/>.
/// </param>
/// <returns>A task that completes when the conditional upsert has been executed.</returns>
public async Task StoreDeployedConfigIfNewerAsync(
string instanceName,
string configJson,
string deploymentId,
string revisionHash,
bool isEnabled,
DateTimeOffset? deployedAtOverride = null)
{
var deployedAt = (deployedAtOverride ?? DateTimeOffset.UtcNow).ToString("O");
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO deployed_configurations
(instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
ON CONFLICT(instance_unique_name) DO UPDATE SET
config_json = excluded.config_json,
deployment_id = excluded.deployment_id,
revision_hash = excluded.revision_hash,
is_enabled = excluded.is_enabled,
deployed_at = excluded.deployed_at
WHERE excluded.deployed_at > deployed_configurations.deployed_at";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@json", configJson);
command.Parameters.AddWithValue("@depId", deploymentId);
command.Parameters.AddWithValue("@hash", revisionHash);
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
command.Parameters.AddWithValue("@deployedAt", deployedAt);
await command.ExecuteNonQueryAsync();
_logger.LogDebug("StoreDeployedConfigIfNewer for {Instance}, deploymentId={DeploymentId}", instanceName, deploymentId);
}
/// <summary>
/// Removes a deployed instance configuration and its static overrides.
/// </summary>
/// <param name="instanceName">The unique name of the instance to remove.</param>
/// <returns>A task that completes when the configuration and its overrides have been removed.</returns>
public async Task RemoveDeployedConfigAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
await using (var cmd = connection.CreateCommand())
{
cmd.Transaction = (SqliteTransaction)transaction;
cmd.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
cmd.Parameters.AddWithValue("@name", instanceName);
await cmd.ExecuteNonQueryAsync();
}
await using (var cmd = connection.CreateCommand())
{
cmd.Transaction = (SqliteTransaction)transaction;
cmd.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
cmd.Parameters.AddWithValue("@name", instanceName);
await cmd.ExecuteNonQueryAsync();
}
await using (var cmd = connection.CreateCommand())
{
cmd.Transaction = (SqliteTransaction)transaction;
cmd.CommandText = "DELETE FROM deployed_configurations WHERE instance_unique_name = @name";
cmd.Parameters.AddWithValue("@name", instanceName);
await cmd.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
_logger.LogInformation("Removed deployed config and overrides for {Instance}", instanceName);
}
/// <summary>
/// Sets the enabled/disabled state of a deployed instance.
/// </summary>
/// <param name="instanceName">The unique name of the instance.</param>
/// <param name="isEnabled">Whether the instance should be enabled.</param>
/// <returns>A task that completes when the enabled flag has been updated.</returns>
public async Task SetInstanceEnabledAsync(string instanceName, bool isEnabled)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
UPDATE deployed_configurations
SET is_enabled = @enabled
WHERE instance_unique_name = @name";
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
command.Parameters.AddWithValue("@name", instanceName);
var rows = await command.ExecuteNonQueryAsync();
if (rows == 0)
{
_logger.LogWarning("SetInstanceEnabled: instance {Instance} not found", instanceName);
}
}
// ── Static Attribute Override CRUD ──
/// <summary>
/// Returns all static attribute overrides for an instance.
/// </summary>
/// <param name="instanceName">The unique name of the instance.</param>
/// <returns>A task that resolves to a dictionary mapping attribute names to their override values.</returns>
public async Task<Dictionary<string, string>> GetStaticOverridesAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT attribute_name, override_value
FROM static_attribute_overrides
WHERE instance_unique_name = @name";
command.Parameters.AddWithValue("@name", instanceName);
var results = new Dictionary<string, string>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results[reader.GetString(0)] = reader.GetString(1);
}
return results;
}
/// <summary>
/// Sets or updates a single static attribute override for an instance.
/// </summary>
/// <param name="instanceName">The unique name of the instance.</param>
/// <param name="attributeName">The name of the attribute to override.</param>
/// <param name="value">The override value for the attribute.</param>
/// <returns>A task that completes when the override has been saved.</returns>
public async Task SetStaticOverrideAsync(string instanceName, string attributeName, string value)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO static_attribute_overrides (instance_unique_name, attribute_name, override_value, updated_at)
VALUES (@name, @attr, @val, @updatedAt)
ON CONFLICT(instance_unique_name, attribute_name) DO UPDATE SET
override_value = excluded.override_value,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@attr", attributeName);
command.Parameters.AddWithValue("@val", value);
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Clears all static attribute overrides for an instance.
/// Called on redeployment to reset overrides.
/// </summary>
/// <param name="instanceName">The unique name of the instance.</param>
/// <returns>A task that completes when all overrides for the instance have been deleted.</returns>
public async Task ClearStaticOverridesAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
command.Parameters.AddWithValue("@name", instanceName);
await command.ExecuteNonQueryAsync();
_logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
}
// ── Native Alarm State store (read-only mirror of source A&C conditions) ──
/// <summary>
/// Inserts or updates a single mirrored native alarm condition, keyed by
/// (instance, source canonical name, source reference). Newer transitions overwrite older ones.
/// </summary>
/// <param name="instanceName">Unique name of the instance owning the alarm.</param>
/// <param name="sourceCanonicalName">Canonical name of the source binding (connection/alarm source).</param>
/// <param name="sourceReference">Source-system reference key identifying the specific alarm condition.</param>
/// <param name="conditionJson">Serialized <see cref="AlarmConditionState"/> JSON snapshot.</param>
/// <param name="lastTransitionAt">Timestamp of the most recent condition transition.</param>
/// <param name="metadataJson">Optional serialized metadata JSON for the alarm condition; <c>null</c> when not supplied.</param>
/// <returns>A task that completes when the alarm condition has been inserted or updated.</returns>
public async Task UpsertNativeAlarmAsync(
string instanceName, string sourceCanonicalName, string sourceReference,
string conditionJson, DateTimeOffset lastTransitionAt, string? metadataJson = null)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO native_alarm_state
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
VALUES (@name, @source, @ref, @json, @at, @meta)
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
condition_json = excluded.condition_json,
last_transition_at = excluded.last_transition_at,
metadata_json = excluded.metadata_json";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@source", sourceCanonicalName);
command.Parameters.AddWithValue("@ref", sourceReference);
command.Parameters.AddWithValue("@json", conditionJson);
command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O"));
command.Parameters.AddWithValue("@meta", (object?)metadataJson ?? DBNull.Value);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Batch counterpart of <see cref="UpsertNativeAlarmAsync"/> (P4): upserts a set of mirrored
/// native alarm conditions for one instance/source in a SINGLE connection + transaction with
/// one prepared command re-bound per row. Used by the NativeAlarmActor's coalesced flush so an
/// alarm storm collapses into one batched write instead of a write per transition.
/// </summary>
/// <param name="instanceName">Unique name of the instance owning the alarms.</param>
/// <param name="sourceCanonicalName">Canonical name of the source binding.</param>
/// <param name="rows">The conditions to upsert (source reference, serialized condition, transition time).</param>
/// <returns>A task that completes when all rows have been committed.</returns>
public async Task UpsertNativeAlarmsAsync(
string instanceName, string sourceCanonicalName,
IReadOnlyList<(string SourceReference, string ConditionJson, string? MetadataJson, DateTimeOffset LastTransitionAt)> rows)
{
if (rows.Count == 0)
return;
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = @"
INSERT INTO native_alarm_state
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
VALUES (@name, @source, @ref, @json, @at, @meta)
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
condition_json = excluded.condition_json,
last_transition_at = excluded.last_transition_at,
metadata_json = excluded.metadata_json";
var pName = command.Parameters.Add("@name", SqliteType.Text);
var pSource = command.Parameters.Add("@source", SqliteType.Text);
var pRef = command.Parameters.Add("@ref", SqliteType.Text);
var pJson = command.Parameters.Add("@json", SqliteType.Text);
var pAt = command.Parameters.Add("@at", SqliteType.Text);
var pMeta = command.Parameters.Add("@meta", SqliteType.Text);
pName.Value = instanceName;
pSource.Value = sourceCanonicalName;
foreach (var row in rows)
{
pRef.Value = row.SourceReference;
pJson.Value = row.ConditionJson;
pAt.Value = row.LastTransitionAt.ToString("O");
pMeta.Value = (object?)row.MetadataJson ?? DBNull.Value;
await command.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
}
/// <summary>
/// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention).
/// </summary>
/// <param name="instanceName">Unique name of the instance owning the alarm.</param>
/// <param name="sourceCanonicalName">Canonical name of the source binding.</param>
/// <param name="sourceReference">Source-system reference key identifying the alarm condition to remove.</param>
/// <returns>A task that completes when the alarm condition row has been deleted.</returns>
public async Task DeleteNativeAlarmAsync(string instanceName, string sourceCanonicalName, string sourceReference)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
DELETE FROM native_alarm_state
WHERE instance_unique_name = @name
AND source_canonical_name = @source
AND source_reference = @ref";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@source", sourceCanonicalName);
command.Parameters.AddWithValue("@ref", sourceReference);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Returns all mirrored native alarm conditions for an instance's source binding,
/// used to rehydrate a NativeAlarmActor on (re)start.
/// </summary>
/// <param name="instanceName">Unique name of the instance to query.</param>
/// <param name="sourceCanonicalName">Canonical name of the source binding to query.</param>
/// <returns>A task that resolves to the list of stored native alarm condition rows for the binding.</returns>
public async Task<IReadOnlyList<NativeAlarmRow>> GetNativeAlarmsAsync(string instanceName, string sourceCanonicalName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT source_reference, condition_json, last_transition_at, metadata_json
FROM native_alarm_state
WHERE instance_unique_name = @name AND source_canonical_name = @source";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@source", sourceCanonicalName);
var results = new List<NativeAlarmRow>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new NativeAlarmRow(
reader.GetString(0),
reader.GetString(1),
DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind),
reader.IsDBNull(3) ? null : reader.GetString(3)));
}
return results;
}
/// <summary>
/// Clears all mirrored native alarm conditions for an instance. Called on redeployment / stop.
/// </summary>
/// <param name="instanceName">Unique name of the instance whose native alarm state should be cleared.</param>
/// <returns>A task that completes when all native alarm rows for the instance have been deleted.</returns>
public async Task ClearNativeAlarmsForInstanceAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
command.Parameters.AddWithValue("@name", instanceName);
await command.ExecuteNonQueryAsync();
_logger.LogDebug("Cleared native alarm state for {Instance}", instanceName);
}
// ── Shared Script CRUD ──
/// <summary>
/// Stores or updates a shared script. Uses UPSERT semantics.
/// </summary>
/// <param name="name">The name of the shared script.</param>
/// <param name="code">The script code.</param>
/// <param name="parameterDefs">JSON representation of parameter definitions, if any.</param>
/// <param name="returnDef">JSON representation of the return type definition, if any.</param>
/// <returns>A task that completes when the shared script has been stored or updated.</returns>
public async Task StoreSharedScriptAsync(string name, string code, string? parameterDefs, string? returnDef)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO shared_scripts (name, code, parameter_definitions, return_definition, updated_at)
VALUES (@name, @code, @paramDefs, @returnDef, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
code = excluded.code,
parameter_definitions = excluded.parameter_definitions,
return_definition = excluded.return_definition,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@code", code);
command.Parameters.AddWithValue("@paramDefs", (object?)parameterDefs ?? DBNull.Value);
command.Parameters.AddWithValue("@returnDef", (object?)returnDef ?? DBNull.Value);
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
_logger.LogDebug("Stored shared script '{Name}'", name);
}
/// <summary>
/// Returns all stored shared scripts.
/// </summary>
/// <returns>A task that resolves to the list of all stored shared scripts.</returns>
public async Task<List<StoredSharedScript>> GetAllSharedScriptsAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT name, code, parameter_definitions, return_definition FROM shared_scripts";
var results = new List<StoredSharedScript>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new StoredSharedScript
{
Name = reader.GetString(0),
Code = reader.GetString(1),
ParameterDefinitions = reader.IsDBNull(2) ? null : reader.GetString(2),
ReturnDefinition = reader.IsDBNull(3) ? null : reader.GetString(3)
});
}
return results;
}
// ── External System CRUD ──
/// <summary>
/// Stores or updates an external system definition.
/// </summary>
/// <param name="name">The name of the external system.</param>
/// <param name="endpointUrl">The REST API endpoint URL.</param>
/// <param name="authType">The authentication type (e.g., 'ApiKey', 'BasicAuth').</param>
/// <param name="authConfig">Authentication configuration JSON, if applicable.</param>
/// <param name="methodDefs">JSON representation of available method definitions, if any.</param>
/// <param name="timeoutSeconds">Per-system call timeout in seconds (0 = use the site default).</param>
/// <returns>A task that completes when the external system definition has been stored or updated.</returns>
public async Task StoreExternalSystemAsync(
string name, string endpointUrl, string authType, string? authConfig, string? methodDefs,
int timeoutSeconds = 0)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO external_systems (name, endpoint_url, auth_type, auth_configuration, method_definitions, timeout_seconds, updated_at)
VALUES (@name, @url, @authType, @authConfig, @methodDefs, @timeoutSeconds, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
endpoint_url = excluded.endpoint_url,
auth_type = excluded.auth_type,
auth_configuration = excluded.auth_configuration,
method_definitions = excluded.method_definitions,
timeout_seconds = excluded.timeout_seconds,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@url", endpointUrl);
command.Parameters.AddWithValue("@authType", authType);
command.Parameters.AddWithValue("@authConfig", (object?)authConfig ?? DBNull.Value);
command.Parameters.AddWithValue("@methodDefs", (object?)methodDefs ?? DBNull.Value);
command.Parameters.AddWithValue("@timeoutSeconds", timeoutSeconds);
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
// ── Database Connection CRUD ──
/// <summary>
/// Stores or updates a database connection definition.
/// </summary>
/// <param name="name">The name of the database connection.</param>
/// <param name="connectionString">The database connection string.</param>
/// <param name="maxRetries">Maximum number of retry attempts.</param>
/// <param name="retryDelay">Delay between retry attempts.</param>
/// <returns>A task that completes when the database connection definition has been stored or updated.</returns>
public async Task StoreDatabaseConnectionAsync(
string name, string connectionString, int maxRetries, TimeSpan retryDelay)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO database_connections (name, connection_string, max_retries, retry_delay_ms, updated_at)
VALUES (@name, @connStr, @maxRetries, @retryDelayMs, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
connection_string = excluded.connection_string,
max_retries = excluded.max_retries,
retry_delay_ms = excluded.retry_delay_ms,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@connStr", connectionString);
command.Parameters.AddWithValue("@maxRetries", maxRetries);
command.Parameters.AddWithValue("@retryDelayMs", (long)retryDelay.TotalMilliseconds);
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
// ── Notification List CRUD ──
/// <summary>
/// Notification delivery is central-only.
/// Sites store-and-forward notifications to the central cluster and never deliver
/// over SMTP, so notification lists and SMTP configuration must never live on a
/// site. This purges every row from the site-local <c>notification_lists</c> and
/// <c>smtp_configurations</c> tables, clearing any rows a prior (now-corrected)
/// build may have shipped — most importantly the plaintext SMTP password. It is
/// idempotent and is invoked on every artifact apply / deploy so existing exposure
/// is cleared, not just future writes. The tables themselves are retained (the
/// schema is harmless once empty); only their contents are removed.
///
/// The site-side write paths (<c>StoreNotificationListAsync</c>/<c>StoreSmtpConfigurationAsync</c>)
/// and the <c>SiteNotificationRepository</c> were removed 2026-07-10 (arch-review 08 §1.3)
/// because notification config is central-only and must never be written to a site — this
/// purge is the sole remaining touchpoint, kept only to scrub DBs written by older builds;
/// do not reintroduce site-side notification writes.
/// </summary>
/// <returns>A task that completes when both tables have been emptied.</returns>
public async Task PurgeCentralOnlyNotificationConfigAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
DELETE FROM notification_lists;
DELETE FROM smtp_configurations;";
await command.ExecuteNonQueryAsync();
}
// ── Data Connection Definition CRUD ──
/// <summary>
/// Stores or updates a data connection definition (OPC UA endpoint, etc.).
/// </summary>
/// <param name="name">The name of the data connection.</param>
/// <param name="protocol">The protocol type (e.g., 'OpcUa').</param>
/// <param name="configJson">Primary configuration as JSON.</param>
/// <param name="backupConfigJson">Backup configuration as JSON, if applicable.</param>
/// <param name="failoverRetryCount">Number of retries for failover attempts.</param>
/// <returns>A task that completes when the data connection definition has been stored or updated.</returns>
public async Task StoreDataConnectionDefinitionAsync(
string name, string protocol, string? configJson, string? backupConfigJson = null, int failoverRetryCount = 3)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO data_connection_definitions (name, protocol, configuration, backup_configuration, failover_retry_count, updated_at)
VALUES (@name, @protocol, @config, @backupConfig, @failoverRetryCount, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
protocol = excluded.protocol,
configuration = excluded.configuration,
backup_configuration = excluded.backup_configuration,
failover_retry_count = excluded.failover_retry_count,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@protocol", protocol);
command.Parameters.AddWithValue("@config", (object?)configJson ?? DBNull.Value);
command.Parameters.AddWithValue("@backupConfig", (object?)backupConfigJson ?? DBNull.Value);
command.Parameters.AddWithValue("@failoverRetryCount", failoverRetryCount);
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
_logger.LogDebug("Stored data connection definition '{Name}' (protocol={Protocol})", name, protocol);
}
/// <summary>
/// Returns all stored data connection definitions.
/// </summary>
/// <returns>A task that resolves to the list of all stored data connection definitions.</returns>
public async Task<List<StoredDataConnectionDefinition>> GetAllDataConnectionDefinitionsAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT name, protocol, configuration, backup_configuration, failover_retry_count FROM data_connection_definitions";
var results = new List<StoredDataConnectionDefinition>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new StoredDataConnectionDefinition
{
Name = reader.GetString(0),
Protocol = reader.GetString(1),
ConfigurationJson = reader.IsDBNull(2) ? null : reader.GetString(2),
BackupConfigurationJson = reader.IsDBNull(3) ? null : reader.GetString(3),
FailoverRetryCount = reader.GetInt32(4)
});
}
return results;
}
}
/// <summary>
/// Represents a deployed instance configuration as stored in SQLite.
/// </summary>
public class DeployedInstance
{
/// <summary>
/// The unique name of the instance.
/// </summary>
public string InstanceUniqueName { get; init; } = string.Empty;
/// <summary>
/// The deployed configuration as JSON.
/// </summary>
public string ConfigJson { get; init; } = string.Empty;
/// <summary>
/// The unique deployment identifier.
/// </summary>
public string DeploymentId { get; init; } = string.Empty;
/// <summary>
/// The configuration revision hash for staleness detection.
/// </summary>
public string RevisionHash { get; init; } = string.Empty;
/// <summary>
/// Whether the instance is enabled.
/// </summary>
public bool IsEnabled { get; init; }
/// <summary>
/// The timestamp when the configuration was deployed.
/// </summary>
public string DeployedAt { get; init; } = string.Empty;
}
/// <summary>
/// Represents a shared script stored locally in SQLite.
/// </summary>
public class StoredSharedScript
{
/// <summary>
/// The name of the shared script.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// The script code.
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// JSON representation of parameter definitions, if any.
/// </summary>
public string? ParameterDefinitions { get; init; }
/// <summary>
/// JSON representation of the return type definition, if any.
/// </summary>
public string? ReturnDefinition { get; init; }
}
/// <summary>
/// Represents a data connection definition stored locally in SQLite.
/// </summary>
public class StoredDataConnectionDefinition
{
/// <summary>
/// The name of the data connection.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// The protocol type (e.g., 'OpcUa').
/// </summary>
public string Protocol { get; init; } = string.Empty;
/// <summary>
/// Primary configuration as JSON.
/// </summary>
public string? ConfigurationJson { get; init; }
/// <summary>
/// Backup configuration as JSON, if applicable.
/// </summary>
public string? BackupConfigurationJson { get; init; }
/// <summary>
/// Number of retries for failover attempts.
/// </summary>
public int FailoverRetryCount { get; init; } = 3;
}
/// <summary>
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table.
/// <paramref name="MetadataJson"/> carries the serialized display metadata (type/category/message/
/// values) restored on rehydration (UA4); null on rows written before the column existed.
/// </summary>
public record NativeAlarmRow(
string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null);