diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs new file mode 100644 index 00000000..b54711b8 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs @@ -0,0 +1,169 @@ +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; + +/// +/// DDL for the site's nine configuration tables, extracted from +/// so it can be applied by whoever owns the database +/// file. +/// +/// Deliberately depends only on Microsoft.Data.Sqlite, not on the LocalDb library. +/// The Host applies this DDL to a LocalDb-managed connection before +/// RegisterReplicated installs the capture triggers; nothing about the schema +/// itself is LocalDb-specific, and the service still calls it so a directly-constructed +/// service (tests, tooling) remains self-sufficient. +/// +/// +/// Journal-mode and other connection pragmas are deliberately NOT set here — LocalDb owns +/// the connection's pragmas, and keeps its own WAL set-up +/// for the databases it opens itself. +/// +/// +public static class SiteStorageSchema +{ + /// + /// Creates the nine site configuration tables when absent, and additively upgrades + /// tables created by an older build. Idempotent — safe to run on every startup. + /// + /// An open connection to the database that should hold the tables. + public static void Apply(SqliteConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + 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) + ); + "; + command.ExecuteNonQuery(); + } + + // Schema migrations — add columns that may not exist on older databases + MigrateSchema(connection); + } + + private static void MigrateSchema(SqliteConnection connection) + { + // Add backup_configuration and failover_retry_count to data_connection_definitions + // (added in primary/backup data connections feature) + AddColumnIfMissing(connection, "data_connection_definitions", "backup_configuration", "TEXT"); + AddColumnIfMissing(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. + AddColumnIfMissing(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. + AddColumnIfMissing(connection, "external_systems", "timeout_seconds", "INTEGER NOT NULL DEFAULT 0"); + } + + /// + /// Additively adds a column only when it is not already present. SQLite lacks + /// ADD COLUMN IF NOT EXISTS, so the schema is probed via + /// PRAGMA table_info first. Mirrors OperationTrackingSchema. + /// + /// This replaces the previous try/catch on SqliteException message text + /// containing "duplicate column": probing is the same precedent the other schema + /// classes use, does not depend on an error string that is not part of SQLite's + /// contract, and does not swallow unrelated failures of the same exception type. + /// + /// + private static void AddColumnIfMissing( + SqliteConnection connection, string table, string columnName, string columnDefinition) + { + using (var probe = connection.CreateCommand()) + { + // Table + column names are caller-controlled constants, never user input — + // safe to interpolate (parameters are not permitted in DDL or in a + // pragma-function argument). + probe.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = $name"; + probe.Parameters.AddWithValue("$name", columnName); + if (Convert.ToInt32(probe.ExecuteScalar()) > 0) + { + return; + } + } + + using var alter = connection.CreateCommand(); + alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {columnName} {columnDefinition}"; + alter.ExecuteNonQuery(); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs index e02515b2..4ccb0c0b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs @@ -75,126 +75,15 @@ public class SiteStorageService 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); + // 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); } - 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 ── /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs new file mode 100644 index 00000000..83b49ddf --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/SiteStorageSchemaTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; + +/// +/// Pins as the single owner of the site config DDL. +/// The Host applies this to a LocalDb-managed connection before +/// RegisterReplicated installs the capture triggers, so the shape it produces +/// is the shape that replicates. +/// +public class SiteStorageSchemaTests +{ + [Fact] + public void Apply_IsIdempotent_AndCreatesEveryTable() + { + var path = Path.Combine(Path.GetTempPath(), $"site-schema-{Guid.NewGuid():N}.db"); + try + { + using var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + + SiteStorageSchema.Apply(connection); + SiteStorageSchema.Apply(connection); // second run must not throw + + // Every table is named explicitly on purpose — a loop asserting "9 tables" + // would still pass if one were renamed. + foreach (var table in new[] + { + "deployed_configurations", "static_attribute_overrides", "shared_scripts", + "external_systems", "database_connections", "notification_lists", + "data_connection_definitions", "smtp_configurations", "native_alarm_state", + }) + { + Assert.True(TableExists(connection, table), $"missing table: {table}"); + } + } + finally + { + SqliteConnection.ClearAllPools(); + File.Delete(path); + } + } + + /// + /// The migration columns exist to upgrade a database created by an older build in + /// place. Asserting only against a freshly-created table would pass even if every + /// ALTER were deleted, because CREATE TABLE already lists them — so + /// this starts from the pre-migration shape and proves the migration path runs. + /// + [Fact] + public void Apply_AddsMigrationColumns_ToALegacyDatabase() + { + var path = Path.Combine(Path.GetTempPath(), $"site-schema-legacy-{Guid.NewGuid():N}.db"); + try + { + using var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + + // The shapes as they existed before backup_configuration / + // failover_retry_count / metadata_json / timeout_seconds were added. + using (var legacy = connection.CreateCommand()) + { + legacy.CommandText = """ + CREATE TABLE data_connection_definitions ( + name TEXT PRIMARY KEY, + protocol TEXT NOT NULL, + configuration TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE 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) + ); + CREATE TABLE 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 + ); + INSERT INTO external_systems (name, endpoint_url, auth_type, updated_at) + VALUES ('legacy-system', 'http://example.invalid', 'None', '2026-01-01T00:00:00Z'); + """; + legacy.ExecuteNonQuery(); + } + + SiteStorageSchema.Apply(connection); + + Assert.True(ColumnExists(connection, "data_connection_definitions", "backup_configuration")); + Assert.True(ColumnExists(connection, "data_connection_definitions", "failover_retry_count")); + Assert.True(ColumnExists(connection, "native_alarm_state", "metadata_json")); + Assert.True(ColumnExists(connection, "external_systems", "timeout_seconds")); + + // The pre-existing row survives the upgrade and reads back the NOT NULL + // column's default rather than failing. + using var check = connection.CreateCommand(); + check.CommandText = "SELECT timeout_seconds FROM external_systems WHERE name = 'legacy-system'"; + Assert.Equal(0, Convert.ToInt32(check.ExecuteScalar())); + } + finally + { + SqliteConnection.ClearAllPools(); + File.Delete(path); + } + } + + private static bool TableExists(SqliteConnection connection, string table) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name"; + cmd.Parameters.AddWithValue("$name", table); + return Convert.ToInt32(cmd.ExecuteScalar()) > 0; + } + + private static bool ColumnExists(SqliteConnection connection, string table, string column) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = $name"; + cmd.Parameters.AddWithValue("$name", column); + return Convert.ToInt32(cmd.ExecuteScalar()) > 0; + } +}