diff --git a/docker-env2/site-x-node-a/appsettings.Site.json b/docker-env2/site-x-node-a/appsettings.Site.json
index 5c2ada30..edcd4240 100644
--- a/docker-env2/site-x-node-a/appsettings.Site.json
+++ b/docker-env2/site-x-node-a/appsettings.Site.json
@@ -56,5 +56,13 @@
"Logging": {
"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"
}
}
diff --git a/docker-env2/site-x-node-b/appsettings.Site.json b/docker-env2/site-x-node-b/appsettings.Site.json
index b53344a1..459dbf04 100644
--- a/docker-env2/site-x-node-b/appsettings.Site.json
+++ b/docker-env2/site-x-node-b/appsettings.Site.json
@@ -56,5 +56,13 @@
"Logging": {
"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"
}
}
diff --git a/docker/site-a-node-a/appsettings.Site.json b/docker/site-a-node-a/appsettings.Site.json
index 905ff385..7c841b41 100644
--- a/docker/site-a-node-a/appsettings.Site.json
+++ b/docker/site-a-node-a/appsettings.Site.json
@@ -57,5 +57,13 @@
"Logging": {
"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"
}
}
diff --git a/docker/site-a-node-b/appsettings.Site.json b/docker/site-a-node-b/appsettings.Site.json
index f1db4f2a..299055cc 100644
--- a/docker/site-a-node-b/appsettings.Site.json
+++ b/docker/site-a-node-b/appsettings.Site.json
@@ -57,5 +57,13 @@
"Logging": {
"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"
}
}
diff --git a/docker/site-b-node-a/appsettings.Site.json b/docker/site-b-node-a/appsettings.Site.json
index 93018c13..cd13388a 100644
--- a/docker/site-b-node-a/appsettings.Site.json
+++ b/docker/site-b-node-a/appsettings.Site.json
@@ -57,5 +57,13 @@
"Logging": {
"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"
}
}
diff --git a/docker/site-b-node-b/appsettings.Site.json b/docker/site-b-node-b/appsettings.Site.json
index 755b0b0a..9b373702 100644
--- a/docker/site-b-node-b/appsettings.Site.json
+++ b/docker/site-b-node-b/appsettings.Site.json
@@ -57,5 +57,13 @@
"Logging": {
"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"
}
}
diff --git a/docker/site-c-node-a/appsettings.Site.json b/docker/site-c-node-a/appsettings.Site.json
index 40d2269f..759cc46d 100644
--- a/docker/site-c-node-a/appsettings.Site.json
+++ b/docker/site-c-node-a/appsettings.Site.json
@@ -57,5 +57,13 @@
"Logging": {
"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"
}
}
diff --git a/docker/site-c-node-b/appsettings.Site.json b/docker/site-c-node-b/appsettings.Site.json
index 93a6b292..0aeba3aa 100644
--- a/docker/site-c-node-b/appsettings.Site.json
+++ b/docker/site-c-node-b/appsettings.Site.json
@@ -57,5 +57,13 @@
"Logging": {
"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"
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs
new file mode 100644
index 00000000..070dcd1e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs
@@ -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;
+
+///
+/// Initializes the consolidated site database — the single ZB.MOM.WW.LocalDb-managed
+/// file that holds the site node's replicated state.
+///
+///
+///
+/// Ordering is load-bearing. Table DDL must run BEFORE
+/// RegisterReplicated, 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.
+///
+///
+/// Runs inside the AddZbLocalDb onReady callback, once, before any caller
+/// receives the singleton. onReady is a synchronous
+/// Action<ILocalDb>, hence the direct CreateConnection() rather than
+/// blocking on the async execute API. A throw here propagates out of the first
+/// GetRequiredService<ILocalDb>() and fails host startup, which is the
+/// intent: a site node that cannot establish its schema must not come up half-working.
+///
+///
+public static class SiteLocalDbSetup
+{
+ ///
+ /// Creates the site node's replicated tables and opts them into change capture.
+ ///
+ /// The LocalDb instance being initialized.
+ 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");
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs
index 6d282c33..3c5f691c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs
@@ -14,6 +14,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
+using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Telemetry;
@@ -49,6 +50,18 @@ public static class SiteServiceRegistration
// and site-local repository implementations (IExternalSystemRepository, INotificationRepository)
var siteDbPath = config["ScadaBridge:Database:SiteDbPath"] ?? "site.db";
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();
// 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.
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json
index cf8d3fed..ca1c8315 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json
@@ -58,5 +58,11 @@
"Logging": {
"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"
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs
index 5b0b7bcd..dbd8de93 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorPathTests.cs
@@ -163,10 +163,12 @@ public class SiteActorPathTests : IAsyncLifetime
private IHost? _host;
private ActorSystem? _actorSystem;
private string _tempDbPath = null!;
+ private string _tempLocalDbPath = null!;
public async Task InitializeAsync()
{
_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();
builder.ConfigureAppConfiguration(config =>
@@ -183,6 +185,10 @@ public class SiteActorPathTests : IAsyncLifetime
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:25520",
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
["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
["ScadaBridge:Communication:CentralContactPoints:0"] = "akka.tcp://scadabridge@localhost:25510",
});
@@ -207,6 +213,7 @@ public class SiteActorPathTests : IAsyncLifetime
_host.Dispose();
}
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
+ try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
}
[Fact]
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs
new file mode 100644
index 00000000..28a6dc57
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs
@@ -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;
+
+///
+/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site
+/// composition root.
+///
+/// These tests build the actual container from
+/// rather than asserting on a
+/// hand-assembled ServiceCollection. 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
+/// IAuditWriter 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.
+///
+///
+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
+ {
+ ["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();
+
+ 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());
+ }
+
+ [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();
+
+ 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();
+
+ 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();
+ var second = _host.Services.GetRequiredService();
+
+ 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();
+
+ Assert.True(File.Exists(_tempDbPath),
+ $"Expected the consolidated site database at '{_tempDbPath}'.");
+ }
+}