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) { // 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; } /// Busy-timeout floor in seconds applied to the site connection string (S8). private const int BusyTimeoutFloorSeconds = 5; /// /// 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. /// /// The database runs in WAL journal mode (set once in ) 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. /// /// /// 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(); // 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); } 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, metadata_json TEXT, 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"); // Native-alarm display metadata (UA4) — restored on rehydration so a persisted condition // renders fully (type/category/message/values) before the first source snapshot arrives. await TryAddColumnAsync(connection, "native_alarm_state", "metadata_json", "TEXT"); // Per-external-system call timeout (ExternalSystemGateway Timeout) — carried through the // artifact pipeline so site-side calls honor it; 0 = use the site default. await TryAddColumnAsync(connection, "external_systems", "timeout_seconds", "INTEGER NOT NULL DEFAULT 0"); } 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); } // ── 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. /// Optional serialized metadata JSON for the alarm condition; null when not supplied. /// 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, 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(); } /// /// Batch counterpart of (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. /// /// Unique name of the instance owning the alarms. /// Canonical name of the source binding. /// The conditions to upsert (source reference, serialized condition, transition time). /// A task that completes when all rows have been committed. 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(); } /// /// 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, 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(); 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; } /// /// 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); } // ── 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; } // ── 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. /// Per-system call timeout in seconds (0 = use the site default). /// 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, 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 ── /// /// 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(); } // ── Notification List CRUD ── /// /// 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. /// /// The site-side write paths (StoreNotificationListAsync/StoreSmtpConfigurationAsync) /// and the SiteNotificationRepository 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. /// /// 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(); } // ── 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. /// 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. /// carries the serialized display metadata (type/category/message/ /// values) restored on rehydration (UA4); null on rows written before the column existed. /// public record NativeAlarmRow( string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null);