using System.Globalization;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
///
/// DDL for the Part 9 scripted-alarm condition state: one fixed-size row per alarm identity
/// holding the operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the
/// ack/confirm audit fields, and a separate append-only table holding each alarm's comment trail.
///
///
///
/// 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.
///
///
/// The comment trail is NOT on the condition row. It used to be serialized whole into
/// comments_json, so every transition rewrote — and replicated — the alarm's entire
/// history: O(n) per transition, O(n²) over the alarm's life (review R05:4). It now lives in
/// , one row per entry keyed by (scripted_alarm_id, seq),
/// where seq is the entry's ordinal in the alarm's trail. A transition inserts only the
/// entries it appended, so the condition row stays a fixed size and a transition's write is
/// O(1) however long the history is. comments_json is kept, always written as
/// "[]", so the condition table's shape — and with it the pair's replication digest
/// for that table — does not change and an old-shape row stays readable;
/// copies any inline trail across (the alarm's next
/// save clears the column).
///
///
/// Why the ordinal, and not a content hash, is the key. Both halves of a redundant pair
/// run the engine and both apply every operator command, each stamping the entry with its own
/// clock. A content-derived id would keep TWO rows for every logical entry once the pair
/// converged. Keyed by ordinal, the two nodes' copies of entry n share one primary key
/// and LWW collapses them to one row — the convergence the whole-row LWW gave before, now per
/// entry. Entries go in with INSERT OR IGNORE (), so an
/// entry already held — written locally, or arrived from the peer — is never rewritten. A
/// node that MISSED a peer command keeps its own colliding entry by appending it after the
/// stored trail, with a Warning (see the store's divergence handling).
///
///
public static class AlarmConditionStateSchema
{
/// Table holding one condition-state row per scripted-alarm identity.
public const string StateTable = "alarm_condition_state";
/// Append-only table holding one row per comment-trail entry, keyed by alarm id + ordinal.
public const string CommentsTable = "alarm_condition_comment";
/// What comments_json holds once the trail lives in .
public const string EmptyInlineComments = "[]";
///
/// The one append statement for , shared by the migration and the
/// store. OR IGNORE is what makes the trail append-only: an ordinal already held is
/// never rewritten. Parameters: @Id @Seq @Ts @User @Kind @Text.
///
public const string InsertCommentSql =
"""
INSERT OR IGNORE INTO alarm_condition_comment
(scripted_alarm_id, seq, timestamp_utc, user_name, kind, text)
VALUES (@Id, @Seq, @Ts, @User, @Kind, @Text)
""";
private static readonly JsonSerializerOptions InlineJsonOptions = new(JsonSerializerDefaults.Web);
///
/// Creates the state table and the comment table if they do 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 — it is the literal "[]" since the
// trail moved to alarm_condition_comment (an old-shape row may still carry an inline trail
// until the alarm's next save; MigrateInlineComments copies it without touching this row).
// 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
);
CREATE TABLE IF NOT EXISTS alarm_condition_comment (
scripted_alarm_id TEXT NOT NULL,
seq INTEGER NOT NULL,
timestamp_utc TEXT NOT NULL,
user_name TEXT NOT NULL,
kind TEXT NOT NULL,
text TEXT NOT NULL,
PRIMARY KEY (scripted_alarm_id, seq)
);
""";
cmd.ExecuteNonQuery();
}
/// What one run did.
/// Condition rows for which at least one inline entry was copied across.
///
/// Alarms whose inline trail is not valid JSON and was left where it is — the caller logs these,
/// so an unmigratable history is visible rather than skipped silently on every boot.
///
public sealed record InlineCommentMigrationResult(int RowsMoved, IReadOnlyList UnparseableAlarmIds);
///
/// Copies every condition row's inline comments_json trail into
/// . Idempotent; it never writes the condition row itself.
///
///
///
/// Run it AFTER the tables are registered for replication — it writes rows, and a
/// row written before the capture triggers exist never reaches the peer (the same
/// DDL → register → writes rule AlarmSfLegacyMigrator follows).
///
///
/// Copy, don't clear. Resetting comments_json here would be a full-row UPDATE
/// that re-stamps the condition row with a fresh HLC — during a pair's rolling upgrade
/// that stale-but-newly-stamped copy would beat a newer peer state (an ack made on the
/// already-upgraded node) under LWW (review m1). The column is left as it is: the store's
/// load merges an inline trail with the table by ordinal (the table wins), and the
/// engine's next save of that alarm writes "[]" with the alarm's real state.
///
///
/// Each entry lands at its ordinal in the inline array via ,
/// so a re-run — or a row whose trail was already (partly) copied — neither duplicates nor
/// reorders anything and writes nothing for an ordinal already held. Each row copies in its
/// own transaction, so a crash leaves it fully copied or untouched. A row whose inline JSON
/// does not parse is left exactly as it is (the pre-split store could not load it either)
/// and reported in rather
/// than failing host start.
///
///
/// An already-open connection from ILocalDb.CreateConnection().
/// What was copied, and which rows could not be read.
public static InlineCommentMigrationResult MigrateInlineComments(SqliteConnection connection)
{
ArgumentNullException.ThrowIfNull(connection);
var pending = new List<(string AlarmId, string Json)>();
using (var select = connection.CreateCommand())
{
select.CommandText =
"SELECT scripted_alarm_id, comments_json FROM alarm_condition_state WHERE comments_json <> @Empty";
select.Parameters.AddWithValue("@Empty", EmptyInlineComments);
using var reader = select.ExecuteReader();
while (reader.Read())
pending.Add((reader.GetString(0), reader.GetString(1)));
}
var migrated = 0;
var unparseable = new List();
foreach (var (alarmId, json) in pending)
{
IReadOnlyList entries;
try
{
entries = ParseInlineComments(json);
}
catch (JsonException)
{
unparseable.Add(alarmId);
continue;
}
var inserted = 0;
using var tx = connection.BeginTransaction();
for (var seq = 0; seq < entries.Count; seq++)
{
using var insert = connection.CreateCommand();
insert.Transaction = tx;
insert.CommandText = InsertCommentSql;
insert.Parameters.AddWithValue("@Id", alarmId);
insert.Parameters.AddWithValue("@Seq", seq);
insert.Parameters.AddWithValue("@Ts", FormatTimestamp(entries[seq].TimestampUtc));
insert.Parameters.AddWithValue("@User", entries[seq].User);
insert.Parameters.AddWithValue("@Kind", entries[seq].Kind);
insert.Parameters.AddWithValue("@Text", entries[seq].Text);
inserted += insert.ExecuteNonQuery();
}
tx.Commit();
if (inserted > 0)
migrated++;
}
return new InlineCommentMigrationResult(migrated, unparseable);
}
/// Formats a comment timestamp for (round-trip "O" text).
/// The timestamp.
/// The round-trip text form.
public static string FormatTimestamp(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
/// Parses a timestamp, preserving its .
/// The round-trip text form.
/// The timestamp.
public static DateTime ParseTimestamp(string value) =>
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
///
/// Parses a legacy inline comments_json trail exactly as the pre-split store did
/// (; null strings become empty).
///
/// The column value.
/// The entries in trail order; empty for a blank or "[]" column.
/// The column is not a valid trail.
public static IReadOnlyList ParseInlineComments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
var dtos = JsonSerializer.Deserialize>(json, InlineJsonOptions);
if (dtos is null || dtos.Count == 0) return [];
return dtos
.Select(d => new InlineComment(
d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
.ToList();
}
/// One comment-trail entry as stored — the persistence-side mirror of the engine's AlarmComment.
/// When the action happened.
/// Identity of the actor.
/// Classification (Acknowledge, Confirm, AddComment, …).
/// Operator-supplied or engine-generated text.
public sealed record InlineComment(DateTime TimestampUtc, string User, string Kind, string Text);
/// The pre-split on-disk shape of one entry in comments_json.
private sealed class InlineCommentDto
{
/// When the comment was recorded.
public DateTime TimestampUtc { get; set; }
/// Identity of the actor.
public string? User { get; set; }
/// Classification of the entry.
public string? Kind { get; set; }
/// Entry text.
public string? Text { get; set; }
}
}