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();
}
}