LocalDb adoption Phase 1 + 2: consolidate the site database, delete the bespoke replicators #23
@@ -56,5 +56,13 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
||||||
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
||||||
|
// outside the volume and were lost on every recreate.
|
||||||
|
// Replication is opt-in and configured separately; absent = local-only.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "/app/data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,5 +56,13 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
||||||
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
||||||
|
// outside the volume and were lost on every recreate.
|
||||||
|
// Replication is opt-in and configured separately; absent = local-only.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "/app/data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,5 +57,13 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
||||||
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
||||||
|
// outside the volume and were lost on every recreate.
|
||||||
|
// Replication is opt-in and configured separately; absent = local-only.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "/app/data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,5 +57,13 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
||||||
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
||||||
|
// outside the volume and were lost on every recreate.
|
||||||
|
// Replication is opt-in and configured separately; absent = local-only.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "/app/data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,5 +57,13 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
||||||
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
||||||
|
// outside the volume and were lost on every recreate.
|
||||||
|
// Replication is opt-in and configured separately; absent = local-only.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "/app/data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,5 +57,13 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
||||||
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
||||||
|
// outside the volume and were lost on every recreate.
|
||||||
|
// Replication is opt-in and configured separately; absent = local-only.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "/app/data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,5 +57,13 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
||||||
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
||||||
|
// outside the volume and were lost on every recreate.
|
||||||
|
// Replication is opt-in and configured separately; absent = local-only.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "/app/data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,5 +57,13 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
||||||
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
||||||
|
// outside the volume and were lost on every recreate.
|
||||||
|
// Replication is opt-in and configured separately; absent = local-only.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "/app/data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using ZB.MOM.WW.LocalDb;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the consolidated site database — the single <c>ZB.MOM.WW.LocalDb</c>-managed
|
||||||
|
/// file that holds the site node's replicated state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ordering is load-bearing.</b> Table DDL must run BEFORE
|
||||||
|
/// <c>RegisterReplicated</c>, and every row that should replicate must be written
|
||||||
|
/// AFTER it. Capture is trigger-based change-data-capture: the library neither
|
||||||
|
/// captures nor snapshots rows that already existed when the triggers were installed,
|
||||||
|
/// so a row written before registration is invisible to the peer forever — silently,
|
||||||
|
/// with no error anywhere.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Runs inside the <c>AddZbLocalDb</c> onReady callback, once, before any caller
|
||||||
|
/// receives the <see cref="ILocalDb"/> singleton. onReady is a synchronous
|
||||||
|
/// <c>Action<ILocalDb></c>, hence the direct <c>CreateConnection()</c> rather than
|
||||||
|
/// blocking on the async execute API. A throw here propagates out of the first
|
||||||
|
/// <c>GetRequiredService<ILocalDb>()</c> and fails host startup, which is the
|
||||||
|
/// intent: a site node that cannot establish its schema must not come up half-working.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class SiteLocalDbSetup
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the site node's replicated tables and opts them into change capture.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="db">The LocalDb instance being initialized.</param>
|
||||||
|
public static void OnReady(ILocalDb db)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(db);
|
||||||
|
|
||||||
|
using (var connection = db.CreateConnection())
|
||||||
|
{
|
||||||
|
OperationTrackingSchema.Apply(connection);
|
||||||
|
SiteEventLogSchema.Apply(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both tables qualify: each has an explicit primary key (RegisterReplicated
|
||||||
|
// rejects tables without one) and no BLOB columns (which json_object cannot
|
||||||
|
// capture). Registration is idempotent and installs the capture triggers.
|
||||||
|
db.RegisterReplicated("OperationTracking");
|
||||||
|
db.RegisterReplicated("site_events");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService;
|
|||||||
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||||
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
||||||
|
using ZB.MOM.WW.LocalDb;
|
||||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
using ZB.MOM.WW.Telemetry;
|
using ZB.MOM.WW.Telemetry;
|
||||||
|
|
||||||
@@ -49,6 +50,18 @@ public static class SiteServiceRegistration
|
|||||||
// and site-local repository implementations (IExternalSystemRepository, INotificationRepository)
|
// and site-local repository implementations (IExternalSystemRepository, INotificationRepository)
|
||||||
var siteDbPath = config["ScadaBridge:Database:SiteDbPath"] ?? "site.db";
|
var siteDbPath = config["ScadaBridge:Database:SiteDbPath"] ?? "site.db";
|
||||||
services.AddSiteRuntime($"Data Source={siteDbPath}");
|
services.AddSiteRuntime($"Data Source={siteDbPath}");
|
||||||
|
|
||||||
|
// Consolidated site database (LocalDb Phase 1). Holds OperationTracking and
|
||||||
|
// site_events as replicated tables so the pair stops losing them on failover.
|
||||||
|
//
|
||||||
|
// Storage is registered UNCONDITIONALLY and needs no flag: with no peer
|
||||||
|
// configured, LocalDb is simply a local SQLite file and the replication
|
||||||
|
// initiator idles. Replication itself is opt-in via LocalDb:Replication:PeerAddress
|
||||||
|
// and is wired in Program.cs, which owns the gRPC host the sync endpoint needs.
|
||||||
|
//
|
||||||
|
// Design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md
|
||||||
|
services.AddZbLocalDb(config, SiteLocalDbSetup.OnReady);
|
||||||
|
|
||||||
services.AddDataConnectionLayer();
|
services.AddDataConnectionLayer();
|
||||||
// Local SQLite store by default; a local store replicating against a shared SQL-Server hub
|
// Local SQLite store by default; a local store replicating against a shared SQL-Server hub
|
||||||
// only when Secrets:Replication:Enabled is true AND a hub connection string is present.
|
// only when Secrets:Replication:Enabled is true AND a hub connection string is present.
|
||||||
|
|||||||
@@ -58,5 +58,11 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
||||||
|
// LocalDb:Path is REQUIRED and validated on start - a site node with no value here
|
||||||
|
// fails to boot, so every site config must set it.
|
||||||
|
"LocalDb": {
|
||||||
|
"Path": "./data/site-localdb.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,10 +163,12 @@ public class SiteActorPathTests : IAsyncLifetime
|
|||||||
private IHost? _host;
|
private IHost? _host;
|
||||||
private ActorSystem? _actorSystem;
|
private ActorSystem? _actorSystem;
|
||||||
private string _tempDbPath = null!;
|
private string _tempDbPath = null!;
|
||||||
|
private string _tempLocalDbPath = null!;
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_test_{Guid.NewGuid()}.db");
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_test_{Guid.NewGuid()}.db");
|
||||||
|
_tempLocalDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_localdb_{Guid.NewGuid()}.db");
|
||||||
|
|
||||||
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
||||||
builder.ConfigureAppConfiguration(config =>
|
builder.ConfigureAppConfiguration(config =>
|
||||||
@@ -183,6 +185,10 @@ public class SiteActorPathTests : IAsyncLifetime
|
|||||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
|
||||||
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
|
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
|
||||||
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
|
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
|
||||||
|
// Required: LocalDbOptions.Path is validated with ValidateOnStart, and this
|
||||||
|
// fixture actually starts the host — a site node with no configured
|
||||||
|
// consolidated-database path fails fast rather than booting half-working.
|
||||||
|
["LocalDb:Path"] = _tempLocalDbPath,
|
||||||
// Configure a dummy central contact point to trigger ClusterClient creation
|
// Configure a dummy central contact point to trigger ClusterClient creation
|
||||||
["ScadaBridge:Communication:CentralContactPoints:0"] = "akka.tcp://scadabridge@localhost:25510",
|
["ScadaBridge:Communication:CentralContactPoints:0"] = "akka.tcp://scadabridge@localhost:25510",
|
||||||
});
|
});
|
||||||
@@ -207,6 +213,7 @@ public class SiteActorPathTests : IAsyncLifetime
|
|||||||
_host.Dispose();
|
_host.Dispose();
|
||||||
}
|
}
|
||||||
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||||
|
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.LocalDb;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site
|
||||||
|
/// composition root.
|
||||||
|
/// <para>
|
||||||
|
/// These tests build the actual container from
|
||||||
|
/// <see cref="SiteServiceRegistration.Configure"/> rather than asserting on a
|
||||||
|
/// hand-assembled <c>ServiceCollection</c>. That is deliberate and load-bearing: this
|
||||||
|
/// family has now shipped three wiring defects that only a container-built graph would
|
||||||
|
/// have caught — the Secrets Akka replicator that registered into a no-op sink (0.2.0),
|
||||||
|
/// the singleton cycle that deadlocked a real host (0.2.2), and Secrets.Ui's missing
|
||||||
|
/// <c>IAuditWriter</c> that 500'd only behind a login wall (ScadaBridge#22). A test that
|
||||||
|
/// merely checks "AddZbLocalDb was called" would have passed in every one of those cases.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class SiteLocalDbWiringTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _host;
|
||||||
|
private readonly string _tempDbPath;
|
||||||
|
private readonly string _tempSiteDbPath;
|
||||||
|
private readonly string _tempTrackingDbPath;
|
||||||
|
|
||||||
|
public SiteLocalDbWiringTests()
|
||||||
|
{
|
||||||
|
var stamp = Guid.NewGuid();
|
||||||
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{stamp}.db");
|
||||||
|
_tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_site_{stamp}.db");
|
||||||
|
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{stamp}.db");
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
builder.Configuration.Sources.Clear();
|
||||||
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ScadaBridge:Node:Role"] = "Site",
|
||||||
|
["ScadaBridge:Node:NodeName"] = "node-a",
|
||||||
|
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
||||||
|
["ScadaBridge:Node:SiteId"] = "TestSite",
|
||||||
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
||||||
|
["ScadaBridge:Node:GrpcPort"] = "0",
|
||||||
|
["ScadaBridge:Database:SiteDbPath"] = _tempSiteDbPath,
|
||||||
|
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
|
||||||
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
||||||
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
||||||
|
|
||||||
|
// The consolidated site database. No Replication section at all — this
|
||||||
|
// fixture is also the default-OFF pin: storage must work standalone.
|
||||||
|
["LocalDb:Path"] = _tempDbPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddGrpc();
|
||||||
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
||||||
|
|
||||||
|
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||||
|
|
||||||
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||||
|
|
||||||
|
_host = builder.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
(_host as IDisposable)?.Dispose();
|
||||||
|
foreach (var path in new[] { _tempDbPath, _tempSiteDbPath, _tempTrackingDbPath })
|
||||||
|
{
|
||||||
|
try { File.Delete(path); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_Resolves_ILocalDb()
|
||||||
|
{
|
||||||
|
Assert.NotNull(_host.Services.GetService<ILocalDb>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_LocalDb_RegistersBothReplicatedTables()
|
||||||
|
{
|
||||||
|
// The whole point of Phase 1. If onReady silently failed to register these,
|
||||||
|
// storage would still work and every other test would pass — the pair would
|
||||||
|
// just never replicate anything. Assert on the library's own view of what is
|
||||||
|
// registered, not on our belief that we called it.
|
||||||
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
||||||
|
|
||||||
|
Assert.Contains("OperationTracking", db.ReplicatedTables.Keys);
|
||||||
|
Assert.Contains("site_events", db.ReplicatedTables.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_LocalDb_ReplicatedTablesHaveExplicitPrimaryKeys()
|
||||||
|
{
|
||||||
|
// RegisterReplicated refuses a table with no explicit PK, so reaching this
|
||||||
|
// assertion at all proves the DDL ran before registration. The PK names are
|
||||||
|
// pinned because they are what last-writer-wins conflict resolution keys on.
|
||||||
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
["TrackedOperationId"], db.ReplicatedTables["OperationTracking"].PkColumns);
|
||||||
|
Assert.Equal(["id"], db.ReplicatedTables["site_events"].PkColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_LocalDb_IsASingleton()
|
||||||
|
{
|
||||||
|
// AddZbLocalDb is one-DB-per-process and first-registration-wins. Two
|
||||||
|
// instances would mean two SQLite handles and two trigger installations
|
||||||
|
// against the same file.
|
||||||
|
var first = _host.Services.GetRequiredService<ILocalDb>();
|
||||||
|
var second = _host.Services.GetRequiredService<ILocalDb>();
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_LocalDb_CreatesTheConfiguredFile()
|
||||||
|
{
|
||||||
|
// Resolving is what triggers onReady; the file should exist afterwards at the
|
||||||
|
// configured path and not somewhere CWD-relative.
|
||||||
|
_ = _host.Services.GetRequiredService<ILocalDb>();
|
||||||
|
|
||||||
|
Assert.True(File.Exists(_tempDbPath),
|
||||||
|
$"Expected the consolidated site database at '{_tempDbPath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user