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;
+ }
+}