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