diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs new file mode 100644 index 00000000..3e52ab8e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs @@ -0,0 +1,264 @@ +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-Phase-1 site databases (site-tracking.db and +/// site_events.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. 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. +/// +/// +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"; + + /// + /// 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); + } + + /// + /// 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); + } + + 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 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); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs index 070dcd1e..13b08ae4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking; @@ -29,12 +30,15 @@ namespace ZB.MOM.WW.ScadaBridge.Host; public static class SiteLocalDbSetup { /// - /// Creates the site node's replicated tables and opts them into change capture. + /// Creates the site node's replicated tables, opts them into change capture, and + /// migrates any pre-Phase-1 databases in. /// /// The LocalDb instance being initialized. - public static void OnReady(ILocalDb db) + /// Configuration, for the legacy migrator's old path keys and node name. + public static void OnReady(ILocalDb db, IConfiguration config) { ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(config); using (var connection = db.CreateConnection()) { @@ -47,5 +51,9 @@ public static class SiteLocalDbSetup // capture). Registration is idempotent and installs the capture triggers. db.RegisterReplicated("OperationTracking"); db.RegisterReplicated("site_events"); + + // AFTER registration, so migrated rows enter the oplog and reach the peer like + // any other write. Before it, they would be invisible to replication forever. + SiteLocalDbLegacyMigrator.Migrate(db, config); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index 3b2122d7..e943b705 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -60,7 +60,7 @@ public static class SiteServiceRegistration // initiator idles. // // Design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md - services.AddZbLocalDb(config, SiteLocalDbSetup.OnReady); + services.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config)); // The replication engine, likewise unconditional but INERT by default: with no // LocalDb:Replication:PeerAddress the initiating SyncBackgroundService starts and diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs new file mode 100644 index 00000000..d3065f3c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs @@ -0,0 +1,327 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.ScadaBridge.Host; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// LocalDb Phase 1 (Task 6) — the one-time copy of the pre-Phase-1 site databases into +/// the consolidated one. +/// +/// +/// This is a data migration that runs during host startup, so its failure modes are +/// expensive: a duplicate-on-rerun bug silently doubles a site's event history, and a +/// half-committed copy leaves an operator with no clean state to recover to. Every bullet +/// of the plan's semantics gets its own test. +/// +public class SiteLocalDbLegacyMigratorTests : IDisposable +{ + private readonly string _root; + private readonly List _providers = []; + + public SiteLocalDbLegacyMigratorTests() + { + _root = Path.Combine(Path.GetTempPath(), $"localdb-migrator-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + foreach (var provider in _providers) + { + try { provider.Dispose(); } catch { /* best effort */ } + } + + try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + GC.SuppressFinalize(this); + } + + private string Path_(string name) => System.IO.Path.Combine(_root, name); + + /// A consolidated database with both tables created and registered, as the host has it. + private ILocalDb CreateConsolidated(IConfiguration config) + { + var provider = new ServiceCollection() + .AddZbLocalDb(config, db => + { + using var connection = db.CreateConnection(); + ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking.OperationTrackingSchema.Apply(connection); + ZB.MOM.WW.ScadaBridge.SiteEventLogging.SiteEventLogSchema.Apply(connection); + db.RegisterReplicated("OperationTracking"); + db.RegisterReplicated("site_events"); + }) + .BuildServiceProvider(); + _providers.Add(provider); + + return provider.GetRequiredService(); + } + + private IConfiguration Config( + string? trackingPath = null, string? eventsPath = null, string nodeName = "node-a") + { + var values = new Dictionary + { + ["LocalDb:Path"] = Path_("consolidated.db"), + ["ScadaBridge:Node:NodeName"] = nodeName, + }; + + if (trackingPath is not null) + values["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={trackingPath}"; + if (eventsPath is not null) + values["ScadaBridge:SiteEventLog:DatabasePath"] = eventsPath; + + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + private static void SeedLegacyTracking(string path, params string[] ids) + { + using var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking.OperationTrackingSchema.Apply(connection); + + foreach (var id in ids) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO OperationTracking ( + TrackedOperationId, Kind, TargetSummary, Status, RetryCount, + CreatedAtUtc, UpdatedAtUtc) + VALUES ($id, 'ApiCallCached', 'ERP.GetOrder', 'Submitted', 0, $now, $now); + """; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString("o")); + cmd.ExecuteNonQuery(); + } + } + + /// Seeds the LEGACY event schema: an autoincrement integer id, not a GUID. + private static void SeedLegacyEvents(string path, int count) + { + using var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + + using (var ddl = connection.CreateCommand()) + { + ddl.CommandText = """ + CREATE TABLE IF NOT EXISTS site_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, event_type TEXT NOT NULL, severity TEXT NOT NULL, + instance_id TEXT, source TEXT NOT NULL, message TEXT NOT NULL, details TEXT + ); + """; + ddl.ExecuteNonQuery(); + } + + for (var i = 0; i < count; i++) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO site_events (timestamp, event_type, severity, source, message) + VALUES ($ts, 'script', 'Info', 'src', $msg); + """; + cmd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.AddMinutes(-i).ToString("o")); + cmd.Parameters.AddWithValue("$msg", $"legacy message {i}"); + cmd.ExecuteNonQuery(); + } + } + + private static long CountRows(ILocalDb db, string table) + { + using var connection = db.CreateConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT COUNT(*) FROM {table}"; + return (long)cmd.ExecuteScalar()!; + } + + private static List SelectIds(ILocalDb db, string table, string idColumn) + { + using var connection = db.CreateConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {idColumn} FROM {table} ORDER BY {idColumn}"; + using var reader = cmd.ExecuteReader(); + + var ids = new List(); + while (reader.Read()) ids.Add(reader.GetString(0)); + return ids; + } + + [Fact] + public void MissingLegacyFiles_AreANoOp() + { + // The expected case on the docker rig: neither legacy key is set anywhere, and + // the CWD-relative defaults point outside the data volume, so there is usually + // nothing there at all. That must be a clean no-op, not a startup failure. + var config = Config(Path_("absent-tracking.db"), Path_("absent-events.db")); + var db = CreateConsolidated(config); + + SiteLocalDbLegacyMigrator.Migrate(db, config); + + Assert.Equal(0, CountRows(db, "OperationTracking")); + Assert.Equal(0, CountRows(db, "site_events")); + } + + [Fact] + public void LegacyTrackingRows_AreCopiedAndTheFileIsRenamed() + { + var trackingPath = Path_("site-tracking.db"); + SeedLegacyTracking(trackingPath, "op-1", "op-2", "op-3"); + var config = Config(trackingPath, Path_("absent-events.db")); + var db = CreateConsolidated(config); + + SiteLocalDbLegacyMigrator.Migrate(db, config); + + Assert.Equal(3, CountRows(db, "OperationTracking")); + Assert.Equal(["op-1", "op-2", "op-3"], SelectIds(db, "OperationTracking", "TrackedOperationId")); + + // Renamed, not deleted — the operator can still inspect the original. + Assert.False(File.Exists(trackingPath)); + Assert.True(File.Exists(trackingPath + ".migrated")); + } + + [Fact] + public void LegacyEvents_GetDeterministicIds_NotFreshGuids() + { + // The whole reason a rerun cannot duplicate. Fresh GUIDs would make + // INSERT OR IGNORE useless and double the history on every retry. + var eventsPath = Path_("site_events.db"); + SeedLegacyEvents(eventsPath, count: 3); + var config = Config(Path_("absent-tracking.db"), eventsPath, nodeName: "node-b"); + var db = CreateConsolidated(config); + + SiteLocalDbLegacyMigrator.Migrate(db, config); + + Assert.Equal( + ["mig-node-b-1", "mig-node-b-2", "mig-node-b-3"], + SelectIds(db, "site_events", "id")); + } + + [Fact] + public void SecondBoot_IsANoOp_BecauseTheFileIsAlreadyMarkedMigrated() + { + var trackingPath = Path_("site-tracking.db"); + var eventsPath = Path_("site_events.db"); + SeedLegacyTracking(trackingPath, "op-1", "op-2"); + SeedLegacyEvents(eventsPath, count: 2); + var config = Config(trackingPath, eventsPath); + var db = CreateConsolidated(config); + + SiteLocalDbLegacyMigrator.Migrate(db, config); + SiteLocalDbLegacyMigrator.Migrate(db, config); + + Assert.Equal(2, CountRows(db, "OperationTracking")); + Assert.Equal(2, CountRows(db, "site_events")); + } + + [Fact] + public void RerunAgainstAnUnrenamedFile_DoesNotDuplicate() + { + // Simulates the crash window: the copy committed but the rename did not. On the + // next boot the migrator sees the legacy file again and re-copies it. Both tables + // must absorb that with no duplicates — tracking via its natural TEXT key, events + // via the deterministic mig- id. + var trackingPath = Path_("site-tracking.db"); + var eventsPath = Path_("site_events.db"); + SeedLegacyTracking(trackingPath, "op-1", "op-2"); + SeedLegacyEvents(eventsPath, count: 2); + var config = Config(trackingPath, eventsPath); + var db = CreateConsolidated(config); + + SiteLocalDbLegacyMigrator.Migrate(db, config); + + // Undo the rename, exactly as a crash between commit and rename would leave it. + File.Move(trackingPath + ".migrated", trackingPath); + File.Move(eventsPath + ".migrated", eventsPath); + + SiteLocalDbLegacyMigrator.Migrate(db, config); + + Assert.Equal(2, CountRows(db, "OperationTracking")); + Assert.Equal(2, CountRows(db, "site_events")); + } + + [Fact] + public void MigratedRows_EnterTheOplog_SoTheyActuallyReplicate() + { + // The reason the migrator runs AFTER RegisterReplicated. Capture is trigger-based: + // migrate before registration and the rows are invisible to the peer forever, with + // no error anywhere. Asserting on the oplog is the only way to catch that ordering + // being reversed — every other test here would still pass. + var trackingPath = Path_("site-tracking.db"); + SeedLegacyTracking(trackingPath, "op-1", "op-2"); + var config = Config(trackingPath, Path_("absent-events.db")); + var db = CreateConsolidated(config); + + SiteLocalDbLegacyMigrator.Migrate(db, config); + + using var connection = db.CreateConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'OperationTracking'"; + Assert.Equal(2L, (long)cmd.ExecuteScalar()!); + } + + [Fact] + public void LegacyFileWithoutTheExpectedTable_IsTreatedAsEmpty_NotAFailure() + { + // A file predating the table (or an unrecognised shape) must not fail host boot. + var trackingPath = Path_("site-tracking.db"); + using (var connection = new SqliteConnection($"Data Source={trackingPath}")) + { + connection.Open(); + using var ddl = connection.CreateCommand(); + ddl.CommandText = "CREATE TABLE something_else (x INTEGER);"; + ddl.ExecuteNonQuery(); + } + + var config = Config(trackingPath, Path_("absent-events.db")); + var db = CreateConsolidated(config); + + SiteLocalDbLegacyMigrator.Migrate(db, config); + + Assert.Equal(0, CountRows(db, "OperationTracking")); + Assert.True(File.Exists(trackingPath + ".migrated")); + } + + [Fact] + public void InMemoryLegacyConnectionStrings_AreSkipped() + { + // Test/dev configurations point the legacy stores at in-memory databases. There is + // nothing durable to migrate, and Path.GetFullPath on ":memory:" would be nonsense. + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["LocalDb:Path"] = Path_("consolidated.db"), + ["ScadaBridge:Node:NodeName"] = "node-a", + ["ScadaBridge:OperationTracking:ConnectionString"] = + "Data Source=file:legacy?mode=memory&cache=shared", + ["ScadaBridge:SiteEventLog:DatabasePath"] = ":memory:", + }).Build(); + + Assert.Equal(string.Empty, SiteLocalDbLegacyMigrator.ResolveTrackingPath(config)); + Assert.Equal(string.Empty, SiteLocalDbLegacyMigrator.ResolveEventLogPath(config)); + + var db = CreateConsolidated(config); + SiteLocalDbLegacyMigrator.Migrate(db, config); + + Assert.Equal(0, CountRows(db, "site_events")); + } + + [Fact] + public void UnsetLegacyKeys_FallBackToTheCwdRelativeCodeDefaults() + { + // Neither key is set in any docker appsettings, so the OLD code used these + // CWD-relative defaults. Resolving them anywhere else (e.g. against the data + // volume) would look for the legacy data in a directory it was never written to. + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["LocalDb:Path"] = Path_("consolidated.db"), + }).Build(); + + Assert.Equal( + System.IO.Path.GetFullPath("site-tracking.db"), + SiteLocalDbLegacyMigrator.ResolveTrackingPath(config)); + Assert.Equal( + System.IO.Path.GetFullPath("site_events.db"), + SiteLocalDbLegacyMigrator.ResolveEventLogPath(config)); + } +}