feat(localdb): rewire OperationTrackingStore onto the consolidated site database

Task 4 of the LocalDb Phase 1 adoption plan.

OperationTrackingStore took IOptions<OperationTrackingOptions> and opened its own
SqliteConnection from ConnectionString. It now takes ILocalDb and gets every
connection - writer and the two ad-hoc reader paths - from CreateConnection().

This is not cosmetic. OperationTracking is a RegisterReplicated table, so its
capture triggers call zb_hlc_next(). That UDF is registered per connection by
ILocalDb and by nothing else, so a raw SqliteConnection would fail closed on
every write. Connections from CreateConnection() also arrive already open -
calling Open()/OpenAsync() on one throws - hence the removed OpenAsync calls on
the reader paths.

OperationTrackingOptions.ConnectionString is now vestigial for this store; the
database location is LocalDb:Path. The options class stays (retention settings)
and the config key stays bound for the site config DB.

InitializeSchema is kept but is now always a no-op in the host: onReady runs
while ILocalDb is being constructed, strictly before this constructor can
receive it. It remains so a directly-constructed store (tests, tooling) is
self-sufficient.

Tests: the store fixture moves off mode=memory&cache=shared onto a real ILocalDb
over a temp file. There is no in-memory mode - LocalDbOptions.Path is a
filesystem path - and testing through a raw in-memory connection would no longer
resemble the host. Verifier connections stay raw on purpose: this fixture never
registers the table, so no trigger fires and the UDF is never reached.

Three DI fixtures needed LocalDb:Path added, because resolving
IOperationTrackingStore now forces ILocalDb construction:
SiteCompositionRootTests, SiteAuditWiringTests, and the AuditLog
CombinedTelemetryHarness.

Verified: build 0 warnings; Host 294/294, SiteRuntime 530/530, AuditLog 354/354.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-19 08:59:38 -04:00
parent 2bff527247
commit 0b5e9b44f6
6 changed files with 198 additions and 74 deletions
@@ -1,6 +1,8 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
@@ -9,30 +11,93 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking;
/// <summary>
/// Audit Log #23 (M3 Bundle A — Task A2) — schema + behaviour tests for the
/// site-local <see cref="OperationTrackingStore"/>. Each test uses a unique
/// shared-cache in-memory SQLite database so the store and the verifier share
/// the same store without touching disk.
/// site-local <see cref="OperationTrackingStore"/>.
/// </summary>
public class OperationTrackingStoreTests
/// <remarks>
/// The store now takes <see cref="ILocalDb"/> rather than a connection string, so each
/// test gets a real <c>ILocalDb</c> over a unique temp FILE. The previous
/// <c>mode=memory&amp;cache=shared</c> fixture is gone and cannot come back:
/// <c>LocalDbOptions.Path</c> is a filesystem path, and — more importantly — a raw
/// connection to a shared-cache in-memory database would not carry the
/// <c>zb_hlc_next()</c> UDF the capture triggers call, so testing through one would no
/// longer resemble how the store runs in the host.
/// <para>
/// Verifier connections are plain <see cref="SqliteConnection"/>s over the same file.
/// That is deliberate and safe: they only read, or write columns on an unregistered
/// table (this fixture never calls <c>RegisterReplicated</c>), so no trigger fires and
/// the missing UDF is never reached.
/// </para>
/// </remarks>
public class OperationTrackingStoreTests : IDisposable
{
private static (OperationTrackingStore store, string dataSource) CreateStore(
string testName)
// Every ILocalDb and temp file created by this fixture, torn down together. The
// ILocalDb holds an open master connection anchoring the WAL, so it must be
// disposed before the file can be deleted.
private readonly List<ServiceProvider> _providers = [];
private readonly List<string> _tempFiles = [];
public void Dispose()
{
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var connectionString = $"Data Source={dataSource};Cache=Shared";
var options = new OperationTrackingOptions
foreach (var provider in _providers)
{
ConnectionString = connectionString,
};
var store = new OperationTrackingStore(
Options.Create(options),
NullLogger<OperationTrackingStore>.Instance);
return (store, dataSource);
try { provider.Dispose(); } catch { /* best effort */ }
}
foreach (var path in _tempFiles)
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
{
try { File.Delete(path + suffix); } catch { /* best effort */ }
}
}
GC.SuppressFinalize(this);
}
private static SqliteConnection OpenVerifierConnection(string dataSource)
/// <summary>Allocates a unique temp database path, registered for cleanup.</summary>
private string NewDatabasePath(string testName)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
var path = Path.Combine(
Path.GetTempPath(), $"optracking-{testName}-{Guid.NewGuid():N}.db");
_tempFiles.Add(path);
return path;
}
/// <summary>
/// Builds a real <see cref="ILocalDb"/> over <paramref name="databasePath"/>. No
/// <c>onReady</c> callback and no <c>RegisterReplicated</c> — the store's own
/// <c>InitializeSchema</c> creates the table, which is what keeps a
/// directly-constructed store self-sufficient.
/// </summary>
private ILocalDb CreateLocalDb(string databasePath)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LocalDb:Path"] = databasePath,
})
.Build();
var provider = new ServiceCollection()
.AddZbLocalDb(configuration)
.BuildServiceProvider();
_providers.Add(provider);
return provider.GetRequiredService<ILocalDb>();
}
private (OperationTrackingStore store, string dataSource) CreateStore(string testName)
{
var databasePath = NewDatabasePath(testName);
var store = new OperationTrackingStore(
CreateLocalDb(databasePath),
NullLogger<OperationTrackingStore>.Instance);
return (store, databasePath);
}
private static SqliteConnection OpenVerifierConnection(string databasePath)
{
var connection = new SqliteConnection($"Data Source={databasePath}");
connection.Open();
return connection;
}
@@ -111,14 +176,19 @@ public class OperationTrackingStoreTests
ON OperationTracking (Status, UpdatedAtUtc);
""";
private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource)
/// <summary>
/// Writes the pre-SourceNode schema into <paramref name="databasePath"/> and closes
/// the connection again. Unlike the old shared-cache in-memory fixture — where the
/// seeding connection had to stay open or the database evaporated — a file database
/// persists on its own, so nothing is held across the store's construction.
/// </summary>
private static void SeedPreSourceNodeSchemaDatabase(string databasePath)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
using var connection = new SqliteConnection($"Data Source={databasePath}");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreSourceNodeSchema;
cmd.ExecuteNonQuery();
return connection;
}
private static bool ColumnExists(SqliteConnection connection, string columnName)
@@ -129,36 +199,36 @@ public class OperationTrackingStoreTests
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
private static OperationTrackingStore CreateStoreOver(string dataSource)
{
var connectionString = $"Data Source={dataSource};Cache=Shared";
var options = new OperationTrackingOptions { ConnectionString = connectionString };
return new OperationTrackingStore(
Options.Create(options),
NullLogger<OperationTrackingStore>.Instance);
}
private OperationTrackingStore CreateStoreOver(string databasePath)
=> new(CreateLocalDb(databasePath), NullLogger<OperationTrackingStore>.Instance);
[Fact]
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
{
var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var databasePath = NewDatabasePath(nameof(Initialize_adds_SourceNode_to_pre_existing_schema));
// A pre-SourceNode deployment: tracking.db already exists with the
// 12-column schema and NO SourceNode column.
using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "SourceInstanceId"));
Assert.True(ColumnExists(seedConnection, "SourceScript"));
Assert.False(ColumnExists(seedConnection, "SourceNode"));
// A pre-SourceNode deployment: the tracking database already exists with
// the 12-column schema and NO SourceNode column.
SeedPreSourceNodeSchemaDatabase(databasePath);
using (var seeded = OpenVerifierConnection(databasePath))
{
Assert.True(ColumnExists(seeded, "SourceInstanceId"));
Assert.True(ColumnExists(seeded, "SourceScript"));
Assert.False(ColumnExists(seeded, "SourceNode"));
}
// Upgrade: a post-branch OperationTrackingStore opens the same database.
// Its InitializeSchema must ALTER the missing SourceNode column in —
// the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing
// table.
await using (var store = CreateStoreOver(dataSource))
await using (var store = CreateStoreOver(databasePath))
{
Assert.True(
ColumnExists(seedConnection, "SourceNode"),
"OperationTrackingStore must ALTER the SourceNode column into a pre-existing OperationTracking table.");
using (var verifier = OpenVerifierConnection(databasePath))
{
Assert.True(
ColumnExists(verifier, "SourceNode"),
"OperationTrackingStore must ALTER the SourceNode column into a pre-existing OperationTracking table.");
}
// A RecordEnqueueAsync binding $sourceNode must now succeed; without
// the ALTER it would fail with "no such column: SourceNode".
@@ -178,9 +248,10 @@ public class OperationTrackingStoreTests
// Idempotency: a second store over the now-upgraded DB must not error
// (the probe sees SourceNode already present and skips the ALTER).
await using (var storeAgain = CreateStoreOver(dataSource))
await using (var storeAgain = CreateStoreOver(databasePath))
{
Assert.True(ColumnExists(seedConnection, "SourceNode"));
using var verifier = OpenVerifierConnection(databasePath);
Assert.True(ColumnExists(verifier, "SourceNode"));
}
}