LocalDb adoption Phase 1 + 2: consolidate the site database, delete the bespoke replicators #23
@@ -0,0 +1,169 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DDL for the site's nine configuration tables, extracted from
|
||||||
|
/// <see cref="SiteStorageService"/> so it can be applied by whoever owns the database
|
||||||
|
/// file.
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately depends only on <c>Microsoft.Data.Sqlite</c>, not on the LocalDb library.
|
||||||
|
/// The Host applies this DDL to a LocalDb-managed connection before
|
||||||
|
/// <c>RegisterReplicated</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Journal-mode and other connection pragmas are deliberately NOT set here — LocalDb owns
|
||||||
|
/// the connection's pragmas, and <see cref="SiteStorageService"/> keeps its own WAL set-up
|
||||||
|
/// for the databases it opens itself.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class SiteStorageSchema
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the nine site configuration tables when absent, and additively upgrades
|
||||||
|
/// tables created by an older build. Idempotent — safe to run on every startup.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open connection to the database that should hold the tables.</param>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Additively adds a column only when it is not already present. SQLite lacks
|
||||||
|
/// <c>ADD COLUMN IF NOT EXISTS</c>, so the schema is probed via
|
||||||
|
/// <c>PRAGMA table_info</c> first. Mirrors <c>OperationTrackingSchema</c>.
|
||||||
|
/// <para>
|
||||||
|
/// This replaces the previous try/catch on <c>SqliteException</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,126 +75,15 @@ public class SiteStorageService
|
|||||||
resultingMode);
|
resultingMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var command = connection.CreateCommand();
|
// The DDL itself lives in SiteStorageSchema so the Host can apply it to a
|
||||||
command.CommandText = @"
|
// LocalDb-managed connection before RegisterReplicated installs the capture
|
||||||
CREATE TABLE IF NOT EXISTS deployed_configurations (
|
// triggers. The service still calls it, so a directly-constructed service
|
||||||
instance_unique_name TEXT PRIMARY KEY,
|
// (tests, tooling) remains self-sufficient.
|
||||||
config_json TEXT NOT NULL,
|
SiteStorageSchema.Apply(connection);
|
||||||
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);
|
_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 ──
|
// ── Deployed Configuration CRUD ──
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins <see cref="SiteStorageSchema"/> as the single owner of the site config DDL.
|
||||||
|
/// The Host applies this to a LocalDb-managed connection before
|
||||||
|
/// <c>RegisterReplicated</c> installs the capture triggers, so the shape it produces
|
||||||
|
/// is the shape that replicates.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>ALTER</c> were deleted, because <c>CREATE TABLE</c> already lists them — so
|
||||||
|
/// this starts from the pre-migration shape and proves the migration path runs.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user