diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs index 4fdeb1ee..56f8b832 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs @@ -111,6 +111,15 @@ public class SiteStorageService oauth_config TEXT, updated_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS native_alarm_state ( + instance_unique_name TEXT NOT NULL, + source_canonical_name TEXT NOT NULL, + source_reference TEXT NOT NULL, + condition_json TEXT NOT NULL, + last_transition_at TEXT NOT NULL, + PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference) + ); "; await command.ExecuteNonQueryAsync(); @@ -346,6 +355,104 @@ public class SiteStorageService _logger.LogDebug("Cleared static overrides for {Instance}", instanceName); } + // ── Task 14: Native Alarm State store (read-only mirror of source A&C conditions) ── + + /// + /// Inserts or updates a single mirrored native alarm condition, keyed by + /// (instance, source canonical name, source reference). Newer transitions overwrite older ones. + /// + public async Task UpsertNativeAlarmAsync( + string instanceName, string sourceCanonicalName, string sourceReference, + string conditionJson, DateTimeOffset lastTransitionAt) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO native_alarm_state + (instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at) + VALUES (@name, @source, @ref, @json, @at) + ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET + condition_json = excluded.condition_json, + last_transition_at = excluded.last_transition_at"; + + command.Parameters.AddWithValue("@name", instanceName); + command.Parameters.AddWithValue("@source", sourceCanonicalName); + command.Parameters.AddWithValue("@ref", sourceReference); + command.Parameters.AddWithValue("@json", conditionJson); + command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O")); + + await command.ExecuteNonQueryAsync(); + } + + /// + /// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention). + /// + public async Task DeleteNativeAlarmAsync(string instanceName, string sourceCanonicalName, string sourceReference) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + DELETE FROM native_alarm_state + WHERE instance_unique_name = @name + AND source_canonical_name = @source + AND source_reference = @ref"; + command.Parameters.AddWithValue("@name", instanceName); + command.Parameters.AddWithValue("@source", sourceCanonicalName); + command.Parameters.AddWithValue("@ref", sourceReference); + + await command.ExecuteNonQueryAsync(); + } + + /// + /// Returns all mirrored native alarm conditions for an instance's source binding, + /// used to rehydrate a NativeAlarmActor on (re)start. + /// + public async Task> GetNativeAlarmsAsync(string instanceName, string sourceCanonicalName) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT source_reference, condition_json, last_transition_at + FROM native_alarm_state + WHERE instance_unique_name = @name AND source_canonical_name = @source"; + command.Parameters.AddWithValue("@name", instanceName); + command.Parameters.AddWithValue("@source", sourceCanonicalName); + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + results.Add(new NativeAlarmRow( + reader.GetString(0), + reader.GetString(1), + DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind))); + } + + return results; + } + + /// + /// Clears all mirrored native alarm conditions for an instance. Called on redeployment / stop. + /// + public async Task ClearNativeAlarmsForInstanceAsync(string instanceName) + { + await using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name"; + command.Parameters.AddWithValue("@name", instanceName); + + await command.ExecuteNonQueryAsync(); + _logger.LogDebug("Cleared native alarm state for {Instance}", instanceName); + } + // ── WP-33: Shared Script CRUD ── /// @@ -699,3 +806,8 @@ public class StoredDataConnectionDefinition /// public int FailoverRetryCount { get; init; } = 3; } + +/// +/// A single mirrored native alarm condition row from the site-local native_alarm_state table (Task 14). +/// +public record NativeAlarmRow(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs new file mode 100644 index 00000000..4063ab37 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence; + +/// +/// Task 14: site-local SQLite native_alarm_state store — mirrored native alarm +/// condition snapshots keyed by (instance, source canonical name, source reference). +/// +public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable +{ + private readonly string _dbFile; + private SiteStorageService _storage = null!; + + public NativeAlarmStateStoreTests() + { + _dbFile = Path.Combine(Path.GetTempPath(), $"nas-{Guid.NewGuid():N}.db"); + } + + public async Task InitializeAsync() + { + _storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger.Instance); + await _storage.InitializeAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task Upsert_Then_Get_RoundTrips() + { + await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":true}", DateTimeOffset.UnixEpoch); + + var rows = await _storage.GetNativeAlarmsAsync("inst", "Src"); + + Assert.Single(rows); + Assert.Equal("Tank01.Hi", rows[0].SourceReference); + Assert.Equal("{\"Active\":true}", rows[0].ConditionJson); + Assert.Equal(DateTimeOffset.UnixEpoch, rows[0].LastTransitionAt); + } + + [Fact] + public async Task Upsert_SameKey_ReplacesConditionAndTimestamp() + { + await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":true}", DateTimeOffset.UnixEpoch); + await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":false}", DateTimeOffset.UnixEpoch.AddMinutes(5)); + + var rows = await _storage.GetNativeAlarmsAsync("inst", "Src"); + + Assert.Single(rows); + Assert.Equal("{\"Active\":false}", rows[0].ConditionJson); + Assert.Equal(DateTimeOffset.UnixEpoch.AddMinutes(5), rows[0].LastTransitionAt); + } + + [Fact] + public async Task Get_ScopesToInstanceAndSourceCanonicalName() + { + await _storage.UpsertNativeAlarmAsync("inst", "SrcA", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch); + await _storage.UpsertNativeAlarmAsync("inst", "SrcB", "Tank02.Hi", "{}", DateTimeOffset.UnixEpoch); + await _storage.UpsertNativeAlarmAsync("other", "SrcA", "Tank09.Hi", "{}", DateTimeOffset.UnixEpoch); + + var rows = await _storage.GetNativeAlarmsAsync("inst", "SrcA"); + + Assert.Single(rows); + Assert.Equal("Tank01.Hi", rows[0].SourceReference); + } + + [Fact] + public async Task Delete_RemovesSingleRow() + { + await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch); + await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Lo", "{}", DateTimeOffset.UnixEpoch); + + await _storage.DeleteNativeAlarmAsync("inst", "Src", "Tank01.Hi"); + + var rows = await _storage.GetNativeAlarmsAsync("inst", "Src"); + Assert.Single(rows); + Assert.Equal("Tank01.Lo", rows[0].SourceReference); + } + + [Fact] + public async Task ClearForInstance_RemovesAllSourcesForInstanceOnly() + { + await _storage.UpsertNativeAlarmAsync("inst", "SrcA", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch); + await _storage.UpsertNativeAlarmAsync("inst", "SrcB", "Tank02.Hi", "{}", DateTimeOffset.UnixEpoch); + await _storage.UpsertNativeAlarmAsync("other", "SrcA", "Tank09.Hi", "{}", DateTimeOffset.UnixEpoch); + + await _storage.ClearNativeAlarmsForInstanceAsync("inst"); + + Assert.Empty(await _storage.GetNativeAlarmsAsync("inst", "SrcA")); + Assert.Empty(await _storage.GetNativeAlarmsAsync("inst", "SrcB")); + Assert.Single(await _storage.GetNativeAlarmsAsync("other", "SrcA")); + } + + public void Dispose() + { + if (File.Exists(_dbFile)) + { + File.Delete(_dbFile); + } + } +}