79ce51612e
Task 12 + Task 13. No production behaviour change in either. Task 12: DeploymentManagerActor.HandleDeployArtifacts already purges notification_lists and smtp_configurations on every artifact apply, but nothing pinned the actor's CALL to it — ArtifactStorageTests covers the storage method only. Task 15 deletes SiteReplicationActor's copy, making this the sole remaining call site, and Task 16 edits this actor's wiring; dropping the call would leave plaintext SMTP passwords on disk with every suite still green. Verified red-first by commenting the call out: the pin fails with that message. Task 13: StoreDeployedConfigIfNewerAsync STAYS. Re-verified both callers — SiteReplicationActor:375 (dies at Task 15) and SiteReconciliationActor:166 (survives). Reconciliation is a per-node startup self-heal against central whose fetch races real deploys, so the deployed_at guard still does real work there. Doc comment rewritten to say so, and to warn against porting the guard onto the replication path where it would fight the HLC rather than help it. Also corrected a stale 'guarded standby write' section header in the tests. Re-ran the Task 13 step 2 scope check: ConfigFetchRetryCount's only production reader remains SiteReplicationActor:157, so its option + validator rule stay until Task 17, after Task 15 deletes the actor. Verified: build 0 warnings; SiteRuntime 533, Host 329, StoreAndForward 153, LocalDb integration 16 — all pass. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
853 lines
41 KiB
C#
853 lines
41 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.LocalDb;
|
|
|
|
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 ILocalDb _localDb;
|
|
private readonly ILogger<SiteStorageService> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes the service over the consolidated site database.
|
|
/// </summary>
|
|
/// <param name="localDb">
|
|
/// The consolidated site database. Every connection it hands out is already open and carries
|
|
/// the per-connection pragmas (including the busy timeout that this class used to pin itself)
|
|
/// plus the <c>zb_hlc_next()</c> UDF that the config tables' capture triggers call — which is
|
|
/// exactly why the service no longer builds its own connection string. A raw connection would
|
|
/// lack the UDF and every write to a replicated table would fail closed.
|
|
/// </param>
|
|
/// <param name="logger">Logger instance for diagnostic messages.</param>
|
|
public SiteStorageService(ILocalDb localDb, ILogger<SiteStorageService> logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(localDb);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
_localDb = localDb;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns an <b>already-open</b> connection against the site database.
|
|
/// Exposed so site-local repositories can get their own connections without reaching
|
|
/// into private state via reflection. The caller owns the connection and is
|
|
/// responsible for disposing it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Contract change:</b> this used to return an <i>unopened</i> connection that the
|
|
/// caller opened. LocalDb hands out connections already open, pragma-configured, and
|
|
/// carrying the <c>zb_hlc_next()</c> UDF — calling <c>Open</c>/<c>OpenAsync</c> on one
|
|
/// throws. Callers must not open it.
|
|
/// </remarks>
|
|
/// <returns>An open <see cref="SqliteConnection"/> against the site database.</returns>
|
|
public SqliteConnection CreateConnection() => _localDb.CreateConnection();
|
|
|
|
/// <summary>
|
|
/// Convenience alias used internally, mirroring the other LocalDb-backed stores.
|
|
/// </summary>
|
|
private SqliteConnection OpenConnection() => _localDb.CreateConnection();
|
|
|
|
/// <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 Task InitializeAsync()
|
|
{
|
|
// No journal-mode pragma and no busy-timeout normalization here any more:
|
|
// LocalDb owns the file and sets WAL plus the per-connection pragmas on every
|
|
// connection it hands out.
|
|
using var connection = OpenConnection();
|
|
|
|
// The DDL itself lives in SiteStorageSchema so the Host can apply it to a
|
|
// LocalDb-managed connection before RegisterReplicated installs the capture
|
|
// triggers. The service still calls it, so a directly-constructed service
|
|
// (tests, tooling) remains self-sufficient.
|
|
SiteStorageSchema.Apply(connection);
|
|
|
|
_logger.LogInformation("Site SQLite storage initialized");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ── 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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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>
|
|
/// <b>This is the reconciliation write path.</b> <c>SiteReconciliationActor</c> runs a
|
|
/// per-node startup self-heal against central: it asks central what this node should be
|
|
/// running and fetches anything missing. That fetch races real deploys, so the
|
|
/// <c>deployed_at</c> guard is what stops a slow reconcile response from overwriting a
|
|
/// newer config that landed while it was in flight. The active-node apply path
|
|
/// (<see cref="StoreDeployedConfigAsync"/>) remains unguarded and always overwrites,
|
|
/// because a deploy is always authoritative.
|
|
/// <para>
|
|
/// It was originally the <i>standby</i> write path as well, under notify-and-fetch: the
|
|
/// standby was told a deploy had happened and fetched the config itself. LocalDb Phase 2
|
|
/// replaced that with change-data-capture — the config row simply replicates — so the
|
|
/// standby no longer writes here at all, and last-writer-wins on the primary key (not
|
|
/// this guard) is what orders concurrent writes between the two nodes. Reconciliation is
|
|
/// the reason the method survives; do not port the <c>deployed_at</c> guard onto the
|
|
/// replication path, where it would fight the HLC rather than help it.
|
|
/// </para>
|
|
/// <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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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 = OpenConnection();
|
|
|
|
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);
|