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