feat(mesh-phase4): LocalDb alarm-condition-state store (replicated, pair-local)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-23 12:19:44 -04:00
parent 34b613d942
commit 4f4cdd05ec
7 changed files with 754 additions and 5 deletions
@@ -0,0 +1,82 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// Replaces the central config DB's <c>ScriptedAlarmState</c> 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
/// <see cref="AlarmSfSchema"/>, which this mirrors.
/// </para>
/// <para>
/// Deliberately depends on nothing but <see cref="SqliteConnection"/> so it can be applied
/// to any connection — the host's <c>LocalDbSetup.OnReady</c> in production, and a bare
/// connection in a test — without dragging the DI graph along.
/// </para>
/// <para>
/// <b>The primary key is the alarm identity, and Save is an unconditional upsert onto it.</b>
/// 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.
/// </para>
/// <para>
/// <b>ActiveState has no column</b> — it is re-derived from the live predicate on startup,
/// so persisting it would only risk operators seeing a stale Active on restart. Likewise
/// <c>LastActiveUtc</c> / <c>LastClearedUtc</c> re-derive alongside it and get no columns.
/// <c>LastTransitionUtc</c> is carried by <c>updated_at_utc</c> — 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.
/// </para>
/// </remarks>
public static class AlarmConditionStateSchema
{
/// <summary>Table holding one condition-state row per scripted-alarm identity.</summary>
public const string StateTable = "alarm_condition_state";
/// <summary>
/// Creates the state table if it does not already exist. Idempotent.
/// </summary>
/// <param name="connection">
/// An already-open connection. <c>ILocalDb.CreateConnection()</c> hands out open,
/// pragma-configured connections — do not call <c>Open()</c> on one.
/// </param>
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();
}
}
@@ -6,8 +6,9 @@ using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache and
/// alarm store-and-forward tables and opts them into replication.
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache,
/// alarm store-and-forward, and scripted-alarm condition-state tables and opts them into
/// replication.
/// </summary>
/// <remarks>
/// <para>
@@ -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);
@@ -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;
/// <summary>
/// Node-local <see cref="IAlarmStateStore"/> backed by the replicated
/// <see cref="AlarmConditionStateSchema.StateTable"/> table in the node's consolidated LocalDb.
/// Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
/// Shelving + the ack/confirm audit trail + operator comments.
/// </summary>
/// <remarks>
/// <para>
/// The LocalDb replacement for <c>EfAlarmConditionStateStore</c> (which persisted to the
/// central config DB's <c>ScriptedAlarmState</c> 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.
/// </para>
/// <para>
/// <b>The round-trip is byte-identical to the EF store</b> — 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:
/// </para>
/// <para>
/// <b>ActiveState is NOT persisted</b> — there is no column; on <see cref="LoadAsync"/> it is
/// restored as <see cref="AlarmActiveState.Inactive"/> and the engine re-derives it from the
/// live predicate. <b>LastTransitionUtc ↔ updated_at_utc</b>: no dedicated transition column,
/// so the last transition rides the row-write timestamp. <b>LastActiveUtc / LastClearedUtc</b>
/// have no columns and default to <c>null</c> on load. <see cref="AlarmConditionState.Comments"/>
/// serializes to/from <c>comments_json</c>; an empty list round-trips as <c>"[]"</c>.
/// </para>
/// <para>
/// <b>Save is a single unconditional PK upsert.</b> 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).
/// </para>
/// </remarks>
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<LocalDbAlarmConditionStateStore> _logger;
/// <summary>Initializes a new instance of the <see cref="LocalDbAlarmConditionStateStore"/> class.</summary>
/// <param name="db">
/// The node's local database. Its <see cref="AlarmConditionStateSchema.StateTable"/> table
/// must already exist and be registered for replication — the host does both in
/// <c>LocalDbSetup.OnReady</c>, before any consumer can resolve this store.
/// </param>
/// <param name="logger">The logger instance.</param>
public LocalDbAlarmConditionStateStore(ILocalDb db, ILogger<LocalDbAlarmConditionStateStore> logger)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<AlarmConditionState?> 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;
}
/// <inheritdoc />
public async Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
{
var rows = await _db.QueryAsync(
$"SELECT {SelectColumns} FROM alarm_condition_state",
MapRow,
parameters: null,
ct).ConfigureAwait(false);
return rows;
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
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<AlarmComment> 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<AlarmComment> DeserializeComments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
return dtos
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
.ToImmutableList();
}
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>comments_json</c>.</summary>
private sealed class CommentDto
{
/// <summary>When the comment was recorded (UTC).</summary>
public DateTime TimestampUtc { get; set; }
/// <summary>Identity of the actor that wrote the comment.</summary>
public string? User { get; set; }
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
public string? Kind { get; set; }
/// <summary>Operator-supplied or engine-generated comment text.</summary>
public string? Text { get; set; }
}
}
@@ -0,0 +1,46 @@
using Microsoft.Data.Sqlite;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
/// <summary>
/// Verifies <see cref="AlarmConditionStateSchema.Apply"/> creates its table and is idempotent —
/// the DDL depends on nothing but a <see cref="SqliteConnection"/>, so a bare in-memory
/// connection is a faithful harness (no <c>zb_hlc_next()</c> UDF is involved in <c>CREATE TABLE</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AlarmConditionStateSchemaTests
{
private static SqliteConnection OpenInMemory()
{
var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
return connection;
}
/// <summary>A single Apply creates the table.</summary>
[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);
}
/// <summary>Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS).</summary>
[Fact]
public void Apply_is_idempotent()
{
using var connection = OpenInMemory();
AlarmConditionStateSchema.Apply(connection);
Should.NotThrow(() => AlarmConditionStateSchema.Apply(connection));
}
}
@@ -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()
{
@@ -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]
@@ -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;
/// <summary>
/// Round-trip + upsert + lifecycle coverage for <see cref="LocalDbAlarmConditionStateStore"/>,
/// the LocalDb-backed <see cref="IAlarmStateStore"/> over the replicated
/// <c>alarm_condition_state</c> table.
/// </summary>
/// <remarks>
/// Ported verbatim from <c>EfAlarmConditionStateStoreTests</c> — the two stores share the
/// <see cref="IAlarmStateStore"/> contract, so the assertions transfer unchanged and prove
/// parity. Only store construction differs: this suite drives a real temp-file
/// <see cref="ILocalDb"/> (the library has no in-memory mode, and a bare
/// <see cref="SqliteConnection"/> would lack the <c>zb_hlc_next()</c> UDF the capture triggers
/// call), whereas the EF suite used an in-memory DbContext factory.
/// </remarks>
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<string, string?> { ["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<ILocalDb>();
}
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<LocalDbAlarmConditionStateStore>.Instance);
private static AlarmConditionState Sample(
string alarmId,
AlarmActiveState active = AlarmActiveState.Inactive,
ShelvingState? shelving = null,
ImmutableList<AlarmComment>? 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<AlarmComment>.Empty);
}
/// <summary>Save then load round-trips every persisted operator-state + audit field.</summary>
[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");
}
/// <summary>Loading an id that was never saved returns null.</summary>
[Fact]
public async Task LoadAsync_for_unknown_id_returns_null()
{
var store = NewStore();
var loaded = await store.LoadAsync("never-saved", CancellationToken.None);
loaded.ShouldBeNull();
}
/// <summary>Active is not persisted — a saved Active state loads back as Inactive.</summary>
[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);
}
/// <summary>LastActiveUtc / LastClearedUtc have no columns and default to null on load.</summary>
[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();
}
/// <summary>Unshelved round-trips with a null UnshelveAtUtc.</summary>
[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();
}
/// <summary>OneShot shelving round-trips.</summary>
[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();
}
/// <summary>The append-only comment trail round-trips all four fields per comment.</summary>
[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");
}
/// <summary>An empty comment list round-trips as an empty list.</summary>
[Fact]
public async Task Empty_comments_round_trip()
{
var store = NewStore();
await store.SaveAsync(
Sample("alarm-empty-comments", comments: ImmutableList<AlarmComment>.Empty),
CancellationToken.None);
var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None);
loaded.ShouldNotBeNull();
loaded.Comments.ShouldBeEmpty();
}
/// <summary>An empty comment list persists as the literal <c>"[]"</c> in the column.</summary>
[Fact]
public async Task Empty_comments_persist_as_bracket_pair()
{
var store = NewStore();
await store.SaveAsync(
Sample("alarm-empty-literal", comments: ImmutableList<AlarmComment>.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("[]");
}
/// <summary>An unknown enabled string loads back as Enabled (safe default).</summary>
[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
}
/// <summary>Saving the same id twice is an upsert — one row, latest values win.</summary>
[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);
}
/// <summary>LoadAllAsync returns every saved state.</summary>
[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" });
}
/// <summary>RemoveAsync deletes a row so a subsequent load returns null; others survive.</summary>
[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();
}
/// <summary>Removing an unknown id is a no-op (does not throw).</summary>
[Fact]
public async Task RemoveAsync_for_unknown_id_is_noop()
{
var store = NewStore();
await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
}
}