217 lines
11 KiB
C#
217 lines
11 KiB
C#
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
|
|
|
/// <summary>
|
|
/// DDL for the site's eleven configuration tables (nine original plus
|
|
/// <c>deployed_secrets</c> and <c>mirrored_secrets</c>), 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 eleven 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)
|
|
);
|
|
|
|
-- Deployed secrets (ciphertext only — see SecretArtifact). Replicated between the
|
|
-- two site nodes by LocalDb CDC (SiteLocalDbSetup.ReplicatedTables). A full-set
|
|
-- upsert soft-deletes rows absent from the incoming set via is_removed, never a
|
|
-- mass DELETE on a replicated table (that would ship as a destructive resync).
|
|
CREATE TABLE IF NOT EXISTS deployed_secrets (
|
|
name TEXT PRIMARY KEY,
|
|
row_json TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
updated_utc TEXT NOT NULL,
|
|
is_removed INTEGER NOT NULL DEFAULT 0,
|
|
deployment_id TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL
|
|
);
|
|
|
|
-- Node-local ledger of which secret names THIS node has mirrored into its own
|
|
-- secret store. Deliberately NOT in SiteLocalDbSetup.ReplicatedTables: mirroring
|
|
-- is a per-node action against a per-node secret store, so which names are
|
|
-- mirrored (and at what revision) is node-specific, not site-wide state.
|
|
CREATE TABLE IF NOT EXISTS mirrored_secrets (
|
|
name TEXT PRIMARY KEY,
|
|
revision INTEGER NOT NULL,
|
|
mirrored_at TEXT NOT NULL
|
|
);
|
|
";
|
|
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");
|
|
|
|
// Store-and-Forward retry policy, deployed per external system (deferred-work row 27).
|
|
//
|
|
// 0 = unset on BOTH columns, which is what makes this additive change behaviourally
|
|
// invisible until an operator actually configures a policy: an existing row backfilled
|
|
// with the DEFAULT reads back as 0, and ExternalSystemClient.CachedCallAsync maps a 0
|
|
// to null so the engine's DefaultMaxRetries/DefaultRetryInterval still apply. (Contrast
|
|
// database_connections, whose equivalent columns default to 3/1000 — that table has
|
|
// never had an "unset means engine default" contract.)
|
|
//
|
|
// These land on a table in SiteLocalDbSetup.ReplicatedTables. That is safe and needs no
|
|
// trigger migration: RegisterReplicated re-reads PRAGMA table_info on EVERY boot and its
|
|
// install script is DROP-then-CREATE, so the capture triggers are rebuilt over the new
|
|
// column set automatically — and Apply() runs before RegisterReplicated, so the rebuild
|
|
// sees these columns on the same boot that adds them. What DOES change is the table's
|
|
// schema digest (ReplicatedTable.ComputeDigest hashes every column name + type), which
|
|
// the sync handshake compares fail-closed; a pair running mismatched builds refuses to
|
|
// sync loudly (LocalDbSchemaMismatchException) rather than corrupting anything. Both
|
|
// nodes must therefore be upgraded together — already the standing rule for a site pair
|
|
// (docs/deployment/topology-guide.md), so this introduces no new operational constraint.
|
|
AddColumnIfMissing(connection, "external_systems", "max_retries", "INTEGER NOT NULL DEFAULT 0");
|
|
AddColumnIfMissing(connection, "external_systems", "retry_delay_ms", "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();
|
|
}
|
|
}
|