using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
///
/// 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.
///
public class SiteStorageService
{
private readonly string _connectionString;
private readonly ILogger _logger;
///
/// Initializes a new instance of the SiteStorageService with the specified SQLite connection string and logger.
///
/// SQLite connection string for the site database.
/// Logger instance for diagnostic messages.
public SiteStorageService(string connectionString, ILogger logger)
{
_connectionString = connectionString;
_logger = logger;
}
///
/// 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 (SiteRuntime-006). The caller owns
/// the connection and is responsible for opening and disposing it.
///
/// A new, unopened against the site database.
public SqliteConnection CreateConnection() => new(_connectionString);
///
/// Creates the SQLite tables if they do not exist.
/// Called once on site startup.
///
/// A task that completes when all tables have been created or verified.
public async Task InitializeAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
CREATE TABLE IF NOT EXISTS deployed_configurations (
instance_unique_name TEXT PRIMARY KEY,
config_json TEXT NOT NULL,
deployment_id TEXT NOT NULL,
revision_hash TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
deployed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS static_attribute_overrides (
instance_unique_name TEXT NOT NULL,
attribute_name TEXT NOT NULL,
override_value TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_unique_name, attribute_name)
);
CREATE TABLE IF NOT EXISTS shared_scripts (
name TEXT PRIMARY KEY,
code TEXT NOT NULL,
parameter_definitions TEXT,
return_definition TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS external_systems (
name TEXT PRIMARY KEY,
endpoint_url TEXT NOT NULL,
auth_type TEXT NOT NULL,
auth_configuration TEXT,
method_definitions TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS database_connections (
name TEXT PRIMARY KEY,
connection_string TEXT NOT NULL,
max_retries INTEGER NOT NULL DEFAULT 3,
retry_delay_ms INTEGER NOT NULL DEFAULT 1000,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS notification_lists (
name TEXT PRIMARY KEY,
recipient_emails TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS data_connection_definitions (
name TEXT PRIMARY KEY,
protocol TEXT NOT NULL,
configuration TEXT,
backup_configuration TEXT,
failover_retry_count INTEGER NOT NULL DEFAULT 3,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS smtp_configurations (
name TEXT PRIMARY KEY,
server TEXT NOT NULL,
port INTEGER NOT NULL,
auth_mode TEXT NOT NULL,
from_address TEXT NOT NULL,
username TEXT,
password TEXT,
oauth_config TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS native_alarm_state (
instance_unique_name TEXT NOT NULL,
source_canonical_name TEXT NOT NULL,
source_reference TEXT NOT NULL,
condition_json TEXT NOT NULL,
last_transition_at TEXT NOT NULL,
PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference)
);
";
await command.ExecuteNonQueryAsync();
// Schema migrations — add columns that may not exist on older databases
await MigrateSchemaAsync(connection);
_logger.LogInformation("Site SQLite storage initialized at {ConnectionString}", _connectionString);
}
private async Task MigrateSchemaAsync(SqliteConnection connection)
{
// Add backup_configuration and failover_retry_count to data_connection_definitions
// (added in primary/backup data connections feature)
await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT");
await TryAddColumnAsync(connection, "data_connection_definitions", "failover_retry_count", "INTEGER NOT NULL DEFAULT 3");
}
private async Task TryAddColumnAsync(SqliteConnection connection, string table, string column, string type)
{
try
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
await cmd.ExecuteNonQueryAsync();
_logger.LogInformation("Migrated: added column {Column} to {Table}", column, table);
}
catch (SqliteException ex) when (ex.Message.Contains("duplicate column"))
{
// Column already exists — no action needed
}
}
// ── Deployed Configuration CRUD ──
///
/// Returns all deployed instance configurations from SQLite.
///
/// A task that resolves to the list of all deployed instance configurations.
public async Task> 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();
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;
}
///
/// Stores or updates a deployed instance configuration. Uses UPSERT semantics.
///
/// The unique name of the instance.
/// The deployed configuration as JSON.
/// The unique deployment identifier.
/// The configuration revision hash for staleness detection.
/// Whether the instance is enabled.
/// A task that completes when the configuration has been stored or updated.
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);
}
///
/// 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
/// ON CONFLICT … WHERE excluded.deployed_at > deployed_configurations.deployed_at
/// clause so the guard is atomic with no application-level read-modify-write.
///
///
/// This is the standby-node write path for replicated configs. The active-node
/// apply path () remains unguarded and always
/// overwrites, because the active node's write is always authoritative.
///
/// is exposed for testing so that the exact
/// deployed_at value can be controlled without sleeping between calls.
/// Production callers omit it and receive the default
/// semantics, identical to .
///
///
/// The unique name of the instance.
/// The deployed configuration as JSON.
/// The unique deployment identifier.
/// The configuration revision hash for staleness detection.
/// Whether the instance is enabled.
///
/// Optional explicit deployed_at timestamp. Defaults to
/// when .
///
/// A task that completes when the conditional upsert has been executed.
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);
}
///
/// Removes a deployed instance configuration and its static overrides.
///
/// The unique name of the instance to remove.
/// A task that completes when the configuration and its overrides have been removed.
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);
}
///
/// Sets the enabled/disabled state of a deployed instance.
///
/// The unique name of the instance.
/// Whether the instance should be enabled.
/// A task that completes when the enabled flag has been updated.
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 ──
///
/// Returns all static attribute overrides for an instance.
///
/// The unique name of the instance.
/// A task that resolves to a dictionary mapping attribute names to their override values.
public async Task> 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();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results[reader.GetString(0)] = reader.GetString(1);
}
return results;
}
///
/// Sets or updates a single static attribute override for an instance.
///
/// The unique name of the instance.
/// The name of the attribute to override.
/// The override value for the attribute.
/// A task that completes when the override has been saved.
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();
}
///
/// Clears all static attribute overrides for an instance.
/// Called on redeployment to reset overrides.
///
/// The unique name of the instance.
/// A task that completes when all overrides for the instance have been deleted.
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);
}
// ── Task 14: Native Alarm State store (read-only mirror of source A&C conditions) ──
///
/// Inserts or updates a single mirrored native alarm condition, keyed by
/// (instance, source canonical name, source reference). Newer transitions overwrite older ones.
///
/// Unique name of the instance owning the alarm.
/// Canonical name of the source binding (connection/alarm source).
/// Source-system reference key identifying the specific alarm condition.
/// Serialized JSON snapshot.
/// Timestamp of the most recent condition transition.
/// A task that completes when the alarm condition has been inserted or updated.
public async Task UpsertNativeAlarmAsync(
string instanceName, string sourceCanonicalName, string sourceReference,
string conditionJson, DateTimeOffset lastTransitionAt)
{
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)
VALUES (@name, @source, @ref, @json, @at)
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";
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"));
await command.ExecuteNonQueryAsync();
}
///
/// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention).
///
/// Unique name of the instance owning the alarm.
/// Canonical name of the source binding.
/// Source-system reference key identifying the alarm condition to remove.
/// A task that completes when the alarm condition row has been deleted.
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();
}
///
/// Returns all mirrored native alarm conditions for an instance's source binding,
/// used to rehydrate a NativeAlarmActor on (re)start.
///
/// Unique name of the instance to query.
/// Canonical name of the source binding to query.
/// A task that resolves to the list of stored native alarm condition rows for the binding.
public async Task> 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
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();
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)));
}
return results;
}
///
/// Clears all mirrored native alarm conditions for an instance. Called on redeployment / stop.
///
/// Unique name of the instance whose native alarm state should be cleared.
/// A task that completes when all native alarm rows for the instance have been deleted.
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);
}
// ── WP-33: Shared Script CRUD ──
///
/// Stores or updates a shared script. Uses UPSERT semantics.
///
/// The name of the shared script.
/// The script code.
/// JSON representation of parameter definitions, if any.
/// JSON representation of the return type definition, if any.
/// A task that completes when the shared script has been stored or updated.
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);
}
///
/// Returns all stored shared scripts.
///
/// A task that resolves to the list of all stored shared scripts.
public async Task> 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();
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;
}
// ── WP-33: External System CRUD ──
///
/// Stores or updates an external system definition.
///
/// The name of the external system.
/// The REST API endpoint URL.
/// The authentication type (e.g., 'ApiKey', 'BasicAuth').
/// Authentication configuration JSON, if applicable.
/// JSON representation of available method definitions, if any.
/// A task that completes when the external system definition has been stored or updated.
public async Task StoreExternalSystemAsync(
string name, string endpointUrl, string authType, string? authConfig, string? methodDefs)
{
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, updated_at)
VALUES (@name, @url, @authType, @authConfig, @methodDefs, @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,
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("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
// ── WP-33: Database Connection CRUD ──
///
/// Stores or updates a database connection definition.
///
/// The name of the database connection.
/// The database connection string.
/// Maximum number of retry attempts.
/// Delay between retry attempts.
/// A task that completes when the database connection definition has been stored or updated.
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();
}
// ── WP-33: Notification List CRUD ──
///
/// DeploymentManager-025 / SiteRuntime-031: 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 notification_lists and
/// smtp_configurations 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.
///
/// A task that completes when both tables have been emptied.
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();
}
///
/// Stores or updates a notification list.
///
/// The name of the notification list.
/// List of recipient email addresses.
/// A task that completes when the notification list has been stored or updated.
public async Task StoreNotificationListAsync(string name, IReadOnlyList recipientEmails)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO notification_lists (name, recipient_emails, updated_at)
VALUES (@name, @emails, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
recipient_emails = excluded.recipient_emails,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@emails", System.Text.Json.JsonSerializer.Serialize(recipientEmails));
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
// ── WP-33: SMTP Configuration CRUD ──
///
/// Stores or updates an SMTP configuration.
///
/// The name of the SMTP configuration.
/// SMTP server hostname.
/// SMTP server port.
/// Authentication mode ('None', 'BasicAuth', 'OAuth2').
/// Email address used as the sender.
/// Username for authentication, if applicable.
/// Password for authentication, if applicable.
/// OAuth2 configuration JSON, if applicable.
/// A task that completes when the SMTP configuration has been stored or updated.
public async Task StoreSmtpConfigurationAsync(
string name, string server, int port, string authMode, string fromAddress,
string? username, string? password, string? oauthConfig)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at)
VALUES (@name, @server, @port, @authMode, @fromAddress, @username, @password, @oauthConfig, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
server = excluded.server,
port = excluded.port,
auth_mode = excluded.auth_mode,
from_address = excluded.from_address,
username = excluded.username,
password = excluded.password,
oauth_config = excluded.oauth_config,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@server", server);
command.Parameters.AddWithValue("@port", port);
command.Parameters.AddWithValue("@authMode", authMode);
command.Parameters.AddWithValue("@fromAddress", fromAddress);
command.Parameters.AddWithValue("@username", (object?)username ?? DBNull.Value);
command.Parameters.AddWithValue("@password", (object?)password ?? DBNull.Value);
command.Parameters.AddWithValue("@oauthConfig", (object?)oauthConfig ?? DBNull.Value);
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
// ── Data Connection Definition CRUD ──
///
/// Stores or updates a data connection definition (OPC UA endpoint, etc.).
///
/// The name of the data connection.
/// The protocol type (e.g., 'OpcUa').
/// Primary configuration as JSON.
/// Backup configuration as JSON, if applicable.
/// Number of retries for failover attempts.
/// A task that completes when the data connection definition has been stored or updated.
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);
}
///
/// Returns all stored data connection definitions.
///
/// A task that resolves to the list of all stored data connection definitions.
public async Task> 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();
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;
}
}
///
/// Represents a deployed instance configuration as stored in SQLite.
///
public class DeployedInstance
{
///
/// The unique name of the instance.
///
public string InstanceUniqueName { get; init; } = string.Empty;
///
/// The deployed configuration as JSON.
///
public string ConfigJson { get; init; } = string.Empty;
///
/// The unique deployment identifier.
///
public string DeploymentId { get; init; } = string.Empty;
///
/// The configuration revision hash for staleness detection.
///
public string RevisionHash { get; init; } = string.Empty;
///
/// Whether the instance is enabled.
///
public bool IsEnabled { get; init; }
///
/// The timestamp when the configuration was deployed.
///
public string DeployedAt { get; init; } = string.Empty;
}
///
/// Represents a shared script stored locally in SQLite (WP-33).
///
public class StoredSharedScript
{
///
/// The name of the shared script.
///
public string Name { get; init; } = string.Empty;
///
/// The script code.
///
public string Code { get; init; } = string.Empty;
///
/// JSON representation of parameter definitions, if any.
///
public string? ParameterDefinitions { get; init; }
///
/// JSON representation of the return type definition, if any.
///
public string? ReturnDefinition { get; init; }
}
///
/// Represents a data connection definition stored locally in SQLite.
///
public class StoredDataConnectionDefinition
{
///
/// The name of the data connection.
///
public string Name { get; init; } = string.Empty;
///
/// The protocol type (e.g., 'OpcUa').
///
public string Protocol { get; init; } = string.Empty;
///
/// Primary configuration as JSON.
///
public string? ConfigurationJson { get; init; }
///
/// Backup configuration as JSON, if applicable.
///
public string? BackupConfigurationJson { get; init; }
///
/// Number of retries for failover attempts.
///
public int FailoverRetryCount { get; init; } = 3;
}
///
/// A single mirrored native alarm condition row from the site-local native_alarm_state table (Task 14).
///
public record NativeAlarmRow(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt);