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