using Microsoft.Data.Sqlite; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; /// /// DDL for the site's twelve configuration tables (nine original plus /// deployed_secrets, mirrored_secrets and secret_mirror_status), 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 twelve 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) ); -- 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 ); -- Per-node deployed-secrets mirror status (review finding M1): one row per site -- node, keyed by node name, holding that node's CURRENT mirror failures as a JSON -- array of {SecretName, Message} (names and messages only — never values or -- ciphertext). Unlike mirrored_secrets this IS replicated -- (SiteLocalDbSetup.ReplicatedTables): each node writes only its own row, and the -- replication is exactly what lets the ACTIVE node's health report carry the -- STANDBY node's failures to central. Written only when a node's failure set -- changes (plus once per start-up), never per mirror tick. No foreign keys. CREATE TABLE IF NOT EXISTS secret_mirror_status ( node_name TEXT PRIMARY KEY, failures_json TEXT NOT NULL, checked_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"); } /// /// 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(); } }