using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.ScadaBridge.Host; /// /// One-time copy of the pre-consolidation site databases — Phase 1's /// site-tracking.db and site_events.db, and Phase 2's /// store-and-forward.db — into the consolidated ZB.MOM.WW.LocalDb database. /// /// /// /// Runs after RegisterReplicated, deliberately. Capture is trigger-based, so /// rows inserted before registration would never enter the oplog and would never reach the /// peer. Migrating after registration means the migrated history replicates like any other /// write — which is the entire point of Phase 1. /// /// /// Idempotent twice over. Tracking rows carry the same TEXT primary key they always /// had, so INSERT OR IGNORE is naturally idempotent. Legacy events had autoincrement /// integer ids, which the consolidated schema replaced with GUIDs; they are given /// deterministic ids of the form mig-{NodeName}-{legacyId} rather than fresh /// GUIDs, so a migration that crashes and re-runs cannot duplicate events. The NodeName /// prefix keeps the two nodes of a pair from colliding on their independent legacy id /// sequences. /// /// /// All-or-nothing. The copy runs in one transaction and the legacy file is renamed /// to <name>.migrated only after commit. A failure throws out of /// OnReady, which fails host startup with the legacy files untouched — no /// half-migrated state to reason about. A second boot sees the renamed file and no-ops. /// /// /// Finding nothing is the expected case on the docker rig for the two Phase 1 files. /// Neither legacy config key /// is set in any rig appsettings, so both fall back to CWD-relative code defaults /// (/app/site-tracking.db, /app/site_events.db) that sit OUTSIDE the mounted /// data volume — meaning they were already being discarded on every container recreate. /// Phase 1 incidentally fixes that data-loss bug by consolidating into /// /app/data/site-localdb.db. A no-op here is a legitimate result, not a failure. /// /// /// Store-and-forward is the exception. Its default path is inside the data /// volume (./data/store-and-forward.db), so a real deployment has a real file there /// holding undelivered messages. That migration genuinely moves data, and dropping it would /// silently discard exactly the buffered calls store-and-forward exists to protect. /// /// public static class SiteLocalDbLegacyMigrator { private const string MigratedSuffix = ".migrated"; /// Default legacy tracking connection string (OperationTrackingOptions.ConnectionString). private const string DefaultTrackingConnectionString = "Data Source=site-tracking.db"; /// Default legacy event-log path (SiteEventLogOptions.DatabasePath). private const string DefaultEventLogPath = "site_events.db"; /// Default legacy store-and-forward path (StoreAndForwardOptions.SqliteDbPath). private const string DefaultStoreAndForwardPath = "./data/store-and-forward.db"; /// Default legacy site configuration path (appsettings.Site.json). private const string DefaultSiteStoragePath = "./data/scadabridge.db"; /// /// Every column of the current sf_messages schema, in a fixed order. Columns /// absent from an older legacy file are dropped from the copy rather than failing it — /// see . /// private static readonly string[] StoreAndForwardColumns = [ "id", "category", "target", "payload_json", "retry_count", "max_retries", "retry_interval_ms", "created_at", "last_attempt_at", "status", "last_error", "origin_instance", "execution_id", "source_script", "parent_execution_id", "last_attempt_at_ms", ]; /// /// The site configuration tables copied out of the legacy scadabridge.db, with /// the current schema's columns for each. /// /// /// notification_lists and smtp_configurations are deliberately absent. /// Both are purged on every deploy and are permanently empty by design — the site-side /// write paths were removed on 2026-07-10. A pre-fix legacy file can still hold rows, and /// smtp_configurations.password is plaintext. /// /// Skipping them here is one half of a pair: the cutover also declines to register them /// for replication, for the same reason. Migrating them would leave plaintext SMTP /// passwords sitting in the consolidated database — one future RegisterReplicated /// away from being shipped to a peer — in exchange for resurrecting config that nothing /// reads. Keeping the tables permanently empty is what makes both decisions safe. /// /// The tables themselves are still created (see SiteStorageSchema); only their /// historical contents are left behind. /// internal static readonly LegacyTable[] SiteStorageTables = [ new("deployed_configurations", "instance_unique_name", [ "instance_unique_name", "config_json", "deployment_id", "revision_hash", "is_enabled", "deployed_at", ]), new("static_attribute_overrides", "instance_unique_name", [ "instance_unique_name", "attribute_name", "override_value", "updated_at", ]), new("shared_scripts", "name", [ "name", "code", "parameter_definitions", "return_definition", "updated_at", ]), new("external_systems", "name", [ "name", "endpoint_url", "auth_type", "auth_configuration", "method_definitions", "updated_at", "timeout_seconds", ]), new("database_connections", "name", [ "name", "connection_string", "max_retries", "retry_delay_ms", "updated_at", ]), new("data_connection_definitions", "name", [ "name", "protocol", "configuration", "backup_configuration", "failover_retry_count", "updated_at", ]), new("native_alarm_state", "instance_unique_name", [ "instance_unique_name", "source_canonical_name", "source_reference", "condition_json", "last_transition_at", "metadata_json", ]), ]; /// /// Copies any legacy site databases into , then renames them. /// /// The consolidated site database, with both tables already registered. /// Configuration supplying the legacy paths and the node name. public static void Migrate(ILocalDb db, IConfiguration config) { ArgumentNullException.ThrowIfNull(db); ArgumentNullException.ThrowIfNull(config); var nodeName = config["ScadaBridge:Node:NodeName"] ?? "unknown-node"; MigrateTracking(db, ResolveTrackingPath(config)); MigrateEvents(db, ResolveEventLogPath(config), nodeName); MigrateStoreAndForward(db, ResolveStoreAndForwardPath(config)); MigrateSiteStorage(db, ResolveSiteStoragePath(config)); } /// /// Resolves the legacy tracking database path from the OLD connection-string key, /// falling back to the code default. Relative paths resolve against the process working /// directory — NOT the data volume — because that is where the old code actually put them. /// internal static string ResolveTrackingPath(IConfiguration config) { var connectionString = config["ScadaBridge:OperationTracking:ConnectionString"] ?? DefaultTrackingConnectionString; string dataSource; try { dataSource = new SqliteConnectionStringBuilder(connectionString).DataSource; } catch (ArgumentException) { // An unparseable legacy connection string means there is nothing to migrate // from. Do not fail the host over a stale key we are about to stop reading. return string.Empty; } // In-memory legacy databases (test/dev configurations) have nothing durable to // migrate, and Path.GetFullPath on them would produce nonsense. if (string.IsNullOrWhiteSpace(dataSource) || dataSource.Equals(":memory:", StringComparison.OrdinalIgnoreCase) || dataSource.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) { return string.Empty; } return Path.GetFullPath(dataSource); } /// Resolves the legacy event-log path from the OLD key, falling back to the code default. internal static string ResolveEventLogPath(IConfiguration config) { var path = config["ScadaBridge:SiteEventLog:DatabasePath"] ?? DefaultEventLogPath; if (string.IsNullOrWhiteSpace(path) || path.Equals(":memory:", StringComparison.OrdinalIgnoreCase)) { return string.Empty; } return Path.GetFullPath(path); } /// /// Resolves the legacy store-and-forward database path from the OLD key, falling back /// to the code default. Unlike the two Phase 1 paths, this default sits INSIDE the /// mounted data volume (./data/), so on the docker rig there is a real file here /// with real buffered messages — this migration is not the usual no-op. /// internal static string ResolveStoreAndForwardPath(IConfiguration config) { var path = config["ScadaBridge:StoreAndForward:SqliteDbPath"] ?? DefaultStoreAndForwardPath; if (string.IsNullOrWhiteSpace(path) || path.Equals(":memory:", StringComparison.OrdinalIgnoreCase) || path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) { return string.Empty; } return Path.GetFullPath(path); } /// /// Resolves the legacy site configuration database path from the OLD key, falling back /// to the code default. Like store-and-forward — and unlike the two Phase 1 paths — this /// default is inside the mounted data volume, so a real deployment has real config here. /// internal static string ResolveSiteStoragePath(IConfiguration config) { var path = config["ScadaBridge:Database:SiteDbPath"] ?? DefaultSiteStoragePath; if (string.IsNullOrWhiteSpace(path) || path.Equals(":memory:", StringComparison.OrdinalIgnoreCase) || path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) { return string.Empty; } return Path.GetFullPath(path); } /// /// Copies buffered store-and-forward messages out of the legacy file. /// /// /// No id synthesis, unlike : sf_messages.id is already /// a caller-assigned TEXT primary key, so INSERT OR IGNORE is naturally idempotent /// across a crash-then-rerun. /// /// These are undelivered messages, so dropping them is real data loss — a buffered call /// that never reaches its external system is exactly what store-and-forward exists to /// prevent. That is why the copy tolerates an older column set rather than bailing. /// /// private static void MigrateStoreAndForward(ILocalDb db, string legacyPath) => MigrateFile(db, legacyPath, [new LegacyTable("sf_messages", "id", StoreAndForwardColumns)]); /// /// Copies the site's configuration tables out of the legacy scadabridge.db. /// /// /// All seven migrated tables live in one file, so they are copied inside a single /// transaction and the file is renamed once: a partial config migration would leave a /// site node running against half its old configuration, which is worse than failing /// startup outright. /// private static void MigrateSiteStorage(ILocalDb db, string legacyPath) => MigrateFile(db, legacyPath, SiteStorageTables); /// /// Copies one table from a legacy file into the consolidated database, then renames the /// legacy file. /// /// /// The copy is restricted to the columns the legacy table actually has. A file /// written by an older build predates some columns, and naming a missing column in the /// SELECT would throw "no such column" — which the reader treats as an unrecognised /// shape, silently discarding every row in the table. Intersecting first means an old /// file migrates its data and simply leaves the newer columns at their schema defaults. /// /// The consolidated database. /// The legacy file, which may not exist. /// Every table to copy out of this file, in order. private static void MigrateFile(ILocalDb db, string legacyPath, IReadOnlyList tables) { if (!ShouldMigrate(legacyPath)) return; using (var legacy = OpenLegacyReadOnly(legacyPath)) { using var connection = db.CreateConnection(); using var transaction = connection.BeginTransaction(); foreach (var table in tables) { var present = PresentColumns(legacy, table.Table, table.Columns); // An absent table probes as zero columns, so this one guard covers both // "old file predating the table" and "file we do not recognise". if (present.Contains(table.RequiredColumn)) CopyRows(legacy, connection, transaction, table.Table, present); } transaction.Commit(); } MarkMigrated(legacyPath); } /// One table to copy out of a legacy file. /// Table name, identical on both sides. /// /// A column without which the table is not the one we mean — normally the primary key. /// Copying rows with a NULL PK would be worse than copying nothing. /// /// The current schema's full column list, in a fixed order. internal sealed record LegacyTable(string Table, string RequiredColumn, string[] Columns); private static void MigrateTracking(ILocalDb db, string legacyPath) { if (!ShouldMigrate(legacyPath)) return; var rows = ReadAll( legacyPath, """ SELECT TrackedOperationId, Kind, TargetSummary, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, SourceInstanceId, SourceScript, SourceNode FROM OperationTracking; """, expectedColumns: 13); // A legacy file with no OperationTracking table at all reads as "nothing to do"; // it still gets renamed so the probe does not repeat on every boot. if (rows is not null) { using var connection = db.CreateConnection(); using var transaction = connection.BeginTransaction(); foreach (var row in rows) { using var cmd = connection.CreateCommand(); cmd.Transaction = transaction; cmd.CommandText = """ INSERT OR IGNORE INTO OperationTracking ( TrackedOperationId, Kind, TargetSummary, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, SourceInstanceId, SourceScript, SourceNode ) VALUES ( $p0, $p1, $p2, $p3, $p4, $p5, $p6, $p7, $p8, $p9, $p10, $p11, $p12 ); """; Bind(cmd, row); cmd.ExecuteNonQuery(); } transaction.Commit(); } MarkMigrated(legacyPath); } private static void MigrateEvents(ILocalDb db, string legacyPath, string nodeName) { if (!ShouldMigrate(legacyPath)) return; var rows = ReadAll( legacyPath, """ SELECT id, timestamp, event_type, severity, instance_id, source, message, details FROM site_events; """, expectedColumns: 8); if (rows is not null) { using var connection = db.CreateConnection(); using var transaction = connection.BeginTransaction(); foreach (var row in rows) { using var cmd = connection.CreateCommand(); cmd.Transaction = transaction; cmd.CommandText = """ INSERT OR IGNORE INTO site_events ( id, timestamp, event_type, severity, instance_id, source, message, details ) VALUES ( $id, $p1, $p2, $p3, $p4, $p5, $p6, $p7 ); """; // Deterministic id, NOT a fresh GUID: a crashed-then-rerun migration must // not duplicate events, and INSERT OR IGNORE can only dedupe on a stable key. cmd.Parameters.AddWithValue("$id", $"mig-{nodeName}-{row[0] ?? "null"}"); for (var i = 1; i < row.Length; i++) cmd.Parameters.AddWithValue($"$p{i}", row[i] ?? (object)DBNull.Value); cmd.ExecuteNonQuery(); } transaction.Commit(); } MarkMigrated(legacyPath); } /// True when there is a legacy file present that has not already been migrated. private static bool ShouldMigrate(string legacyPath) => !string.IsNullOrEmpty(legacyPath) && File.Exists(legacyPath) && !File.Exists(legacyPath + MigratedSuffix); /// /// Reads every row of from the legacy file, or null when the /// table does not exist (an old file predating the table is not an error). /// private static List? ReadAll(string legacyPath, string sql, int expectedColumns) { using var connection = new SqliteConnection($"Data Source={legacyPath};Mode=ReadOnly"); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = sql; SqliteDataReader reader; try { reader = cmd.ExecuteReader(); } catch (SqliteException) { // "no such table" / "no such column" — a legacy file from before this table // existed, or a shape we do not recognise. Nothing to copy. return null; } var rows = new List(); using (reader) { while (reader.Read()) { var row = new object?[expectedColumns]; for (var i = 0; i < expectedColumns; i++) row[i] = reader.IsDBNull(i) ? null : reader.GetValue(i); rows.Add(row); } } return rows; } private static SqliteConnection OpenLegacyReadOnly(string legacyPath) { var connection = new SqliteConnection($"Data Source={legacyPath};Mode=ReadOnly"); connection.Open(); return connection; } /// /// Returns the subset of that the legacy table actually has, /// in the caller's order. An absent table yields an empty list rather than throwing. /// private static List PresentColumns( SqliteConnection legacy, string table, IReadOnlyList wanted) { var present = new HashSet(StringComparer.OrdinalIgnoreCase); using (var probe = legacy.CreateCommand()) { // Table name is a caller-controlled constant, never user input — safe to // interpolate (parameters are not permitted as a pragma-function argument). probe.CommandText = $"SELECT name FROM pragma_table_info('{table}')"; using var reader = probe.ExecuteReader(); while (reader.Read()) present.Add(reader.GetString(0)); } return [.. wanted.Where(present.Contains)]; } /// Streams every row of from the legacy table into the target. private static void CopyRows( SqliteConnection legacy, SqliteConnection target, SqliteTransaction transaction, string table, IReadOnlyList columns) { var columnList = string.Join(", ", columns); var parameterList = string.Join(", ", columns.Select((_, i) => $"$p{i}")); using var read = legacy.CreateCommand(); read.CommandText = $"SELECT {columnList} FROM {table};"; using var reader = read.ExecuteReader(); while (reader.Read()) { using var write = target.CreateCommand(); write.Transaction = transaction; write.CommandText = $"INSERT OR IGNORE INTO {table} ({columnList}) VALUES ({parameterList});"; for (var i = 0; i < columns.Count; i++) write.Parameters.AddWithValue($"$p{i}", reader.IsDBNull(i) ? DBNull.Value : reader.GetValue(i)); write.ExecuteNonQuery(); } } private static void Bind(SqliteCommand cmd, object?[] row) { for (var i = 0; i < row.Length; i++) cmd.Parameters.AddWithValue($"$p{i}", row[i] ?? (object)DBNull.Value); } /// /// Renames the legacy file so a later boot skips it. Done only after the copy has /// committed; the file is kept rather than deleted so an operator can still inspect it. /// private static void MarkMigrated(string legacyPath) => File.Move(legacyPath, legacyPath + MigratedSuffix); }