diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmConditionStateSchema.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmConditionStateSchema.cs
new file mode 100644
index 00000000..38b3acf5
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmConditionStateSchema.cs
@@ -0,0 +1,82 @@
+using Microsoft.Data.Sqlite;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+
+///
+/// DDL for the Part 9 scripted-alarm condition state: one row per alarm identity holding the
+/// operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the ack/confirm audit
+/// trail and comment history.
+///
+///
+///
+/// Replaces the central config DB's ScriptedAlarmState table as the node-local home
+/// for this state (per-cluster mesh Phase 4, which cuts the ConfigDb from driver-only
+/// nodes). Living in the consolidated LocalDb file is what lets the state replicate to the
+/// redundant pair peer, exactly like the alarm store-and-forward buffer — see
+/// , which this mirrors.
+///
+///
+/// Deliberately depends on nothing but so it can be applied
+/// to any connection — the host's LocalDbSetup.OnReady in production, and a bare
+/// connection in a test — without dragging the DI graph along.
+///
+///
+/// The primary key is the alarm identity, and Save is an unconditional upsert onto it.
+/// Convergence across the pair is last-writer-wins over the primary key, handled by the
+/// LocalDb replication HLC — so the store stacks no application-level last-write-wins on top,
+/// the same discipline the alarm-sf sink and the deployment pointer follow.
+///
+///
+/// ActiveState has no column — it is re-derived from the live predicate on startup,
+/// so persisting it would only risk operators seeing a stale Active on restart. Likewise
+/// LastActiveUtc / LastClearedUtc re-derive alongside it and get no columns.
+/// LastTransitionUtc is carried by updated_at_utc — the row-write timestamp is
+/// the last transition. Timestamps are round-trip ("O") TEXT so lexicographic order is
+/// chronological; nullable timestamps and audit fields are stored as NULL.
+///
+///
+public static class AlarmConditionStateSchema
+{
+ /// Table holding one condition-state row per scripted-alarm identity.
+ public const string StateTable = "alarm_condition_state";
+
+ ///
+ /// Creates the state table if it does not already exist. Idempotent.
+ ///
+ ///
+ /// An already-open connection. ILocalDb.CreateConnection() hands out open,
+ /// pragma-configured connections — do not call Open() on one.
+ ///
+ public static void Apply(SqliteConnection connection)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+
+ using var cmd = connection.CreateCommand();
+
+ // The four *_state columns are always written to a mapped, non-null string (Enabled/Disabled,
+ // Acknowledged/Unacknowledged, Confirmed/Unconfirmed, Unshelved/OneShotShelved/TimedShelved),
+ // so they are NOT NULL. comments_json is never null either — an empty trail is the literal
+ // "[]". The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm
+ // has no such history yet. updated_at_utc carries LastTransitionUtc (there is no dedicated
+ // transition column).
+ cmd.CommandText = """
+ CREATE TABLE IF NOT EXISTS alarm_condition_state (
+ scripted_alarm_id TEXT NOT NULL PRIMARY KEY,
+ enabled_state TEXT NOT NULL,
+ acked_state TEXT NOT NULL,
+ confirmed_state TEXT NOT NULL,
+ shelving_state TEXT NOT NULL,
+ shelving_expires_utc TEXT NULL,
+ last_ack_user TEXT NULL,
+ last_ack_comment TEXT NULL,
+ last_ack_utc TEXT NULL,
+ last_confirm_user TEXT NULL,
+ last_confirm_comment TEXT NULL,
+ last_confirm_utc TEXT NULL,
+ comments_json TEXT NOT NULL,
+ updated_at_utc TEXT NOT NULL
+ );
+ """;
+ cmd.ExecuteNonQuery();
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
index ecbd6c8a..cd70e38c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
@@ -6,8 +6,9 @@ using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
///
-/// The onReady callback handed to AddZbLocalDb: creates the deployment-cache and
-/// alarm store-and-forward tables and opts them into replication.
+/// The onReady callback handed to AddZbLocalDb: creates the deployment-cache,
+/// alarm store-and-forward, and scripted-alarm condition-state tables and opts them into
+/// replication.
///
///
///
@@ -56,11 +57,13 @@ public static class LocalDbSetup
{
DeploymentCacheSchema.Apply(connection);
AlarmSfSchema.Apply(connection);
+ AlarmConditionStateSchema.Apply(connection);
}
db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable);
db.RegisterReplicated(DeploymentCacheSchema.PointerTable);
db.RegisterReplicated(AlarmSfSchema.EventsTable);
+ db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
// LAST, and only here. This is the one call in OnReady that writes rows.
AlarmSfLegacyMigrator.Migrate(db, configuration);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/LocalDbAlarmConditionStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/LocalDbAlarmConditionStateStore.cs
new file mode 100644
index 00000000..a47b7e65
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/LocalDbAlarmConditionStateStore.cs
@@ -0,0 +1,264 @@
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Text.Json;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.LocalDb;
+using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
+
+///
+/// Node-local backed by the replicated
+/// table in the node's consolidated LocalDb.
+/// Maps the full Part 9 — Enabled / Acked / Confirmed /
+/// Shelving + the ack/confirm audit trail + operator comments.
+///
+///
+///
+/// The LocalDb replacement for EfAlarmConditionStateStore (which persisted to the
+/// central config DB's ScriptedAlarmState table). Per-cluster mesh Phase 4 cuts the
+/// ConfigDb from driver-only nodes, so this state moves into the same replicated LocalDb
+/// file the Phase-2 alarm store-and-forward buffer already lives in — a node's condition
+/// state then mirrors to its redundant pair peer instead of depending on central SQL.
+///
+///
+/// The round-trip is byte-identical to the EF store — the enum↔string mappers and the
+/// comment serialization below are duplicated verbatim from it (it is deleted in a later
+/// task; the ported round-trip tests prove parity). The persistence rules carry over
+/// unchanged:
+///
+///
+/// ActiveState is NOT persisted — there is no column; on it is
+/// restored as and the engine re-derives it from the
+/// live predicate. LastTransitionUtc ↔ updated_at_utc: no dedicated transition column,
+/// so the last transition rides the row-write timestamp. LastActiveUtc / LastClearedUtc
+/// have no columns and default to null on load.
+/// serializes to/from comments_json; an empty list round-trips as "[]".
+///
+///
+/// Save is a single unconditional PK upsert. No application-level last-write-wins or
+/// HLC comparison is layered on top — the LocalDb replication HLC handles convergence, and an
+/// unconditional upsert keyed by the primary key is HLC-safe (the same discipline the
+/// alarm-sf sink and the deployment pointer follow).
+///
+///
+public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ // Fixed column order shared by every read — the reader map below indexes by these ordinals.
+ private const string SelectColumns =
+ "scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state, " +
+ "shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc, " +
+ "last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc";
+
+ private readonly ILocalDb _db;
+ private readonly ILogger _logger;
+
+ /// Initializes a new instance of the class.
+ ///
+ /// The node's local database. Its table
+ /// must already exist and be registered for replication — the host does both in
+ /// LocalDbSetup.OnReady, before any consumer can resolve this store.
+ ///
+ /// The logger instance.
+ public LocalDbAlarmConditionStateStore(ILocalDb db, ILogger logger)
+ {
+ _db = db ?? throw new ArgumentNullException(nameof(db));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task LoadAsync(string alarmId, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(alarmId);
+ var rows = await _db.QueryAsync(
+ $"SELECT {SelectColumns} FROM alarm_condition_state WHERE scripted_alarm_id = @Id",
+ MapRow,
+ new { Id = alarmId },
+ ct).ConfigureAwait(false);
+ return rows.Count > 0 ? rows[0] : null;
+ }
+
+ ///
+ public async Task> LoadAllAsync(CancellationToken ct)
+ {
+ var rows = await _db.QueryAsync(
+ $"SELECT {SelectColumns} FROM alarm_condition_state",
+ MapRow,
+ parameters: null,
+ ct).ConfigureAwait(false);
+ return rows;
+ }
+
+ ///
+ public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+
+ // A single unconditional upsert keyed on the alarm identity. Convergence across the pair is
+ // last-writer-wins over the primary key, handled by the LocalDb replication HLC — no
+ // app-level LWW here (see the class remarks).
+ await _db.ExecuteAsync(
+ """
+ INSERT INTO alarm_condition_state
+ (scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
+ shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc,
+ last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc)
+ VALUES
+ (@Id, @Enabled, @Acked, @Confirmed, @Shelving,
+ @ShelvingExpires, @LastAckUser, @LastAckComment, @LastAckUtc,
+ @LastConfirmUser, @LastConfirmComment, @LastConfirmUtc, @CommentsJson, @UpdatedAtUtc)
+ ON CONFLICT(scripted_alarm_id) DO UPDATE SET
+ enabled_state = excluded.enabled_state,
+ acked_state = excluded.acked_state,
+ confirmed_state = excluded.confirmed_state,
+ shelving_state = excluded.shelving_state,
+ shelving_expires_utc = excluded.shelving_expires_utc,
+ last_ack_user = excluded.last_ack_user,
+ last_ack_comment = excluded.last_ack_comment,
+ last_ack_utc = excluded.last_ack_utc,
+ last_confirm_user = excluded.last_confirm_user,
+ last_confirm_comment = excluded.last_confirm_comment,
+ last_confirm_utc = excluded.last_confirm_utc,
+ comments_json = excluded.comments_json,
+ updated_at_utc = excluded.updated_at_utc
+ """,
+ new
+ {
+ Id = state.AlarmId,
+ Enabled = MapEnabledToColumn(state.Enabled),
+ Acked = MapAckedToColumn(state.Acked),
+ Confirmed = MapConfirmedToColumn(state.Confirmed),
+ Shelving = MapShelvingToColumn(state.Shelving.Kind),
+ ShelvingExpires = FormatNullable(state.Shelving.UnshelveAtUtc),
+ LastAckUser = state.LastAckUser,
+ LastAckComment = state.LastAckComment,
+ LastAckUtc = FormatNullable(state.LastAckUtc),
+ LastConfirmUser = state.LastConfirmUser,
+ LastConfirmComment = state.LastConfirmComment,
+ LastConfirmUtc = FormatNullable(state.LastConfirmUtc),
+ CommentsJson = SerializeComments(state.Comments),
+ // No dedicated transition column — persist LastTransitionUtc into updated_at_utc.
+ UpdatedAtUtc = Format(state.LastTransitionUtc),
+ },
+ ct).ConfigureAwait(false);
+
+ _logger.LogTrace("Persisted alarm-condition state for {AlarmId}", state.AlarmId);
+ }
+
+ ///
+ public async Task RemoveAsync(string alarmId, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(alarmId);
+ await _db.ExecuteAsync(
+ "DELETE FROM alarm_condition_state WHERE scripted_alarm_id = @Id",
+ new { Id = alarmId },
+ ct).ConfigureAwait(false);
+ }
+
+ private static AlarmConditionState MapRow(SqliteDataReader r) => new(
+ AlarmId: r.GetString(0),
+ Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
+ ? AlarmEnabledState.Disabled
+ : AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
+ // Active is not persisted — the engine re-derives it from the predicate at startup.
+ Active: AlarmActiveState.Inactive,
+ Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
+ ? AlarmAckedState.Acknowledged
+ : AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
+ Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
+ ? AlarmConfirmedState.Confirmed
+ : AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
+ Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
+ // No transition column — updated_at_utc carries the last transition timestamp.
+ LastTransitionUtc: Parse(r.GetString(13)),
+ // LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
+ LastActiveUtc: null,
+ LastClearedUtc: null,
+ LastAckUtc: ParseNullable(r, 8),
+ LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
+ LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
+ LastConfirmUtc: ParseNullable(r, 11),
+ LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
+ LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
+ Comments: DeserializeComments(r.GetString(12)));
+
+ // Timestamps are stored as round-trip ("O") TEXT so lexicographic order stays chronological and
+ // the DateTimeKind.Utc round-trips exactly.
+ private static string Format(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
+
+ private static string? FormatNullable(DateTime? value) => value?.ToString("O", CultureInfo.InvariantCulture);
+
+ private static DateTime Parse(string value) =>
+ DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+
+ private static DateTime? ParseNullable(SqliteDataReader r, int ordinal) =>
+ r.IsDBNull(ordinal) ? null : Parse(r.GetString(ordinal));
+
+ private static string MapEnabledToColumn(AlarmEnabledState enabled)
+ => enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled";
+
+ private static string MapAckedToColumn(AlarmAckedState acked)
+ => acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged";
+
+ private static string MapConfirmedToColumn(AlarmConfirmedState confirmed)
+ => confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed";
+
+ private static string MapShelvingToColumn(ShelvingKind kind) => kind switch
+ {
+ ShelvingKind.OneShot => "OneShotShelved",
+ ShelvingKind.Timed => "TimedShelved",
+ _ => "Unshelved",
+ };
+
+ private static ShelvingKind MapShelvingFromColumn(string column) => column switch
+ {
+ "OneShotShelved" => ShelvingKind.OneShot,
+ "TimedShelved" => ShelvingKind.Timed,
+ _ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
+ };
+
+ private static string SerializeComments(ImmutableList comments)
+ {
+ if (comments.IsEmpty) return "[]";
+ var dtos = comments.Select(c => new CommentDto
+ {
+ // AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
+ // the engine always creates AlarmComment instances with Utc kind.
+ TimestampUtc = c.TimestampUtc,
+ User = c.User,
+ Kind = c.Kind,
+ Text = c.Text,
+ });
+ return JsonSerializer.Serialize(dtos, JsonOptions);
+ }
+
+ private static ImmutableList DeserializeComments(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return ImmutableList.Empty;
+ var dtos = JsonSerializer.Deserialize>(json, JsonOptions);
+ if (dtos is null || dtos.Count == 0) return ImmutableList.Empty;
+ return dtos
+ .Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
+ .ToImmutableList();
+ }
+
+ /// Stable on-disk shape for a persisted in comments_json.
+ private sealed class CommentDto
+ {
+ /// When the comment was recorded (UTC).
+ public DateTime TimestampUtc { get; set; }
+
+ /// Identity of the actor that wrote the comment.
+ public string? User { get; set; }
+
+ /// Human-readable classification of the comment (Acknowledge, Confirm, …).
+ public string? Kind { get; set; }
+
+ /// Operator-supplied or engine-generated comment text.
+ public string? Text { get; set; }
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/AlarmConditionStateSchemaTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/AlarmConditionStateSchemaTests.cs
new file mode 100644
index 00000000..028bc49f
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/AlarmConditionStateSchemaTests.cs
@@ -0,0 +1,46 @@
+using Microsoft.Data.Sqlite;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
+
+///
+/// Verifies creates its table and is idempotent —
+/// the DDL depends on nothing but a , so a bare in-memory
+/// connection is a faithful harness (no zb_hlc_next() UDF is involved in CREATE TABLE).
+///
+[Trait("Category", "Unit")]
+public sealed class AlarmConditionStateSchemaTests
+{
+ private static SqliteConnection OpenInMemory()
+ {
+ var connection = new SqliteConnection("Data Source=:memory:");
+ connection.Open();
+ return connection;
+ }
+
+ /// A single Apply creates the table.
+ [Fact]
+ public void Apply_creates_the_state_table()
+ {
+ using var connection = OpenInMemory();
+
+ AlarmConditionStateSchema.Apply(connection);
+
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText =
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @Name";
+ cmd.Parameters.AddWithValue("@Name", AlarmConditionStateSchema.StateTable);
+ ((long)cmd.ExecuteScalar()!).ShouldBe(1);
+ }
+
+ /// Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS).
+ [Fact]
+ public void Apply_is_idempotent()
+ {
+ using var connection = OpenInMemory();
+
+ AlarmConditionStateSchema.Apply(connection);
+ Should.NotThrow(() => AlarmConditionStateSchema.Apply(connection));
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs
index 3e45a17d..df45f06a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs
@@ -46,7 +46,7 @@ public sealed class LocalDbSetupTests : IDisposable
}
[Fact]
- public void OnReady_RegistersExactlyTheThreeReplicatedTables()
+ public void OnReady_RegistersExactlyTheFourReplicatedTables()
{
// Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call
// (that table then never replicates). "No more" catches an accidental registration —
@@ -54,7 +54,7 @@ public sealed class LocalDbSetupTests : IDisposable
var db = BuildDb();
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
- .ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
+ .ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
}
[Fact]
@@ -89,6 +89,16 @@ public sealed class LocalDbSetupTests : IDisposable
db.ReplicatedTables["alarm_sf_events"].PkColumns.ShouldBe(["id"]);
}
+ [Fact]
+ public void AlarmConditionState_PkIsTheScriptedAlarmId()
+ {
+ // One condition-state row per alarm identity. The pair's two nodes converge on the same row
+ // under LWW rather than each keeping its own, and Save is an unconditional upsert onto it.
+ var db = BuildDb();
+
+ db.ReplicatedTables["alarm_condition_state"].PkColumns.ShouldBe(["scripted_alarm_id"]);
+ }
+
[Fact]
public async Task AlarmSfEventRows_EnterTheOplog()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs
index 82f6c36e..a2c81f5a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs
@@ -95,7 +95,7 @@ public sealed class LocalDbWiringTests : IDisposable
// Exact set, both directions — a dropped registration replicates nothing; an extra one costs
// three triggers and oplog volume on every write.
first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
- .ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
+ .ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
}
[Fact]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs
new file mode 100644
index 00000000..b2c15696
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs
@@ -0,0 +1,344 @@
+using System.Collections.Immutable;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LocalDb;
+using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
+
+///
+/// Round-trip + upsert + lifecycle coverage for ,
+/// the LocalDb-backed over the replicated
+/// alarm_condition_state table.
+///
+///
+/// Ported verbatim from EfAlarmConditionStateStoreTests — the two stores share the
+/// contract, so the assertions transfer unchanged and prove
+/// parity. Only store construction differs: this suite drives a real temp-file
+/// (the library has no in-memory mode, and a bare
+/// would lack the zb_hlc_next() UDF the capture triggers
+/// call), whereas the EF suite used an in-memory DbContext factory.
+///
+public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
+{
+ private readonly string _dbPath =
+ Path.Combine(Path.GetTempPath(), $"otopcua-alarm-state-{Guid.NewGuid():N}.db");
+
+ private readonly ServiceProvider _provider;
+ private readonly ILocalDb _db;
+
+ public LocalDbAlarmConditionStateStoreTests()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = _dbPath })
+ .Build();
+
+ _provider = new ServiceCollection()
+ .AddZbLocalDb(configuration, db =>
+ {
+ using (var connection = db.CreateConnection())
+ {
+ AlarmConditionStateSchema.Apply(connection);
+ }
+
+ db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
+ })
+ .BuildServiceProvider();
+
+ _db = _provider.GetRequiredService();
+ }
+
+ public void Dispose()
+ {
+ _provider.Dispose();
+
+ // Pooled connections keep a handle open past dispose; clearing the pools first is what
+ // makes the delete actually succeed.
+ SqliteConnection.ClearAllPools();
+
+ foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
+ {
+ try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
+ }
+ }
+
+ private LocalDbAlarmConditionStateStore NewStore()
+ => new(_db, NullLogger.Instance);
+
+ private static AlarmConditionState Sample(
+ string alarmId,
+ AlarmActiveState active = AlarmActiveState.Inactive,
+ ShelvingState? shelving = null,
+ ImmutableList? comments = null)
+ {
+ var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
+ return new AlarmConditionState(
+ AlarmId: alarmId,
+ Enabled: AlarmEnabledState.Disabled,
+ Active: active,
+ Acked: AlarmAckedState.Unacknowledged,
+ Confirmed: AlarmConfirmedState.Unconfirmed,
+ Shelving: shelving ?? new ShelvingState(ShelvingKind.Timed, t.AddMinutes(30)),
+ LastTransitionUtc: t,
+ LastActiveUtc: t.AddMinutes(-5),
+ LastClearedUtc: t.AddMinutes(-1),
+ LastAckUtc: t.AddMinutes(-2),
+ LastAckUser: "jane",
+ LastAckComment: "ack-comment",
+ LastConfirmUtc: t.AddMinutes(-3),
+ LastConfirmUser: "bob",
+ LastConfirmComment: "confirm-comment",
+ Comments: comments ?? ImmutableList.Empty);
+ }
+
+ /// Save then load round-trips every persisted operator-state + audit field.
+ [Fact]
+ public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields()
+ {
+ var store = NewStore();
+ var state = Sample("alarm-1");
+
+ await store.SaveAsync(state, CancellationToken.None);
+ var loaded = await store.LoadAsync("alarm-1", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.AlarmId.ShouldBe("alarm-1");
+ loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled);
+ loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
+ loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
+ loaded.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
+ loaded.Shelving.UnshelveAtUtc.ShouldBe(state.Shelving.UnshelveAtUtc);
+ loaded.LastTransitionUtc.ShouldBe(state.LastTransitionUtc);
+ loaded.LastAckUtc.ShouldBe(state.LastAckUtc);
+ loaded.LastAckUser.ShouldBe("jane");
+ loaded.LastAckComment.ShouldBe("ack-comment");
+ loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc);
+ loaded.LastConfirmUser.ShouldBe("bob");
+ loaded.LastConfirmComment.ShouldBe("confirm-comment");
+ }
+
+ /// Loading an id that was never saved returns null.
+ [Fact]
+ public async Task LoadAsync_for_unknown_id_returns_null()
+ {
+ var store = NewStore();
+
+ var loaded = await store.LoadAsync("never-saved", CancellationToken.None);
+
+ loaded.ShouldBeNull();
+ }
+
+ /// Active is not persisted — a saved Active state loads back as Inactive.
+ [Fact]
+ public async Task Active_is_ignored_on_load_and_defaults_to_Inactive()
+ {
+ var store = NewStore();
+ await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-active", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Active.ShouldBe(AlarmActiveState.Inactive);
+ }
+
+ /// LastActiveUtc / LastClearedUtc have no columns and default to null on load.
+ [Fact]
+ public async Task Derived_timestamps_default_to_null_on_load()
+ {
+ var store = NewStore();
+ await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.LastActiveUtc.ShouldBeNull();
+ loaded.LastClearedUtc.ShouldBeNull();
+ }
+
+ /// Unshelved round-trips with a null UnshelveAtUtc.
+ [Fact]
+ public async Task Unshelved_round_trips()
+ {
+ var store = NewStore();
+ await store.SaveAsync(
+ Sample("alarm-unshelved", shelving: ShelvingState.Unshelved),
+ CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-unshelved", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
+ loaded.Shelving.UnshelveAtUtc.ShouldBeNull();
+ }
+
+ /// OneShot shelving round-trips.
+ [Fact]
+ public async Task OneShot_shelving_round_trips()
+ {
+ var store = NewStore();
+ await store.SaveAsync(
+ Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)),
+ CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-oneshot", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
+ loaded.Shelving.UnshelveAtUtc.ShouldBeNull();
+ }
+
+ /// The append-only comment trail round-trips all four fields per comment.
+ [Fact]
+ public async Task Comments_round_trip_all_fields()
+ {
+ var store = NewStore();
+ var c1 = new AlarmComment(
+ new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump");
+ var c2 = new AlarmComment(
+ new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc), "bob", "Confirm", "all clear");
+ var comments = ImmutableList.Create(c1, c2);
+
+ await store.SaveAsync(Sample("alarm-comments", comments: comments), CancellationToken.None);
+ var loaded = await store.LoadAsync("alarm-comments", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Comments.Count.ShouldBe(2);
+ loaded.Comments[0].TimestampUtc.ShouldBe(c1.TimestampUtc);
+ loaded.Comments[0].User.ShouldBe("jane");
+ loaded.Comments[0].Kind.ShouldBe("Acknowledge");
+ loaded.Comments[0].Text.ShouldBe("checking pump");
+ loaded.Comments[1].TimestampUtc.ShouldBe(c2.TimestampUtc);
+ loaded.Comments[1].User.ShouldBe("bob");
+ loaded.Comments[1].Kind.ShouldBe("Confirm");
+ loaded.Comments[1].Text.ShouldBe("all clear");
+ }
+
+ /// An empty comment list round-trips as an empty list.
+ [Fact]
+ public async Task Empty_comments_round_trip()
+ {
+ var store = NewStore();
+ await store.SaveAsync(
+ Sample("alarm-empty-comments", comments: ImmutableList.Empty),
+ CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Comments.ShouldBeEmpty();
+ }
+
+ /// An empty comment list persists as the literal "[]" in the column.
+ [Fact]
+ public async Task Empty_comments_persist_as_bracket_pair()
+ {
+ var store = NewStore();
+ await store.SaveAsync(
+ Sample("alarm-empty-literal", comments: ImmutableList.Empty),
+ CancellationToken.None);
+
+ using var conn = _db.CreateConnection();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText =
+ "SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-empty-literal'";
+ var json = (string?)cmd.ExecuteScalar();
+
+ json.ShouldBe("[]");
+ }
+
+ /// An unknown enabled string loads back as Enabled (safe default).
+ [Fact]
+ public async Task Unknown_enum_strings_map_to_safe_defaults()
+ {
+ // Write raw rows carrying garbage enum strings to prove the load-side mappers coerce them.
+ using (var conn = _db.CreateConnection())
+ using (var cmd = conn.CreateCommand())
+ {
+ cmd.CommandText =
+ """
+ INSERT INTO alarm_condition_state
+ (scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
+ comments_json, updated_at_utc)
+ VALUES
+ ('garbage', 'nonsense', 'nonsense', 'nonsense', 'nonsense', '[]', '2026-06-10T12:00:00.0000000Z')
+ """;
+ cmd.ExecuteNonQuery();
+ }
+
+ var loaded = await NewStore().LoadAsync("garbage", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Enabled.ShouldBe(AlarmEnabledState.Enabled); // unknown → Enabled
+ loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged); // unknown → Unacknowledged
+ loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); // unknown → Unconfirmed
+ loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); // unknown → Unshelved
+ }
+
+ /// Saving the same id twice is an upsert — one row, latest values win.
+ [Fact]
+ public async Task SaveAsync_twice_upserts_latest()
+ {
+ var store = NewStore();
+ var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled };
+ var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled };
+
+ await store.SaveAsync(first, CancellationToken.None);
+ await store.SaveAsync(second, CancellationToken.None);
+
+ using (var conn = _db.CreateConnection())
+ using (var cmd = conn.CreateCommand())
+ {
+ cmd.CommandText =
+ "SELECT COUNT(*) FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-upsert'";
+ ((long)cmd.ExecuteScalar()!).ShouldBe(1);
+ }
+
+ var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None);
+ loaded.ShouldNotBeNull();
+ loaded.LastAckUser.ShouldBe("second-user");
+ loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled);
+ }
+
+ /// LoadAllAsync returns every saved state.
+ [Fact]
+ public async Task LoadAllAsync_returns_all_saved_states()
+ {
+ var store = NewStore();
+ await store.SaveAsync(Sample("a-1"), CancellationToken.None);
+ await store.SaveAsync(Sample("a-2"), CancellationToken.None);
+ await store.SaveAsync(Sample("a-3"), CancellationToken.None);
+
+ var all = await store.LoadAllAsync(CancellationToken.None);
+
+ all.Select(s => s.AlarmId).OrderBy(x => x).ShouldBe(new[] { "a-1", "a-2", "a-3" });
+ }
+
+ /// RemoveAsync deletes a row so a subsequent load returns null; others survive.
+ [Fact]
+ public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null()
+ {
+ var store = NewStore();
+ await store.SaveAsync(Sample("keep"), CancellationToken.None);
+ await store.SaveAsync(Sample("drop"), CancellationToken.None);
+
+ await store.RemoveAsync("drop", CancellationToken.None);
+
+ (await store.LoadAsync("drop", CancellationToken.None)).ShouldBeNull();
+ (await store.LoadAsync("keep", CancellationToken.None)).ShouldNotBeNull();
+ }
+
+ /// Removing an unknown id is a no-op (does not throw).
+ [Fact]
+ public async Task RemoveAsync_for_unknown_id_is_noop()
+ {
+ var store = NewStore();
+
+ await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
+ }
+}