5a878b78d4
Add missing <summary>/<param>/<returns>/<typeparam> tags and switch interface implementations to <inheritdoc/> across 106 files; strip project bookkeeping identifiers (Task NN, #05-TNN, PLAN-04, StoreAndForward-0NN) from shipped code comments while preserving the descriptive rationale. Comment-only: zero code-logic lines changed; solution builds 0/0. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
980 lines
47 KiB
C#
980 lines
47 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
|
|
|
/// <summary>
|
|
/// Direct SQLite persistence for site-local deployment state.
|
|
/// Stores deployed instance configurations (as JSON) and static attribute overrides.
|
|
/// This is NOT EF Core — uses Microsoft.Data.Sqlite directly for lightweight site storage.
|
|
/// </summary>
|
|
public class SiteStorageService
|
|
{
|
|
private readonly string _connectionString;
|
|
private readonly ILogger<SiteStorageService> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the SiteStorageService with the specified SQLite connection string and logger.
|
|
/// </summary>
|
|
/// <param name="connectionString">SQLite connection string for the site database.</param>
|
|
/// <param name="logger">Logger instance for diagnostic messages.</param>
|
|
public SiteStorageService(string connectionString, ILogger<SiteStorageService> logger)
|
|
{
|
|
// Normalize the connection string and pin a busy-timeout floor (S8). WAL lets a reader
|
|
// and a writer proceed concurrently, but two writers still contend; Microsoft.Data.Sqlite
|
|
// drives its busy handler off the command timeout, so a floor of 5 s means a briefly
|
|
// busy database is waited-on rather than failing fast with SQLITE_BUSY. Only raise a
|
|
// caller-supplied value that is lower than the floor (the library default of 30 s stays).
|
|
var builder = new SqliteConnectionStringBuilder(connectionString);
|
|
if (builder.DefaultTimeout < BusyTimeoutFloorSeconds)
|
|
builder.DefaultTimeout = BusyTimeoutFloorSeconds;
|
|
_connectionString = builder.ToString();
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>Busy-timeout floor in seconds applied to the site connection string (S8).</summary>
|
|
private const int BusyTimeoutFloorSeconds = 5;
|
|
|
|
/// <summary>
|
|
/// Creates a new (unopened) SQLite connection against the site database.
|
|
/// Exposed so site-local repositories can open their own connections without
|
|
/// reaching into private state via reflection. The caller owns
|
|
/// the connection and is responsible for opening and disposing it.
|
|
/// <para>
|
|
/// The database runs in WAL journal mode (set once in <see cref="InitializeAsync"/>) with a
|
|
/// busy-timeout floor (see the constructor), so connections handed out here inherit
|
|
/// concurrent-reader/writer behavior and wait out a briefly-busy database instead of
|
|
/// failing with SQLITE_BUSY.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <returns>A new, unopened <see cref="SqliteConnection"/> against the site database.</returns>
|
|
public SqliteConnection CreateConnection() => new(_connectionString);
|
|
|
|
/// <summary>
|
|
/// Creates the SQLite tables if they do not exist.
|
|
/// Called once on site startup.
|
|
/// </summary>
|
|
/// <returns>A task that completes when all tables have been created or verified.</returns>
|
|
public async Task InitializeAsync()
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
// Switch to WAL journal mode (S8). WAL is persistent (database-level, survives across
|
|
// connections) so this one-time set is enough. It lets readers and a writer proceed
|
|
// concurrently instead of every access serializing on the rollback journal. A
|
|
// :memory: database or a filesystem that cannot support WAL falls back to its prior
|
|
// mode — we log the result rather than throwing.
|
|
await using (var walCommand = connection.CreateCommand())
|
|
{
|
|
walCommand.CommandText = "PRAGMA journal_mode=WAL;";
|
|
var resultingMode = (await walCommand.ExecuteScalarAsync()) as string ?? "unknown";
|
|
if (!string.Equals(resultingMode, "wal", StringComparison.OrdinalIgnoreCase))
|
|
_logger.LogWarning(
|
|
"Site SQLite could not enable WAL journal mode (got '{Mode}') — concurrent access may serialize",
|
|
resultingMode);
|
|
}
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
CREATE TABLE IF NOT EXISTS deployed_configurations (
|
|
instance_unique_name TEXT PRIMARY KEY,
|
|
config_json TEXT NOT NULL,
|
|
deployment_id TEXT NOT NULL,
|
|
revision_hash TEXT NOT NULL,
|
|
is_enabled INTEGER NOT NULL DEFAULT 1,
|
|
deployed_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS static_attribute_overrides (
|
|
instance_unique_name TEXT NOT NULL,
|
|
attribute_name TEXT NOT NULL,
|
|
override_value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (instance_unique_name, attribute_name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS shared_scripts (
|
|
name TEXT PRIMARY KEY,
|
|
code TEXT NOT NULL,
|
|
parameter_definitions TEXT,
|
|
return_definition TEXT,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS external_systems (
|
|
name TEXT PRIMARY KEY,
|
|
endpoint_url TEXT NOT NULL,
|
|
auth_type TEXT NOT NULL,
|
|
auth_configuration TEXT,
|
|
method_definitions TEXT,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS database_connections (
|
|
name TEXT PRIMARY KEY,
|
|
connection_string TEXT NOT NULL,
|
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
retry_delay_ms INTEGER NOT NULL DEFAULT 1000,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS notification_lists (
|
|
name TEXT PRIMARY KEY,
|
|
recipient_emails TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS data_connection_definitions (
|
|
name TEXT PRIMARY KEY,
|
|
protocol TEXT NOT NULL,
|
|
configuration TEXT,
|
|
backup_configuration TEXT,
|
|
failover_retry_count INTEGER NOT NULL DEFAULT 3,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS smtp_configurations (
|
|
name TEXT PRIMARY KEY,
|
|
server TEXT NOT NULL,
|
|
port INTEGER NOT NULL,
|
|
auth_mode TEXT NOT NULL,
|
|
from_address TEXT NOT NULL,
|
|
username TEXT,
|
|
password TEXT,
|
|
oauth_config TEXT,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS native_alarm_state (
|
|
instance_unique_name TEXT NOT NULL,
|
|
source_canonical_name TEXT NOT NULL,
|
|
source_reference TEXT NOT NULL,
|
|
condition_json TEXT NOT NULL,
|
|
last_transition_at TEXT NOT NULL,
|
|
metadata_json TEXT,
|
|
PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference)
|
|
);
|
|
";
|
|
await command.ExecuteNonQueryAsync();
|
|
|
|
// Schema migrations — add columns that may not exist on older databases
|
|
await MigrateSchemaAsync(connection);
|
|
|
|
_logger.LogInformation("Site SQLite storage initialized at {ConnectionString}", _connectionString);
|
|
}
|
|
|
|
private async Task MigrateSchemaAsync(SqliteConnection connection)
|
|
{
|
|
// Add backup_configuration and failover_retry_count to data_connection_definitions
|
|
// (added in primary/backup data connections feature)
|
|
await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT");
|
|
await TryAddColumnAsync(connection, "data_connection_definitions", "failover_retry_count", "INTEGER NOT NULL DEFAULT 3");
|
|
|
|
// Native-alarm display metadata (UA4) — restored on rehydration so a persisted condition
|
|
// renders fully (type/category/message/values) before the first source snapshot arrives.
|
|
await TryAddColumnAsync(connection, "native_alarm_state", "metadata_json", "TEXT");
|
|
|
|
// Per-external-system call timeout (ExternalSystemGateway Timeout) — carried through the
|
|
// artifact pipeline so site-side calls honor it; 0 = use the site default.
|
|
await TryAddColumnAsync(connection, "external_systems", "timeout_seconds", "INTEGER NOT NULL DEFAULT 0");
|
|
}
|
|
|
|
private async Task TryAddColumnAsync(SqliteConnection connection, string table, string column, string type)
|
|
{
|
|
try
|
|
{
|
|
await using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
|
|
await cmd.ExecuteNonQueryAsync();
|
|
_logger.LogInformation("Migrated: added column {Column} to {Table}", column, table);
|
|
}
|
|
catch (SqliteException ex) when (ex.Message.Contains("duplicate column"))
|
|
{
|
|
// Column already exists — no action needed
|
|
}
|
|
}
|
|
|
|
// ── Deployed Configuration CRUD ──
|
|
|
|
/// <summary>
|
|
/// Returns all deployed instance configurations from SQLite.
|
|
/// </summary>
|
|
/// <returns>A task that resolves to the list of all deployed instance configurations.</returns>
|
|
public async Task<List<DeployedInstance>> GetAllDeployedConfigsAsync()
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
SELECT instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at
|
|
FROM deployed_configurations";
|
|
|
|
var results = new List<DeployedInstance>();
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync())
|
|
{
|
|
results.Add(new DeployedInstance
|
|
{
|
|
InstanceUniqueName = reader.GetString(0),
|
|
ConfigJson = reader.GetString(1),
|
|
DeploymentId = reader.GetString(2),
|
|
RevisionHash = reader.GetString(3),
|
|
IsEnabled = reader.GetInt64(4) != 0,
|
|
DeployedAt = reader.GetString(5)
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores or updates a deployed instance configuration. Uses UPSERT semantics.
|
|
/// </summary>
|
|
/// <param name="instanceName">The unique name of the instance.</param>
|
|
/// <param name="configJson">The deployed configuration as JSON.</param>
|
|
/// <param name="deploymentId">The unique deployment identifier.</param>
|
|
/// <param name="revisionHash">The configuration revision hash for staleness detection.</param>
|
|
/// <param name="isEnabled">Whether the instance is enabled.</param>
|
|
/// <returns>A task that completes when the configuration has been stored or updated.</returns>
|
|
public async Task StoreDeployedConfigAsync(
|
|
string instanceName,
|
|
string configJson,
|
|
string deploymentId,
|
|
string revisionHash,
|
|
bool isEnabled)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
INSERT INTO deployed_configurations (instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
|
|
VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
|
|
ON CONFLICT(instance_unique_name) DO UPDATE SET
|
|
config_json = excluded.config_json,
|
|
deployment_id = excluded.deployment_id,
|
|
revision_hash = excluded.revision_hash,
|
|
is_enabled = excluded.is_enabled,
|
|
deployed_at = excluded.deployed_at";
|
|
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
command.Parameters.AddWithValue("@json", configJson);
|
|
command.Parameters.AddWithValue("@depId", deploymentId);
|
|
command.Parameters.AddWithValue("@hash", revisionHash);
|
|
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
|
|
command.Parameters.AddWithValue("@deployedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
_logger.LogDebug("Stored deployed config for {Instance}, deploymentId={DeploymentId}", instanceName, deploymentId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores a deployed instance configuration only if the incoming write is strictly
|
|
/// newer than any already-stored row for the same instance. If no row exists the
|
|
/// write is always performed (insert path). Uses SQLite's conditional
|
|
/// <c>ON CONFLICT … WHERE excluded.deployed_at > deployed_configurations.deployed_at</c>
|
|
/// clause so the guard is atomic with no application-level read-modify-write.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is the standby-node write path for replicated configs. The active-node
|
|
/// apply path (<see cref="StoreDeployedConfigAsync"/>) remains unguarded and always
|
|
/// overwrites, because the active node's write is always authoritative.
|
|
/// <para>
|
|
/// <paramref name="deployedAtOverride"/> is exposed for testing so that the exact
|
|
/// <c>deployed_at</c> value can be controlled without sleeping between calls.
|
|
/// Production callers omit it and receive the default <see cref="DateTimeOffset.UtcNow"/>
|
|
/// semantics, identical to <see cref="StoreDeployedConfigAsync"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="instanceName">The unique name of the instance.</param>
|
|
/// <param name="configJson">The deployed configuration as JSON.</param>
|
|
/// <param name="deploymentId">The unique deployment identifier.</param>
|
|
/// <param name="revisionHash">The configuration revision hash for staleness detection.</param>
|
|
/// <param name="isEnabled">Whether the instance is enabled.</param>
|
|
/// <param name="deployedAtOverride">
|
|
/// Optional explicit <c>deployed_at</c> timestamp. Defaults to
|
|
/// <see cref="DateTimeOffset.UtcNow"/> when <see langword="null"/>.
|
|
/// </param>
|
|
/// <returns>A task that completes when the conditional upsert has been executed.</returns>
|
|
public async Task StoreDeployedConfigIfNewerAsync(
|
|
string instanceName,
|
|
string configJson,
|
|
string deploymentId,
|
|
string revisionHash,
|
|
bool isEnabled,
|
|
DateTimeOffset? deployedAtOverride = null)
|
|
{
|
|
var deployedAt = (deployedAtOverride ?? DateTimeOffset.UtcNow).ToString("O");
|
|
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
INSERT INTO deployed_configurations
|
|
(instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
|
|
VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
|
|
ON CONFLICT(instance_unique_name) DO UPDATE SET
|
|
config_json = excluded.config_json,
|
|
deployment_id = excluded.deployment_id,
|
|
revision_hash = excluded.revision_hash,
|
|
is_enabled = excluded.is_enabled,
|
|
deployed_at = excluded.deployed_at
|
|
WHERE excluded.deployed_at > deployed_configurations.deployed_at";
|
|
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
command.Parameters.AddWithValue("@json", configJson);
|
|
command.Parameters.AddWithValue("@depId", deploymentId);
|
|
command.Parameters.AddWithValue("@hash", revisionHash);
|
|
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
|
|
command.Parameters.AddWithValue("@deployedAt", deployedAt);
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
_logger.LogDebug("StoreDeployedConfigIfNewer for {Instance}, deploymentId={DeploymentId}", instanceName, deploymentId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a deployed instance configuration and its static overrides.
|
|
/// </summary>
|
|
/// <param name="instanceName">The unique name of the instance to remove.</param>
|
|
/// <returns>A task that completes when the configuration and its overrides have been removed.</returns>
|
|
public async Task RemoveDeployedConfigAsync(string instanceName)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var transaction = await connection.BeginTransactionAsync();
|
|
|
|
await using (var cmd = connection.CreateCommand())
|
|
{
|
|
cmd.Transaction = (SqliteTransaction)transaction;
|
|
cmd.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
|
|
cmd.Parameters.AddWithValue("@name", instanceName);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
await using (var cmd = connection.CreateCommand())
|
|
{
|
|
cmd.Transaction = (SqliteTransaction)transaction;
|
|
cmd.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
|
|
cmd.Parameters.AddWithValue("@name", instanceName);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
await using (var cmd = connection.CreateCommand())
|
|
{
|
|
cmd.Transaction = (SqliteTransaction)transaction;
|
|
cmd.CommandText = "DELETE FROM deployed_configurations WHERE instance_unique_name = @name";
|
|
cmd.Parameters.AddWithValue("@name", instanceName);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
await transaction.CommitAsync();
|
|
_logger.LogInformation("Removed deployed config and overrides for {Instance}", instanceName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the enabled/disabled state of a deployed instance.
|
|
/// </summary>
|
|
/// <param name="instanceName">The unique name of the instance.</param>
|
|
/// <param name="isEnabled">Whether the instance should be enabled.</param>
|
|
/// <returns>A task that completes when the enabled flag has been updated.</returns>
|
|
public async Task SetInstanceEnabledAsync(string instanceName, bool isEnabled)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
UPDATE deployed_configurations
|
|
SET is_enabled = @enabled
|
|
WHERE instance_unique_name = @name";
|
|
|
|
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
|
|
var rows = await command.ExecuteNonQueryAsync();
|
|
if (rows == 0)
|
|
{
|
|
_logger.LogWarning("SetInstanceEnabled: instance {Instance} not found", instanceName);
|
|
}
|
|
}
|
|
|
|
// ── Static Attribute Override CRUD ──
|
|
|
|
/// <summary>
|
|
/// Returns all static attribute overrides for an instance.
|
|
/// </summary>
|
|
/// <param name="instanceName">The unique name of the instance.</param>
|
|
/// <returns>A task that resolves to a dictionary mapping attribute names to their override values.</returns>
|
|
public async Task<Dictionary<string, string>> GetStaticOverridesAsync(string instanceName)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
SELECT attribute_name, override_value
|
|
FROM static_attribute_overrides
|
|
WHERE instance_unique_name = @name";
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
|
|
var results = new Dictionary<string, string>();
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync())
|
|
{
|
|
results[reader.GetString(0)] = reader.GetString(1);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets or updates a single static attribute override for an instance.
|
|
/// </summary>
|
|
/// <param name="instanceName">The unique name of the instance.</param>
|
|
/// <param name="attributeName">The name of the attribute to override.</param>
|
|
/// <param name="value">The override value for the attribute.</param>
|
|
/// <returns>A task that completes when the override has been saved.</returns>
|
|
public async Task SetStaticOverrideAsync(string instanceName, string attributeName, string value)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
INSERT INTO static_attribute_overrides (instance_unique_name, attribute_name, override_value, updated_at)
|
|
VALUES (@name, @attr, @val, @updatedAt)
|
|
ON CONFLICT(instance_unique_name, attribute_name) DO UPDATE SET
|
|
override_value = excluded.override_value,
|
|
updated_at = excluded.updated_at";
|
|
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
command.Parameters.AddWithValue("@attr", attributeName);
|
|
command.Parameters.AddWithValue("@val", value);
|
|
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all static attribute overrides for an instance.
|
|
/// Called on redeployment to reset overrides.
|
|
/// </summary>
|
|
/// <param name="instanceName">The unique name of the instance.</param>
|
|
/// <returns>A task that completes when all overrides for the instance have been deleted.</returns>
|
|
public async Task ClearStaticOverridesAsync(string instanceName)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
_logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
|
|
}
|
|
|
|
// ── Native Alarm State store (read-only mirror of source A&C conditions) ──
|
|
|
|
/// <summary>
|
|
/// Inserts or updates a single mirrored native alarm condition, keyed by
|
|
/// (instance, source canonical name, source reference). Newer transitions overwrite older ones.
|
|
/// </summary>
|
|
/// <param name="instanceName">Unique name of the instance owning the alarm.</param>
|
|
/// <param name="sourceCanonicalName">Canonical name of the source binding (connection/alarm source).</param>
|
|
/// <param name="sourceReference">Source-system reference key identifying the specific alarm condition.</param>
|
|
/// <param name="conditionJson">Serialized <see cref="AlarmConditionState"/> JSON snapshot.</param>
|
|
/// <param name="lastTransitionAt">Timestamp of the most recent condition transition.</param>
|
|
/// <param name="metadataJson">Optional serialized metadata JSON for the alarm condition; <c>null</c> when not supplied.</param>
|
|
/// <returns>A task that completes when the alarm condition has been inserted or updated.</returns>
|
|
public async Task UpsertNativeAlarmAsync(
|
|
string instanceName, string sourceCanonicalName, string sourceReference,
|
|
string conditionJson, DateTimeOffset lastTransitionAt, string? metadataJson = null)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
INSERT INTO native_alarm_state
|
|
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
|
|
VALUES (@name, @source, @ref, @json, @at, @meta)
|
|
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
|
|
condition_json = excluded.condition_json,
|
|
last_transition_at = excluded.last_transition_at,
|
|
metadata_json = excluded.metadata_json";
|
|
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
command.Parameters.AddWithValue("@source", sourceCanonicalName);
|
|
command.Parameters.AddWithValue("@ref", sourceReference);
|
|
command.Parameters.AddWithValue("@json", conditionJson);
|
|
command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O"));
|
|
command.Parameters.AddWithValue("@meta", (object?)metadataJson ?? DBNull.Value);
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Batch counterpart of <see cref="UpsertNativeAlarmAsync"/> (P4): upserts a set of mirrored
|
|
/// native alarm conditions for one instance/source in a SINGLE connection + transaction with
|
|
/// one prepared command re-bound per row. Used by the NativeAlarmActor's coalesced flush so an
|
|
/// alarm storm collapses into one batched write instead of a write per transition.
|
|
/// </summary>
|
|
/// <param name="instanceName">Unique name of the instance owning the alarms.</param>
|
|
/// <param name="sourceCanonicalName">Canonical name of the source binding.</param>
|
|
/// <param name="rows">The conditions to upsert (source reference, serialized condition, transition time).</param>
|
|
/// <returns>A task that completes when all rows have been committed.</returns>
|
|
public async Task UpsertNativeAlarmsAsync(
|
|
string instanceName, string sourceCanonicalName,
|
|
IReadOnlyList<(string SourceReference, string ConditionJson, string? MetadataJson, DateTimeOffset LastTransitionAt)> rows)
|
|
{
|
|
if (rows.Count == 0)
|
|
return;
|
|
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.Transaction = transaction;
|
|
command.CommandText = @"
|
|
INSERT INTO native_alarm_state
|
|
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at, metadata_json)
|
|
VALUES (@name, @source, @ref, @json, @at, @meta)
|
|
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
|
|
condition_json = excluded.condition_json,
|
|
last_transition_at = excluded.last_transition_at,
|
|
metadata_json = excluded.metadata_json";
|
|
|
|
var pName = command.Parameters.Add("@name", SqliteType.Text);
|
|
var pSource = command.Parameters.Add("@source", SqliteType.Text);
|
|
var pRef = command.Parameters.Add("@ref", SqliteType.Text);
|
|
var pJson = command.Parameters.Add("@json", SqliteType.Text);
|
|
var pAt = command.Parameters.Add("@at", SqliteType.Text);
|
|
var pMeta = command.Parameters.Add("@meta", SqliteType.Text);
|
|
pName.Value = instanceName;
|
|
pSource.Value = sourceCanonicalName;
|
|
|
|
foreach (var row in rows)
|
|
{
|
|
pRef.Value = row.SourceReference;
|
|
pJson.Value = row.ConditionJson;
|
|
pAt.Value = row.LastTransitionAt.ToString("O");
|
|
pMeta.Value = (object?)row.MetadataJson ?? DBNull.Value;
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
await transaction.CommitAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention).
|
|
/// </summary>
|
|
/// <param name="instanceName">Unique name of the instance owning the alarm.</param>
|
|
/// <param name="sourceCanonicalName">Canonical name of the source binding.</param>
|
|
/// <param name="sourceReference">Source-system reference key identifying the alarm condition to remove.</param>
|
|
/// <returns>A task that completes when the alarm condition row has been deleted.</returns>
|
|
public async Task DeleteNativeAlarmAsync(string instanceName, string sourceCanonicalName, string sourceReference)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
DELETE FROM native_alarm_state
|
|
WHERE instance_unique_name = @name
|
|
AND source_canonical_name = @source
|
|
AND source_reference = @ref";
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
command.Parameters.AddWithValue("@source", sourceCanonicalName);
|
|
command.Parameters.AddWithValue("@ref", sourceReference);
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all mirrored native alarm conditions for an instance's source binding,
|
|
/// used to rehydrate a NativeAlarmActor on (re)start.
|
|
/// </summary>
|
|
/// <param name="instanceName">Unique name of the instance to query.</param>
|
|
/// <param name="sourceCanonicalName">Canonical name of the source binding to query.</param>
|
|
/// <returns>A task that resolves to the list of stored native alarm condition rows for the binding.</returns>
|
|
public async Task<IReadOnlyList<NativeAlarmRow>> GetNativeAlarmsAsync(string instanceName, string sourceCanonicalName)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
SELECT source_reference, condition_json, last_transition_at, metadata_json
|
|
FROM native_alarm_state
|
|
WHERE instance_unique_name = @name AND source_canonical_name = @source";
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
command.Parameters.AddWithValue("@source", sourceCanonicalName);
|
|
|
|
var results = new List<NativeAlarmRow>();
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync())
|
|
{
|
|
results.Add(new NativeAlarmRow(
|
|
reader.GetString(0),
|
|
reader.GetString(1),
|
|
DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind),
|
|
reader.IsDBNull(3) ? null : reader.GetString(3)));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all mirrored native alarm conditions for an instance. Called on redeployment / stop.
|
|
/// </summary>
|
|
/// <param name="instanceName">Unique name of the instance whose native alarm state should be cleared.</param>
|
|
/// <returns>A task that completes when all native alarm rows for the instance have been deleted.</returns>
|
|
public async Task ClearNativeAlarmsForInstanceAsync(string instanceName)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
|
|
command.Parameters.AddWithValue("@name", instanceName);
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
_logger.LogDebug("Cleared native alarm state for {Instance}", instanceName);
|
|
}
|
|
|
|
// ── Shared Script CRUD ──
|
|
|
|
/// <summary>
|
|
/// Stores or updates a shared script. Uses UPSERT semantics.
|
|
/// </summary>
|
|
/// <param name="name">The name of the shared script.</param>
|
|
/// <param name="code">The script code.</param>
|
|
/// <param name="parameterDefs">JSON representation of parameter definitions, if any.</param>
|
|
/// <param name="returnDef">JSON representation of the return type definition, if any.</param>
|
|
/// <returns>A task that completes when the shared script has been stored or updated.</returns>
|
|
public async Task StoreSharedScriptAsync(string name, string code, string? parameterDefs, string? returnDef)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
INSERT INTO shared_scripts (name, code, parameter_definitions, return_definition, updated_at)
|
|
VALUES (@name, @code, @paramDefs, @returnDef, @updatedAt)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
code = excluded.code,
|
|
parameter_definitions = excluded.parameter_definitions,
|
|
return_definition = excluded.return_definition,
|
|
updated_at = excluded.updated_at";
|
|
|
|
command.Parameters.AddWithValue("@name", name);
|
|
command.Parameters.AddWithValue("@code", code);
|
|
command.Parameters.AddWithValue("@paramDefs", (object?)parameterDefs ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@returnDef", (object?)returnDef ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
_logger.LogDebug("Stored shared script '{Name}'", name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all stored shared scripts.
|
|
/// </summary>
|
|
/// <returns>A task that resolves to the list of all stored shared scripts.</returns>
|
|
public async Task<List<StoredSharedScript>> GetAllSharedScriptsAsync()
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "SELECT name, code, parameter_definitions, return_definition FROM shared_scripts";
|
|
|
|
var results = new List<StoredSharedScript>();
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync())
|
|
{
|
|
results.Add(new StoredSharedScript
|
|
{
|
|
Name = reader.GetString(0),
|
|
Code = reader.GetString(1),
|
|
ParameterDefinitions = reader.IsDBNull(2) ? null : reader.GetString(2),
|
|
ReturnDefinition = reader.IsDBNull(3) ? null : reader.GetString(3)
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ── External System CRUD ──
|
|
|
|
/// <summary>
|
|
/// Stores or updates an external system definition.
|
|
/// </summary>
|
|
/// <param name="name">The name of the external system.</param>
|
|
/// <param name="endpointUrl">The REST API endpoint URL.</param>
|
|
/// <param name="authType">The authentication type (e.g., 'ApiKey', 'BasicAuth').</param>
|
|
/// <param name="authConfig">Authentication configuration JSON, if applicable.</param>
|
|
/// <param name="methodDefs">JSON representation of available method definitions, if any.</param>
|
|
/// <param name="timeoutSeconds">Per-system call timeout in seconds (0 = use the site default).</param>
|
|
/// <returns>A task that completes when the external system definition has been stored or updated.</returns>
|
|
public async Task StoreExternalSystemAsync(
|
|
string name, string endpointUrl, string authType, string? authConfig, string? methodDefs,
|
|
int timeoutSeconds = 0)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
INSERT INTO external_systems (name, endpoint_url, auth_type, auth_configuration, method_definitions, timeout_seconds, updated_at)
|
|
VALUES (@name, @url, @authType, @authConfig, @methodDefs, @timeoutSeconds, @updatedAt)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
endpoint_url = excluded.endpoint_url,
|
|
auth_type = excluded.auth_type,
|
|
auth_configuration = excluded.auth_configuration,
|
|
method_definitions = excluded.method_definitions,
|
|
timeout_seconds = excluded.timeout_seconds,
|
|
updated_at = excluded.updated_at";
|
|
|
|
command.Parameters.AddWithValue("@name", name);
|
|
command.Parameters.AddWithValue("@url", endpointUrl);
|
|
command.Parameters.AddWithValue("@authType", authType);
|
|
command.Parameters.AddWithValue("@authConfig", (object?)authConfig ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@methodDefs", (object?)methodDefs ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@timeoutSeconds", timeoutSeconds);
|
|
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// ── Database Connection CRUD ──
|
|
|
|
/// <summary>
|
|
/// Stores or updates a database connection definition.
|
|
/// </summary>
|
|
/// <param name="name">The name of the database connection.</param>
|
|
/// <param name="connectionString">The database connection string.</param>
|
|
/// <param name="maxRetries">Maximum number of retry attempts.</param>
|
|
/// <param name="retryDelay">Delay between retry attempts.</param>
|
|
/// <returns>A task that completes when the database connection definition has been stored or updated.</returns>
|
|
public async Task StoreDatabaseConnectionAsync(
|
|
string name, string connectionString, int maxRetries, TimeSpan retryDelay)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
INSERT INTO database_connections (name, connection_string, max_retries, retry_delay_ms, updated_at)
|
|
VALUES (@name, @connStr, @maxRetries, @retryDelayMs, @updatedAt)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
connection_string = excluded.connection_string,
|
|
max_retries = excluded.max_retries,
|
|
retry_delay_ms = excluded.retry_delay_ms,
|
|
updated_at = excluded.updated_at";
|
|
|
|
command.Parameters.AddWithValue("@name", name);
|
|
command.Parameters.AddWithValue("@connStr", connectionString);
|
|
command.Parameters.AddWithValue("@maxRetries", maxRetries);
|
|
command.Parameters.AddWithValue("@retryDelayMs", (long)retryDelay.TotalMilliseconds);
|
|
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// ── Notification List CRUD ──
|
|
|
|
/// <summary>
|
|
/// Notification delivery is central-only.
|
|
/// Sites store-and-forward notifications to the central cluster and never deliver
|
|
/// over SMTP, so notification lists and SMTP configuration must never live on a
|
|
/// site. This purges every row from the site-local <c>notification_lists</c> and
|
|
/// <c>smtp_configurations</c> tables, clearing any rows a prior (now-corrected)
|
|
/// build may have shipped — most importantly the plaintext SMTP password. It is
|
|
/// idempotent and is invoked on every artifact apply / deploy so existing exposure
|
|
/// is cleared, not just future writes. The tables themselves are retained (the
|
|
/// schema is harmless once empty); only their contents are removed.
|
|
///
|
|
/// The site-side write paths (<c>StoreNotificationListAsync</c>/<c>StoreSmtpConfigurationAsync</c>)
|
|
/// and the <c>SiteNotificationRepository</c> were removed 2026-07-10 (arch-review 08 §1.3)
|
|
/// because notification config is central-only and must never be written to a site — this
|
|
/// purge is the sole remaining touchpoint, kept only to scrub DBs written by older builds;
|
|
/// do not reintroduce site-side notification writes.
|
|
/// </summary>
|
|
/// <returns>A task that completes when both tables have been emptied.</returns>
|
|
public async Task PurgeCentralOnlyNotificationConfigAsync()
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
DELETE FROM notification_lists;
|
|
DELETE FROM smtp_configurations;";
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// ── Data Connection Definition CRUD ──
|
|
|
|
/// <summary>
|
|
/// Stores or updates a data connection definition (OPC UA endpoint, etc.).
|
|
/// </summary>
|
|
/// <param name="name">The name of the data connection.</param>
|
|
/// <param name="protocol">The protocol type (e.g., 'OpcUa').</param>
|
|
/// <param name="configJson">Primary configuration as JSON.</param>
|
|
/// <param name="backupConfigJson">Backup configuration as JSON, if applicable.</param>
|
|
/// <param name="failoverRetryCount">Number of retries for failover attempts.</param>
|
|
/// <returns>A task that completes when the data connection definition has been stored or updated.</returns>
|
|
public async Task StoreDataConnectionDefinitionAsync(
|
|
string name, string protocol, string? configJson, string? backupConfigJson = null, int failoverRetryCount = 3)
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = @"
|
|
INSERT INTO data_connection_definitions (name, protocol, configuration, backup_configuration, failover_retry_count, updated_at)
|
|
VALUES (@name, @protocol, @config, @backupConfig, @failoverRetryCount, @updatedAt)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
protocol = excluded.protocol,
|
|
configuration = excluded.configuration,
|
|
backup_configuration = excluded.backup_configuration,
|
|
failover_retry_count = excluded.failover_retry_count,
|
|
updated_at = excluded.updated_at";
|
|
|
|
command.Parameters.AddWithValue("@name", name);
|
|
command.Parameters.AddWithValue("@protocol", protocol);
|
|
command.Parameters.AddWithValue("@config", (object?)configJson ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@backupConfig", (object?)backupConfigJson ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@failoverRetryCount", failoverRetryCount);
|
|
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
_logger.LogDebug("Stored data connection definition '{Name}' (protocol={Protocol})", name, protocol);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all stored data connection definitions.
|
|
/// </summary>
|
|
/// <returns>A task that resolves to the list of all stored data connection definitions.</returns>
|
|
public async Task<List<StoredDataConnectionDefinition>> GetAllDataConnectionDefinitionsAsync()
|
|
{
|
|
await using var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "SELECT name, protocol, configuration, backup_configuration, failover_retry_count FROM data_connection_definitions";
|
|
|
|
var results = new List<StoredDataConnectionDefinition>();
|
|
await using var reader = await command.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync())
|
|
{
|
|
results.Add(new StoredDataConnectionDefinition
|
|
{
|
|
Name = reader.GetString(0),
|
|
Protocol = reader.GetString(1),
|
|
ConfigurationJson = reader.IsDBNull(2) ? null : reader.GetString(2),
|
|
BackupConfigurationJson = reader.IsDBNull(3) ? null : reader.GetString(3),
|
|
FailoverRetryCount = reader.GetInt32(4)
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a deployed instance configuration as stored in SQLite.
|
|
/// </summary>
|
|
public class DeployedInstance
|
|
{
|
|
/// <summary>
|
|
/// The unique name of the instance.
|
|
/// </summary>
|
|
public string InstanceUniqueName { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// The deployed configuration as JSON.
|
|
/// </summary>
|
|
public string ConfigJson { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// The unique deployment identifier.
|
|
/// </summary>
|
|
public string DeploymentId { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// The configuration revision hash for staleness detection.
|
|
/// </summary>
|
|
public string RevisionHash { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// Whether the instance is enabled.
|
|
/// </summary>
|
|
public bool IsEnabled { get; init; }
|
|
/// <summary>
|
|
/// The timestamp when the configuration was deployed.
|
|
/// </summary>
|
|
public string DeployedAt { get; init; } = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a shared script stored locally in SQLite.
|
|
/// </summary>
|
|
public class StoredSharedScript
|
|
{
|
|
/// <summary>
|
|
/// The name of the shared script.
|
|
/// </summary>
|
|
public string Name { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// The script code.
|
|
/// </summary>
|
|
public string Code { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// JSON representation of parameter definitions, if any.
|
|
/// </summary>
|
|
public string? ParameterDefinitions { get; init; }
|
|
/// <summary>
|
|
/// JSON representation of the return type definition, if any.
|
|
/// </summary>
|
|
public string? ReturnDefinition { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a data connection definition stored locally in SQLite.
|
|
/// </summary>
|
|
public class StoredDataConnectionDefinition
|
|
{
|
|
/// <summary>
|
|
/// The name of the data connection.
|
|
/// </summary>
|
|
public string Name { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// The protocol type (e.g., 'OpcUa').
|
|
/// </summary>
|
|
public string Protocol { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// Primary configuration as JSON.
|
|
/// </summary>
|
|
public string? ConfigurationJson { get; init; }
|
|
/// <summary>
|
|
/// Backup configuration as JSON, if applicable.
|
|
/// </summary>
|
|
public string? BackupConfigurationJson { get; init; }
|
|
/// <summary>
|
|
/// Number of retries for failover attempts.
|
|
/// </summary>
|
|
public int FailoverRetryCount { get; init; } = 3;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table.
|
|
/// <paramref name="MetadataJson"/> carries the serialized display metadata (type/category/message/
|
|
/// values) restored on rehydration (UA4); null on rows written before the column existed.
|
|
/// </summary>
|
|
public record NativeAlarmRow(
|
|
string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null);
|