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