using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; namespace ScadaLink.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; public SiteStorageService(string connectionString, ILogger logger) { _connectionString = connectionString; _logger = logger; } /// /// Creates the SQLite tables if they do not exist. /// Called once on site startup. /// 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 ); "; 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. /// 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. /// 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); } /// /// Removes a deployed instance configuration and its static overrides. /// 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 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. /// 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. /// 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. /// 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. /// 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); } // ── WP-33: Shared Script CRUD ── /// /// Stores or updates a shared script. Uses UPSERT semantics. /// 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. /// 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. /// 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. /// 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 ── /// /// Stores or updates a notification list. /// 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. /// 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.). /// 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. /// 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 { public string InstanceUniqueName { get; init; } = string.Empty; public string ConfigJson { get; init; } = string.Empty; public string DeploymentId { get; init; } = string.Empty; public string RevisionHash { get; init; } = string.Empty; public bool IsEnabled { get; init; } public string DeployedAt { get; init; } = string.Empty; } /// /// Represents a shared script stored locally in SQLite (WP-33). /// public class StoredSharedScript { public string Name { get; init; } = string.Empty; public string Code { get; init; } = string.Empty; public string? ParameterDefinitions { get; init; } public string? ReturnDefinition { get; init; } } /// /// Represents a data connection definition stored locally in SQLite. /// public class StoredDataConnectionDefinition { public string Name { get; init; } = string.Empty; public string Protocol { get; init; } = string.Empty; public string? ConfigurationJson { get; init; } public string? BackupConfigurationJson { get; init; } public int FailoverRetryCount { get; init; } = 3; }