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);
+ }
+ }
+}