Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs
T
Joseph Doherty 0ffd4bdbea docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)
Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
2026-10-07 14:12:25 -04:00

1513 lines
72 KiB
C#

using System.Text.Json;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
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>
/// Returns the single deployed instance configuration for <paramref name="instanceUniqueName"/>,
/// or <see langword="null"/> if no row exists for it.
/// </summary>
/// <remarks>
/// <see cref="GetAllDeployedConfigsAsync"/> plus a <c>FirstOrDefault</c> was
/// materializing every deployed config — including every <c>ConfigJson</c> blob — just to find
/// one row. A volume-restore reconcile healing hundreds of instances turned that into hundreds
/// of full-table reads. Callers that need the full row (including <c>ConfigJson</c>) for exactly
/// one instance — e.g. to (re)start its Instance Actor — should use this instead. Callers that
/// only need identity/staleness metadata should use <see cref="GetDeployedConfigMetadataAsync"/>,
/// which never touches the <c>config_json</c> column.
/// </remarks>
/// <param name="instanceUniqueName">The unique name of the instance.</param>
/// <returns>
/// A task that resolves to the deployed configuration row, or <see langword="null"/> if none
/// exists for the instance.
/// </returns>
public async Task<DeployedInstance?> GetDeployedConfigAsync(string instanceUniqueName)
{
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
WHERE instance_unique_name = @name
LIMIT 1";
command.Parameters.AddWithValue("@name", instanceUniqueName);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return null;
return 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)
};
}
/// <summary>
/// Returns the deployment-identity metadata for <paramref name="instanceUniqueName"/> —
/// <c>(name, deployment id, revision hash, is_enabled)</c> — without reading the
/// <c>config_json</c> column, or <see langword="null"/> if no row exists for it.
/// </summary>
/// <remarks>
/// For a caller that only needs staleness/identity metadata (e.g. the
/// central deployment-state query, which reports only <c>DeploymentId</c>/<c>RevisionHash</c>),
/// reading the <c>config_json</c> blob is pure waste. This projection is that lighter read.
/// </remarks>
/// <param name="instanceUniqueName">The unique name of the instance.</param>
/// <returns>
/// A task that resolves to the metadata row, or <see langword="null"/> if none exists for the
/// instance.
/// </returns>
public async Task<DeployedConfigMetadata?> GetDeployedConfigMetadataAsync(string instanceUniqueName)
{
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT instance_unique_name, deployment_id, revision_hash, is_enabled
FROM deployed_configurations
WHERE instance_unique_name = @name
LIMIT 1";
command.Parameters.AddWithValue("@name", instanceUniqueName);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return null;
return new DeployedConfigMetadata(
InstanceUniqueName: reader.GetString(0),
DeploymentId: reader.GetString(1),
RevisionHash: reader.GetString(2),
IsEnabled: reader.GetInt64(3) != 0);
}
/// <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 &gt; 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>
/// Batch counterpart of <see cref="SetStaticOverrideAsync"/> (perf remediation):
/// upserts a set of static attribute overrides for one instance
/// in a SINGLE connection + transaction with one prepared command re-bound per row.
/// Used by the InstanceActor's coalesced flush — mirrors NativeAlarmActor's
/// MarkDirtyUpsert/FlushDirtyUpserts (P4) pattern — so a burst of rapid SetAttribute
/// calls on an instance collapses into one batched write instead of a write per call.
/// </summary>
/// <param name="instanceName">The unique name of the instance.</param>
/// <param name="overrides">The attribute name/value pairs to upsert (latest value per name).</param>
/// <returns>A task that completes when all rows have been committed.</returns>
public async Task SetStaticOverridesAsync(
string instanceName, IReadOnlyList<(string AttributeName, string Value)> overrides)
{
if (overrides.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 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";
var pName = command.Parameters.Add("@name", SqliteType.Text);
var pAttr = command.Parameters.Add("@attr", SqliteType.Text);
var pVal = command.Parameters.Add("@val", SqliteType.Text);
var pUpdatedAt = command.Parameters.Add("@updatedAt", SqliteType.Text);
pName.Value = instanceName;
var updatedAt = DateTimeOffset.UtcNow.ToString("O");
foreach (var (attributeName, value) in overrides)
{
pAttr.Value = attributeName;
pVal.Value = value;
pUpdatedAt.Value = updatedAt;
await command.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
}
/// <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);
}
// ── Deployed Secrets store (LocalDb-replicated ciphertext) ──
/// <summary>
/// Full-set upsert of the site's deployed secrets: every <see cref="SecretArtifact"/> in
/// <paramref name="secrets"/> is written (<c>is_removed = 0</c>), and every row not named
/// in the set is soft-deleted (<c>is_removed = 1</c>) rather than actually removed.
/// </summary>
/// <remarks>
/// <para>
/// Central ships the complete set of secrets referenced by a site's artifacts on every
/// artifact deploy, mirroring the reconcile contract of
/// <see cref="DeleteRowsExceptAsync"/> for the other artifact tables. Unlike those tables,
/// <c>deployed_secrets</c> is never hard-deleted: it is one of
/// <c>SiteLocalDbSetup.ReplicatedTables</c>, and a mass <c>DELETE</c> on a replicated table
/// would be captured and shipped to the peer as a destructive resync — the same hazard
/// <c>StoreAndForwardStorage.ReplaceAllAsync</c> was deleted for in LocalDb Phase 2. A row
/// that a later deploy re-adds simply flips back to <c>is_removed = 0</c>.
/// </para>
/// <para>
/// <c>row_json</c> is <c>JsonSerializer.Serialize(secret)</c> — the <see cref="SecretArtifact"/>
/// record verbatim, so its <c>byte[]</c> ciphertext fields serialize as base64 and nothing
/// but ciphertext ever lands in this column.
/// </para>
/// </remarks>
/// <param name="secrets">The complete set of secrets that should exist on this site.</param>
/// <param name="deploymentId">The deployment identifier that applied this set.</param>
/// <returns>
/// A task that resolves to the names that were applied (inserted or updated) and the
/// names that were newly marked removed by this call (rows that were previously
/// <c>is_removed = 0</c>; a name already removed by an earlier call is not repeated here).
/// </returns>
public async Task<(IReadOnlyList<string> Applied, IReadOnlyList<string> Removed)> UpsertDeployedSecretsAsync(
IReadOnlyList<SecretArtifact> secrets, string deploymentId)
{
ArgumentNullException.ThrowIfNull(secrets);
ArgumentException.ThrowIfNullOrWhiteSpace(deploymentId);
await using var connection = OpenConnection();
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
var appliedAt = DateTimeOffset.UtcNow.ToString("O");
var applied = new List<string>(secrets.Count);
await using (var upsert = connection.CreateCommand())
{
upsert.Transaction = transaction;
upsert.CommandText = @"
INSERT INTO deployed_secrets (name, row_json, revision, updated_utc, is_removed, deployment_id, applied_at)
VALUES (@name, @json, @revision, @updatedUtc, 0, @deploymentId, @appliedAt)
ON CONFLICT(name) DO UPDATE SET
row_json = excluded.row_json,
revision = excluded.revision,
updated_utc = excluded.updated_utc,
is_removed = 0,
deployment_id = excluded.deployment_id,
applied_at = excluded.applied_at";
var pName = upsert.Parameters.Add("@name", SqliteType.Text);
var pJson = upsert.Parameters.Add("@json", SqliteType.Text);
var pRevision = upsert.Parameters.Add("@revision", SqliteType.Integer);
var pUpdatedUtc = upsert.Parameters.Add("@updatedUtc", SqliteType.Text);
var pDeploymentId = upsert.Parameters.Add("@deploymentId", SqliteType.Text);
var pAppliedAt = upsert.Parameters.Add("@appliedAt", SqliteType.Text);
pDeploymentId.Value = deploymentId;
pAppliedAt.Value = appliedAt;
foreach (var secret in secrets)
{
pName.Value = secret.Name;
pJson.Value = JsonSerializer.Serialize(secret);
pRevision.Value = secret.Revision;
pUpdatedUtc.Value = secret.UpdatedUtc.ToString("O");
await upsert.ExecuteNonQueryAsync();
applied.Add(secret.Name);
}
}
// Names present in the incoming set are never candidates for removal, including
// one that was previously soft-deleted and has just been re-applied above.
var keepParams = secrets.Select((s, i) => (Key: $"@k{i}", Value: s.Name)).ToList();
var predicate = keepParams.Count == 0
? " WHERE is_removed = 0"
: $" WHERE is_removed = 0 AND name NOT IN ({string.Join(", ", keepParams.Select(p => p.Key))})";
var removed = new List<string>();
await using (var select = connection.CreateCommand())
{
select.Transaction = transaction;
select.CommandText = $"SELECT name FROM deployed_secrets{predicate}";
foreach (var (key, value) in keepParams)
select.Parameters.AddWithValue(key, value);
await using var reader = await select.ExecuteReaderAsync();
while (await reader.ReadAsync())
removed.Add(reader.GetString(0));
}
if (removed.Count > 0)
{
await using var markRemoved = connection.CreateCommand();
markRemoved.Transaction = transaction;
markRemoved.CommandText = $"UPDATE deployed_secrets SET is_removed = 1{predicate}";
foreach (var (key, value) in keepParams)
markRemoved.Parameters.AddWithValue(key, value);
await markRemoved.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
_logger.LogInformation(
"Deployed secrets reconcile for deployment {DeploymentId}: applied {AppliedCount}, newly removed {RemovedCount}",
deploymentId, applied.Count, removed.Count);
return (applied, removed);
}
/// <summary>
/// Returns every row in <c>deployed_secrets</c>, including soft-deleted (<c>is_removed</c>)
/// ones, with each row's <see cref="SecretArtifact"/> round-tripped from <c>row_json</c>.
/// Strict: a row whose <c>row_json</c> cannot be parsed throws. The per-node mirror reads
/// through <see cref="GetDeployedSecretsTolerantAsync"/> instead, so one unreadable row is
/// reported against its own name rather than aborting the whole reconcile pass.
/// </summary>
/// <returns>A task that resolves to every stored deployed-secret row.</returns>
public async Task<IReadOnlyList<DeployedSecretRow>> GetDeployedSecretsAsync()
{
var (rows, unreadable) = await GetDeployedSecretsTolerantAsync();
if (unreadable.Count > 0)
{
throw new InvalidOperationException(
$"deployed_secrets row '{unreadable[0].Name}' has invalid row_json ({unreadable[0].Error})");
}
return rows;
}
/// <summary>
/// Returns every row in <c>deployed_secrets</c> like <see cref="GetDeployedSecretsAsync"/>,
/// but never throws for a single bad row: a row whose <c>row_json</c> does not deserialize
/// is returned in <c>Unreadable</c> (name, removal flag and the parse error — never the
/// row's content) instead of failing the whole read (per-row isolation).
/// </summary>
/// <returns>The parsed rows and the rows that could not be parsed.</returns>
public async Task<(IReadOnlyList<DeployedSecretRow> Rows, IReadOnlyList<UnreadableDeployedSecretRow> Unreadable)>
GetDeployedSecretsTolerantAsync()
{
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT name, row_json, is_removed, deployment_id FROM deployed_secrets";
var results = new List<DeployedSecretRow>();
var unreadable = new List<UnreadableDeployedSecretRow>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var name = reader.GetString(0);
var isRemoved = reader.GetInt64(2) != 0;
SecretArtifact? secret;
string? error = null;
try
{
secret = JsonSerializer.Deserialize<SecretArtifact>(reader.GetString(1));
if (secret is null)
{
error = "row_json deserialized to null";
}
}
catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidCastException)
{
secret = null;
error = $"{ex.GetType().Name}: {ex.Message}";
}
if (secret is null)
{
unreadable.Add(new UnreadableDeployedSecretRow(name, isRemoved, error!));
continue;
}
results.Add(new DeployedSecretRow(name, secret, isRemoved, reader.GetString(3)));
}
return (results, unreadable);
}
// ── Mirrored Secrets ledger (node-local only — deliberately NOT LocalDb-replicated) ──
/// <summary>
/// Returns every name this node has recorded as mirrored into its own secret store.
/// </summary>
/// <remarks>
/// <c>mirrored_secrets</c> is not in <c>SiteLocalDbSetup.ReplicatedTables</c> on purpose:
/// mirroring writes a name into THIS node's own local secret store, so which names are
/// mirrored — and at what revision — is per-node fact, not site-wide state. Replicating it
/// would tell this node's ledger about the OTHER node's mirroring progress, which is not
/// what it records.
/// </remarks>
/// <returns>A task that resolves to every mirrored-secret ledger row.</returns>
public async Task<IReadOnlyList<MirroredSecretRow>> GetMirroredSecretsAsync()
{
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT name, revision, mirrored_at FROM mirrored_secrets";
var results = new List<MirroredSecretRow>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new MirroredSecretRow(
reader.GetString(0),
reader.GetInt64(1),
DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind)));
}
return results;
}
/// <summary>
/// Records that this node has mirrored <paramref name="name"/> at <paramref name="revision"/>
/// into its own secret store. Uses UPSERT semantics.
/// </summary>
/// <param name="name">The mirrored secret's name.</param>
/// <param name="revision">The revision that was mirrored.</param>
/// <returns>A task that completes when the ledger row has been stored or updated.</returns>
public async Task SetMirroredSecretAsync(string name, long revision)
{
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO mirrored_secrets (name, revision, mirrored_at)
VALUES (@name, @revision, @mirroredAt)
ON CONFLICT(name) DO UPDATE SET
revision = excluded.revision,
mirrored_at = excluded.mirrored_at";
command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@revision", revision);
command.Parameters.AddWithValue("@mirroredAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Removes the mirrored-secret ledger row for <paramref name="name"/> (e.g. once the
/// secret is no longer deployed to this site and this node has un-mirrored it).
/// </summary>
/// <param name="name">The mirrored secret's name.</param>
/// <returns>A task that completes when the ledger row has been deleted.</returns>
public async Task RemoveMirroredSecretAsync(string name)
{
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM mirrored_secrets WHERE name = @name";
command.Parameters.AddWithValue("@name", name);
await command.ExecuteNonQueryAsync();
}
// ── Secret mirror status (LocalDb-replicated, one row per node) ──
/// <summary>
/// Records <paramref name="nodeName"/>'s current deployed-secrets mirror failures in the
/// replicated <c>secret_mirror_status</c> table (UPSERT on the node name). An empty
/// <paramref name="failures"/> list records a healthy mirror. Every call is a replicated
/// write, so callers write only when the failure set changes.
/// </summary>
/// <param name="nodeName">This node's name — the row key.</param>
/// <param name="failures">The node's current failures (names and messages only, never values).</param>
/// <param name="checkedAtUtc">When the recorded failure set was observed.</param>
/// <returns>A task that completes when the row has been stored.</returns>
public async Task SetSecretMirrorStatusAsync(
string nodeName, IReadOnlyList<SecretMirrorFailure> failures, DateTimeOffset checkedAtUtc)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nodeName);
ArgumentNullException.ThrowIfNull(failures);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO secret_mirror_status (node_name, failures_json, checked_at)
VALUES (@nodeName, @failures, @checkedAt)
ON CONFLICT(node_name) DO UPDATE SET
failures_json = excluded.failures_json,
checked_at = excluded.checked_at";
command.Parameters.AddWithValue("@nodeName", nodeName);
command.Parameters.AddWithValue("@failures", JsonSerializer.Serialize(failures));
command.Parameters.AddWithValue("@checkedAt", checkedAtUtc.ToUniversalTime().ToString("O"));
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Returns every node's row from <c>secret_mirror_status</c>, ordered by node name. A row
/// whose <c>failures_json</c> cannot be parsed is returned with a single synthetic failure
/// saying so, rather than failing the whole read or silently reading as healthy.
/// </summary>
/// <returns>A task that resolves to every node's recorded mirror status.</returns>
public async Task<IReadOnlyList<SecretMirrorNodeStatus>> GetSecretMirrorStatusesAsync()
{
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText =
"SELECT node_name, failures_json, checked_at FROM secret_mirror_status ORDER BY node_name";
var results = new List<SecretMirrorNodeStatus>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var nodeName = reader.GetString(0);
IReadOnlyList<SecretMirrorFailure> failures;
try
{
failures = JsonSerializer.Deserialize<List<SecretMirrorFailure>>(reader.GetString(1))
?? [];
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
failures =
[
new SecretMirrorFailure(
"(unreadable)",
$"Mirror status row for node '{nodeName}' could not be parsed ({ex.GetType().Name})"),
];
}
var checkedAt = DateTimeOffset.TryParse(
reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)
? parsed
: DateTimeOffset.MinValue;
results.Add(new SecretMirrorNodeStatus(nodeName, checkedAt, failures));
}
return results;
}
/// <summary>
/// Returns the raw <c>checked_at</c> of <paramref name="nodeName"/>'s
/// <c>secret_mirror_status</c> row, or <see langword="null"/> when the node has no row — a
/// cheap local read the mirror uses to notice its row was deleted by the peer's prune.
/// </summary>
/// <param name="nodeName">The row key.</param>
/// <returns>The stored <c>checked_at</c> text, or <see langword="null"/>.</returns>
public async Task<string?> GetSecretMirrorStatusCheckedAtAsync(string nodeName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nodeName);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT checked_at FROM secret_mirror_status WHERE node_name = @nodeName";
command.Parameters.AddWithValue("@nodeName", nodeName);
return await command.ExecuteScalarAsync() as string;
}
/// <summary>
/// Deletes <paramref name="nodeName"/>'s <c>secret_mirror_status</c> row, but only while it
/// still carries <paramref name="checkedAtUtc"/> — so a row the node re-wrote after the caller
/// read it (it came back, or refreshed just in time) is never deleted. The delete replicates
/// like any other write.
/// </summary>
/// <param name="nodeName">The row key.</param>
/// <param name="checkedAtUtc">The <c>checked_at</c> the caller read and judged stale.</param>
/// <returns><see langword="true"/> when the row was deleted.</returns>
public async Task<bool> DeleteSecretMirrorStatusIfUnchangedAsync(string nodeName, DateTimeOffset checkedAtUtc)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nodeName);
await using var connection = OpenConnection();
await using var command = connection.CreateCommand();
command.CommandText =
"DELETE FROM secret_mirror_status WHERE node_name = @nodeName AND checked_at = @checkedAt";
command.Parameters.AddWithValue("@nodeName", nodeName);
command.Parameters.AddWithValue("@checkedAt", checkedAtUtc.ToUniversalTime().ToString("O"));
return await command.ExecuteNonQueryAsync() > 0;
}
// ── 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>
/// <param name="maxRetries">
/// Store-and-Forward retry budget for this system's cached calls (0 = unset — the engine's
/// <c>DefaultMaxRetries</c> applies).
/// </param>
/// <param name="retryDelay">
/// Fixed interval between Store-and-Forward retry sweeps for this system's cached calls
/// (<see cref="TimeSpan.Zero"/> = unset — the engine's <c>DefaultRetryInterval</c> applies).
/// </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, int maxRetries = 0, TimeSpan retryDelay = default)
{
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, max_retries, retry_delay_ms, updated_at)
VALUES (@name, @url, @authType, @authConfig, @methodDefs, @timeoutSeconds, @maxRetries, @retryDelayMs, @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,
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("@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("@maxRetries", maxRetries);
command.Parameters.AddWithValue("@retryDelayMs", (long)retryDelay.TotalMilliseconds);
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;
}
// ── Artifact set reconciliation ──
/// <summary>
/// Deletes shared scripts absent from the supplied full set and returns the deleted names.
/// </summary>
/// <param name="keepNames">The complete set of shared script names that should exist.</param>
/// <returns>A task that resolves to the names of the rows that were deleted.</returns>
public Task<List<string>> DeleteSharedScriptsExceptAsync(IReadOnlyCollection<string> keepNames)
=> DeleteRowsExceptAsync("shared_scripts", keepNames);
/// <summary>
/// Deletes external system definitions absent from the supplied full set and returns the deleted names.
/// </summary>
/// <param name="keepNames">The complete set of external system names that should exist.</param>
/// <returns>A task that resolves to the names of the rows that were deleted.</returns>
public Task<List<string>> DeleteExternalSystemsExceptAsync(IReadOnlyCollection<string> keepNames)
=> DeleteRowsExceptAsync("external_systems", keepNames);
/// <summary>
/// Deletes database connection definitions absent from the supplied full set and returns the deleted names.
/// </summary>
/// <param name="keepNames">The complete set of database connection names that should exist.</param>
/// <returns>A task that resolves to the names of the rows that were deleted.</returns>
public Task<List<string>> DeleteDatabaseConnectionsExceptAsync(IReadOnlyCollection<string> keepNames)
=> DeleteRowsExceptAsync("database_connections", keepNames);
/// <summary>
/// Deletes data connection definitions absent from the supplied full set and returns the deleted names.
/// </summary>
/// <param name="keepNames">The complete set of data connection names that should exist.</param>
/// <returns>A task that resolves to the names of the rows that were deleted.</returns>
public Task<List<string>> DeleteDataConnectionDefinitionsExceptAsync(IReadOnlyCollection<string> keepNames)
=> DeleteRowsExceptAsync("data_connection_definitions", keepNames);
/// <summary>
/// Deletes every row of an artifact table whose <c>name</c> is not in
/// <paramref name="keepNames"/>, returning the deleted names.
///
/// Central always ships the COMPLETE system-wide set of each artifact class on an
/// artifact deployment, so a stored row absent from the incoming set means the
/// artifact was deleted centrally. The store methods are upsert-only, which used to
/// leave such rows orphaned on the site forever (a centrally-deleted external
/// system stayed callable from site scripts indefinitely); the artifact apply now
/// reconciles by calling this after upserting. An empty <paramref name="keepNames"/>
/// legitimately deletes every row — central saying "none of these exist anymore".
/// </summary>
private async Task<List<string>> DeleteRowsExceptAsync(string table, IReadOnlyCollection<string> keepNames)
{
await using var connection = OpenConnection();
await using var transaction = connection.BeginTransaction();
var keepParams = keepNames.Select((name, i) => (Key: $"@k{i}", Value: name)).ToList();
var predicate = keepParams.Count == 0
? string.Empty
: $" WHERE name NOT IN ({string.Join(", ", keepParams.Select(p => p.Key))})";
var removed = new List<string>();
await using (var select = connection.CreateCommand())
{
select.Transaction = transaction;
select.CommandText = $"SELECT name FROM {table}{predicate}";
foreach (var (key, value) in keepParams)
select.Parameters.AddWithValue(key, value);
await using var reader = await select.ExecuteReaderAsync();
while (await reader.ReadAsync())
removed.Add(reader.GetString(0));
}
if (removed.Count > 0)
{
await using var delete = connection.CreateCommand();
delete.Transaction = transaction;
delete.CommandText = $"DELETE FROM {table}{predicate}";
foreach (var (key, value) in keepParams)
delete.Parameters.AddWithValue(key, value);
await delete.ExecuteNonQueryAsync();
_logger.LogInformation(
"Artifact reconcile removed {Count} orphaned row(s) from {Table}: {Names}",
removed.Count, table, string.Join(", ", removed));
}
transaction.Commit();
return removed;
}
}
/// <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>
/// Deployment-identity metadata for one deployed instance — the projection of
/// <see cref="DeployedInstance"/> that omits <c>ConfigJson</c>. Returned by
/// <see cref="SiteStorageService.GetDeployedConfigMetadataAsync"/> for callers that only need to
/// answer "is this instance deployed, and at what revision".
/// </summary>
/// <param name="InstanceUniqueName">The unique name of the instance.</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>
public record DeployedConfigMetadata(
string InstanceUniqueName,
string DeploymentId,
string RevisionHash,
bool IsEnabled);
/// <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);
/// <summary>
/// A single row from the site-local, LocalDb-replicated <c>deployed_secrets</c> table.
/// <paramref name="Secret"/> is the <see cref="SecretArtifact"/> round-tripped from
/// <c>row_json</c> — ciphertext only, never plaintext. <paramref name="IsRemoved"/> is true
/// for a soft-deleted row (see <see cref="SiteStorageService.UpsertDeployedSecretsAsync"/>);
/// the row is retained, not hard-deleted, because this table replicates.
/// </summary>
public record DeployedSecretRow(string Name, SecretArtifact Secret, bool IsRemoved, string DeploymentId);
/// <summary>
/// A <c>deployed_secrets</c> row whose <c>row_json</c> could not be parsed — returned by
/// <see cref="SiteStorageService.GetDeployedSecretsTolerantAsync"/> so the mirror can report it
/// against its own name instead of aborting the pass. <paramref name="Error"/> is the parse error
/// (exception type and message), never the row's content.
/// </summary>
public record UnreadableDeployedSecretRow(string Name, bool IsRemoved, string Error);
/// <summary>
/// A single row from the site-local, node-local (NOT LocalDb-replicated) <c>mirrored_secrets</c>
/// ledger — the names this node has mirrored into its own secret store, and at what revision.
/// </summary>
public record MirroredSecretRow(string Name, long Revision, DateTimeOffset MirroredAt);