feat(localdb): one-time migrator for the pre-Phase-1 site databases

Task 6 of the LocalDb Phase 1 adoption plan. Copies site-tracking.db and
site_events.db into the consolidated database, then renames each source to
<name>.migrated.

Runs AFTER RegisterReplicated, deliberately: capture is trigger-based, so rows
inserted before registration would never enter the oplog and would never reach
the peer - silently. There is a test asserting migrated rows land in
__localdb_oplog, because every other test in the file would still pass with that
ordering reversed.

Idempotent twice over. Tracking rows keep their existing TEXT primary key, so
INSERT OR IGNORE dedupes naturally. Legacy events had autoincrement integer ids,
which the consolidated schema replaced with GUIDs; they get DETERMINISTIC ids of
the form mig-{NodeName}-{legacyId} rather than fresh GUIDs, so a migration that
crashes between commit and rename cannot duplicate history on the next boot. The
NodeName prefix keeps the two nodes of a pair from colliding on their independent
legacy id sequences. Both properties are covered, including an explicit
crash-window test that undoes the rename and re-runs.

All-or-nothing: the copy commits in one transaction and the rename happens only
afterwards, so a failure throws out of OnReady, fails host startup, and leaves
the legacy files untouched. Deviation from the plan: it specified
BeginTransactionAsync, but OnReady is a synchronous Action<ILocalDb>. A raw
transaction on a CreateConnection() connection gives identical atomicity and
identical trigger capture without blocking on async in a startup callback.

Legacy paths come from the OLD config keys and fall back to the CWD-relative code
defaults, because that is where the old code actually wrote them. Note this means
both legacy databases have always lived outside the mounted data volume and were
already discarded on every container recreate - so on the rig this migrator will
usually find nothing, which is a legitimate no-op rather than a failure. Phase 1
incidentally fixes that data-loss bug.

Non-failure cases are covered too: missing files, a legacy file with no such
table, and in-memory legacy connection strings from test/dev configs.

Verified: build 0 warnings; Host 316/316 (9 migrator tests new).

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-19 09:36:38 -04:00
parent 59c695191c
commit 98b94771ae
4 changed files with 602 additions and 3 deletions
@@ -0,0 +1,264 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// One-time copy of the pre-Phase-1 site databases (<c>site-tracking.db</c> and
/// <c>site_events.db</c>) into the consolidated <c>ZB.MOM.WW.LocalDb</c> database.
/// </summary>
/// <remarks>
/// <para>
/// <b>Runs after <c>RegisterReplicated</c>, deliberately.</b> 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.
/// </para>
/// <para>
/// <b>Idempotent twice over.</b> Tracking rows carry the same TEXT primary key they always
/// had, so <c>INSERT OR IGNORE</c> is naturally idempotent. Legacy events had autoincrement
/// integer ids, which the consolidated schema replaced with GUIDs; they are given
/// <i>deterministic</i> ids of the form <c>mig-{NodeName}-{legacyId}</c> 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.
/// </para>
/// <para>
/// <b>All-or-nothing.</b> The copy runs in one transaction and the legacy file is renamed
/// to <c>&lt;name&gt;.migrated</c> only after commit. A failure throws out of
/// <c>OnReady</c>, 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.
/// </para>
/// <para>
/// <b>Finding nothing is the expected case on the docker rig.</b> Neither legacy config key
/// is set in any rig appsettings, so both fall back to CWD-relative code defaults
/// (<c>/app/site-tracking.db</c>, <c>/app/site_events.db</c>) 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
/// <c>/app/data/site-localdb.db</c>. A no-op here is a legitimate result, not a failure.
/// </para>
/// </remarks>
public static class SiteLocalDbLegacyMigrator
{
private const string MigratedSuffix = ".migrated";
/// <summary>Default legacy tracking connection string (<c>OperationTrackingOptions.ConnectionString</c>).</summary>
private const string DefaultTrackingConnectionString = "Data Source=site-tracking.db";
/// <summary>Default legacy event-log path (<c>SiteEventLogOptions.DatabasePath</c>).</summary>
private const string DefaultEventLogPath = "site_events.db";
/// <summary>
/// Copies any legacy site databases into <paramref name="db"/>, then renames them.
/// </summary>
/// <param name="db">The consolidated site database, with both tables already registered.</param>
/// <param name="config">Configuration supplying the legacy paths and the node name.</param>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Resolves the legacy event-log path from the OLD key, falling back to the code default.</summary>
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);
}
/// <summary>True when there is a legacy file present that has not already been migrated.</summary>
private static bool ShouldMigrate(string legacyPath)
=> !string.IsNullOrEmpty(legacyPath)
&& File.Exists(legacyPath)
&& !File.Exists(legacyPath + MigratedSuffix);
/// <summary>
/// Reads every row of <paramref name="sql"/> from the legacy file, or null when the
/// table does not exist (an old file predating the table is not an error).
/// </summary>
private static List<object?[]>? 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<object?[]>();
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);
}
/// <summary>
/// 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.
/// </summary>
private static void MarkMigrated(string legacyPath)
=> File.Move(legacyPath, legacyPath + MigratedSuffix);
}
@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <param name="db">The LocalDb instance being initialized.</param>
public static void OnReady(ILocalDb db)
/// <param name="config">Configuration, for the legacy migrator's old path keys and node name.</param>
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);
}
}
@@ -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
@@ -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;
/// <summary>
/// LocalDb Phase 1 (Task 6) — the one-time copy of the pre-Phase-1 site databases into
/// the consolidated one.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class SiteLocalDbLegacyMigratorTests : IDisposable
{
private readonly string _root;
private readonly List<ServiceProvider> _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);
/// <summary>A consolidated database with both tables created and registered, as the host has it.</summary>
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<ILocalDb>();
}
private IConfiguration Config(
string? trackingPath = null, string? eventsPath = null, string nodeName = "node-a")
{
var values = new Dictionary<string, string?>
{
["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();
}
}
/// <summary>Seeds the LEGACY event schema: an autoincrement integer id, not a GUID.</summary>
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<string> 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<string>();
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<string, string?>
{
["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<string, string?>
{
["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));
}
}