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; /// /// 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. /// public class SiteStorageService { private readonly ILocalDb _localDb; private readonly ILogger _logger; /// /// Initializes the service over the consolidated site database. /// /// /// 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 zb_hlc_next() 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. /// /// Logger instance for diagnostic messages. public SiteStorageService(ILocalDb localDb, ILogger logger) { ArgumentNullException.ThrowIfNull(localDb); ArgumentNullException.ThrowIfNull(logger); _localDb = localDb; _logger = logger; } /// /// Returns an already-open 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. /// /// /// Contract change: this used to return an unopened connection that the /// caller opened. LocalDb hands out connections already open, pragma-configured, and /// carrying the zb_hlc_next() UDF — calling Open/OpenAsync on one /// throws. Callers must not open it. /// /// An open against the site database. public SqliteConnection CreateConnection() => _localDb.CreateConnection(); /// /// Convenience alias used internally, mirroring the other LocalDb-backed stores. /// private SqliteConnection OpenConnection() => _localDb.CreateConnection(); /// /// Creates the SQLite tables if they do not exist. /// Called once on site startup. /// /// A task that completes when all tables have been created or verified. 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 ── /// /// Returns all deployed instance configurations from SQLite. /// /// A task that resolves to the list of all deployed instance configurations. public async Task> 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(); 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; } /// /// Returns the single deployed instance configuration for , /// or if no row exists for it. /// /// /// plus a FirstOrDefault was /// materializing every deployed config — including every ConfigJson 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 ConfigJson) 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 , /// which never touches the config_json column. /// /// The unique name of the instance. /// /// A task that resolves to the deployed configuration row, or if none /// exists for the instance. /// public async Task 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) }; } /// /// Returns the deployment-identity metadata for — /// (name, deployment id, revision hash, is_enabled) — without reading the /// config_json column, or if no row exists for it. /// /// /// For a caller that only needs staleness/identity metadata (e.g. the /// central deployment-state query, which reports only DeploymentId/RevisionHash), /// reading the config_json blob is pure waste. This projection is that lighter read. /// /// The unique name of the instance. /// /// A task that resolves to the metadata row, or if none exists for the /// instance. /// public async Task 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); } /// /// Stores or updates a deployed instance configuration. Uses UPSERT semantics. /// /// The unique name of the instance. /// The deployed configuration as JSON. /// The unique deployment identifier. /// The configuration revision hash for staleness detection. /// Whether the instance is enabled. /// A task that completes when the configuration has been stored or updated. 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); } /// /// 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 /// ON CONFLICT … WHERE excluded.deployed_at > deployed_configurations.deployed_at /// clause so the guard is atomic with no application-level read-modify-write. /// /// /// This is the reconciliation write path. SiteReconciliationActor 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 /// deployed_at 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 /// () remains unguarded and always overwrites, /// because a deploy is always authoritative. /// /// It was originally the standby 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 deployed_at guard onto the /// replication path, where it would fight the HLC rather than help it. /// /// /// is exposed for testing so that the exact /// deployed_at value can be controlled without sleeping between calls. /// Production callers omit it and receive the default /// semantics, identical to . /// /// /// The unique name of the instance. /// The deployed configuration as JSON. /// The unique deployment identifier. /// The configuration revision hash for staleness detection. /// Whether the instance is enabled. /// /// Optional explicit deployed_at timestamp. Defaults to /// when . /// /// A task that completes when the conditional upsert has been executed. 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); } /// /// Removes a deployed instance configuration and its static overrides. /// /// The unique name of the instance to remove. /// A task that completes when the configuration and its overrides have been removed. 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); } /// /// Sets the enabled/disabled state of a deployed instance. /// /// The unique name of the instance. /// Whether the instance should be enabled. /// A task that completes when the enabled flag has been updated. 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 ── /// /// Returns all static attribute overrides for an instance. /// /// The unique name of the instance. /// A task that resolves to a dictionary mapping attribute names to their override values. public async Task> 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(); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { results[reader.GetString(0)] = reader.GetString(1); } return results; } /// /// Sets or updates a single static attribute override for an instance. /// /// The unique name of the instance. /// The name of the attribute to override. /// The override value for the attribute. /// A task that completes when the override has been saved. 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(); } /// /// Batch counterpart of (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. /// /// The unique name of the instance. /// The attribute name/value pairs to upsert (latest value per name). /// A task that completes when all rows have been committed. 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(); } /// /// Clears all static attribute overrides for an instance. /// Called on redeployment to reset overrides. /// /// The unique name of the instance. /// A task that completes when all overrides for the instance have been deleted. 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) ── /// /// Inserts or updates a single mirrored native alarm condition, keyed by /// (instance, source canonical name, source reference). Newer transitions overwrite older ones. /// /// Unique name of the instance owning the alarm. /// Canonical name of the source binding (connection/alarm source). /// Source-system reference key identifying the specific alarm condition. /// Serialized JSON snapshot. /// Timestamp of the most recent condition transition. /// Optional serialized metadata JSON for the alarm condition; null when not supplied. /// A task that completes when the alarm condition has been inserted or updated. 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(); } /// /// Batch counterpart of (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. /// /// Unique name of the instance owning the alarms. /// Canonical name of the source binding. /// The conditions to upsert (source reference, serialized condition, transition time). /// A task that completes when all rows have been committed. 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(); } /// /// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention). /// /// Unique name of the instance owning the alarm. /// Canonical name of the source binding. /// Source-system reference key identifying the alarm condition to remove. /// A task that completes when the alarm condition row has been deleted. 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(); } /// /// Returns all mirrored native alarm conditions for an instance's source binding, /// used to rehydrate a NativeAlarmActor on (re)start. /// /// Unique name of the instance to query. /// Canonical name of the source binding to query. /// A task that resolves to the list of stored native alarm condition rows for the binding. public async Task> 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(); 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; } /// /// Clears all mirrored native alarm conditions for an instance. Called on redeployment / stop. /// /// Unique name of the instance whose native alarm state should be cleared. /// A task that completes when all native alarm rows for the instance have been deleted. 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) ── /// /// Full-set upsert of the site's deployed secrets: every in /// is written (is_removed = 0), and every row not named /// in the set is soft-deleted (is_removed = 1) rather than actually removed. /// /// /// /// Central ships the complete set of secrets referenced by a site's artifacts on every /// artifact deploy, mirroring the reconcile contract of /// for the other artifact tables. Unlike those tables, /// deployed_secrets is never hard-deleted: it is one of /// SiteLocalDbSetup.ReplicatedTables, and a mass DELETE on a replicated table /// would be captured and shipped to the peer as a destructive resync — the same hazard /// StoreAndForwardStorage.ReplaceAllAsync was deleted for in LocalDb Phase 2. A row /// that a later deploy re-adds simply flips back to is_removed = 0. /// /// /// row_json is JsonSerializer.Serialize(secret) — the /// record verbatim, so its byte[] ciphertext fields serialize as base64 and nothing /// but ciphertext ever lands in this column. /// /// /// The complete set of secrets that should exist on this site. /// The deployment identifier that applied this set. /// /// 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 /// is_removed = 0; a name already removed by an earlier call is not repeated here). /// public async Task<(IReadOnlyList Applied, IReadOnlyList Removed)> UpsertDeployedSecretsAsync( IReadOnlyList 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(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(); 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); } /// /// Returns every row in deployed_secrets, including soft-deleted (is_removed) /// ones, with each row's round-tripped from row_json. /// Strict: a row whose row_json cannot be parsed throws. The per-node mirror reads /// through instead, so one unreadable row is /// reported against its own name rather than aborting the whole reconcile pass. /// /// A task that resolves to every stored deployed-secret row. public async Task> 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; } /// /// Returns every row in deployed_secrets like , /// but never throws for a single bad row: a row whose row_json does not deserialize /// is returned in Unreadable (name, removal flag and the parse error — never the /// row's content) instead of failing the whole read (per-row isolation). /// /// The parsed rows and the rows that could not be parsed. public async Task<(IReadOnlyList Rows, IReadOnlyList 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(); var unreadable = new List(); 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(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) ── /// /// Returns every name this node has recorded as mirrored into its own secret store. /// /// /// mirrored_secrets is not in SiteLocalDbSetup.ReplicatedTables 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. /// /// A task that resolves to every mirrored-secret ledger row. public async Task> 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(); 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; } /// /// Records that this node has mirrored at /// into its own secret store. Uses UPSERT semantics. /// /// The mirrored secret's name. /// The revision that was mirrored. /// A task that completes when the ledger row has been stored or updated. 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(); } /// /// Removes the mirrored-secret ledger row for (e.g. once the /// secret is no longer deployed to this site and this node has un-mirrored it). /// /// The mirrored secret's name. /// A task that completes when the ledger row has been deleted. 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) ── /// /// Records 's current deployed-secrets mirror failures in the /// replicated secret_mirror_status table (UPSERT on the node name). An empty /// list records a healthy mirror. Every call is a replicated /// write, so callers write only when the failure set changes. /// /// This node's name — the row key. /// The node's current failures (names and messages only, never values). /// When the recorded failure set was observed. /// A task that completes when the row has been stored. public async Task SetSecretMirrorStatusAsync( string nodeName, IReadOnlyList 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(); } /// /// Returns every node's row from secret_mirror_status, ordered by node name. A row /// whose failures_json cannot be parsed is returned with a single synthetic failure /// saying so, rather than failing the whole read or silently reading as healthy. /// /// A task that resolves to every node's recorded mirror status. public async Task> 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(); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var nodeName = reader.GetString(0); IReadOnlyList failures; try { failures = JsonSerializer.Deserialize>(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; } /// /// Returns the raw checked_at of 's /// secret_mirror_status row, or when the node has no row — a /// cheap local read the mirror uses to notice its row was deleted by the peer's prune. /// /// The row key. /// The stored checked_at text, or . public async Task 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; } /// /// Deletes 's secret_mirror_status row, but only while it /// still carries — 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. /// /// The row key. /// The checked_at the caller read and judged stale. /// when the row was deleted. public async Task 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 ── /// /// Stores or updates a shared script. Uses UPSERT semantics. /// /// The name of the shared script. /// The script code. /// JSON representation of parameter definitions, if any. /// JSON representation of the return type definition, if any. /// A task that completes when the shared script has been stored or updated. 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); } /// /// Returns all stored shared scripts. /// /// A task that resolves to the list of all stored shared scripts. public async Task> 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(); 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 ── /// /// Stores or updates an external system definition. /// /// The name of the external system. /// The REST API endpoint URL. /// The authentication type (e.g., 'ApiKey', 'BasicAuth'). /// Authentication configuration JSON, if applicable. /// JSON representation of available method definitions, if any. /// Per-system call timeout in seconds (0 = use the site default). /// /// Store-and-Forward retry budget for this system's cached calls (0 = unset — the engine's /// DefaultMaxRetries applies). /// /// /// Fixed interval between Store-and-Forward retry sweeps for this system's cached calls /// ( = unset — the engine's DefaultRetryInterval applies). /// /// A task that completes when the external system definition has been stored or updated. 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 ── /// /// Stores or updates a database connection definition. /// /// The name of the database connection. /// The database connection string. /// Maximum number of retry attempts. /// Delay between retry attempts. /// A task that completes when the database connection definition has been stored or updated. 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 ── /// /// 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 notification_lists and /// smtp_configurations 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 (StoreNotificationListAsync/StoreSmtpConfigurationAsync) /// and the SiteNotificationRepository 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. /// /// A task that completes when both tables have been emptied. 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 ── /// /// Stores or updates a data connection definition (OPC UA endpoint, etc.). /// /// The name of the data connection. /// The protocol type (e.g., 'OpcUa'). /// Primary configuration as JSON. /// Backup configuration as JSON, if applicable. /// Number of retries for failover attempts. /// A task that completes when the data connection definition has been stored or updated. 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); } /// /// Returns all stored data connection definitions. /// /// A task that resolves to the list of all stored data connection definitions. public async Task> 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(); 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 ── /// /// Deletes shared scripts absent from the supplied full set and returns the deleted names. /// /// The complete set of shared script names that should exist. /// A task that resolves to the names of the rows that were deleted. public Task> DeleteSharedScriptsExceptAsync(IReadOnlyCollection keepNames) => DeleteRowsExceptAsync("shared_scripts", keepNames); /// /// Deletes external system definitions absent from the supplied full set and returns the deleted names. /// /// The complete set of external system names that should exist. /// A task that resolves to the names of the rows that were deleted. public Task> DeleteExternalSystemsExceptAsync(IReadOnlyCollection keepNames) => DeleteRowsExceptAsync("external_systems", keepNames); /// /// Deletes database connection definitions absent from the supplied full set and returns the deleted names. /// /// The complete set of database connection names that should exist. /// A task that resolves to the names of the rows that were deleted. public Task> DeleteDatabaseConnectionsExceptAsync(IReadOnlyCollection keepNames) => DeleteRowsExceptAsync("database_connections", keepNames); /// /// Deletes data connection definitions absent from the supplied full set and returns the deleted names. /// /// The complete set of data connection names that should exist. /// A task that resolves to the names of the rows that were deleted. public Task> DeleteDataConnectionDefinitionsExceptAsync(IReadOnlyCollection keepNames) => DeleteRowsExceptAsync("data_connection_definitions", keepNames); /// /// Deletes every row of an artifact table whose name is not in /// , 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 /// legitimately deletes every row — central saying "none of these exist anymore". /// private async Task> DeleteRowsExceptAsync(string table, IReadOnlyCollection 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(); 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; } } /// /// Represents a deployed instance configuration as stored in SQLite. /// public class DeployedInstance { /// /// The unique name of the instance. /// public string InstanceUniqueName { get; init; } = string.Empty; /// /// The deployed configuration as JSON. /// public string ConfigJson { get; init; } = string.Empty; /// /// The unique deployment identifier. /// public string DeploymentId { get; init; } = string.Empty; /// /// The configuration revision hash for staleness detection. /// public string RevisionHash { get; init; } = string.Empty; /// /// Whether the instance is enabled. /// public bool IsEnabled { get; init; } /// /// The timestamp when the configuration was deployed. /// public string DeployedAt { get; init; } = string.Empty; } /// /// Deployment-identity metadata for one deployed instance — the projection of /// that omits ConfigJson. Returned by /// for callers that only need to /// answer "is this instance deployed, and at what revision". /// /// The unique name of the instance. /// The unique deployment identifier. /// The configuration revision hash for staleness detection. /// Whether the instance is enabled. public record DeployedConfigMetadata( string InstanceUniqueName, string DeploymentId, string RevisionHash, bool IsEnabled); /// /// Represents a shared script stored locally in SQLite. /// public class StoredSharedScript { /// /// The name of the shared script. /// public string Name { get; init; } = string.Empty; /// /// The script code. /// public string Code { get; init; } = string.Empty; /// /// JSON representation of parameter definitions, if any. /// public string? ParameterDefinitions { get; init; } /// /// JSON representation of the return type definition, if any. /// public string? ReturnDefinition { get; init; } } /// /// Represents a data connection definition stored locally in SQLite. /// public class StoredDataConnectionDefinition { /// /// The name of the data connection. /// public string Name { get; init; } = string.Empty; /// /// The protocol type (e.g., 'OpcUa'). /// public string Protocol { get; init; } = string.Empty; /// /// Primary configuration as JSON. /// public string? ConfigurationJson { get; init; } /// /// Backup configuration as JSON, if applicable. /// public string? BackupConfigurationJson { get; init; } /// /// Number of retries for failover attempts. /// public int FailoverRetryCount { get; init; } = 3; } /// /// A single mirrored native alarm condition row from the site-local native_alarm_state table. /// carries the serialized display metadata (type/category/message/ /// values) restored on rehydration (UA4); null on rows written before the column existed. /// public record NativeAlarmRow( string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt, string? MetadataJson = null); /// /// A single row from the site-local, LocalDb-replicated deployed_secrets table. /// is the round-tripped from /// row_json — ciphertext only, never plaintext. is true /// for a soft-deleted row (see ); /// the row is retained, not hard-deleted, because this table replicates. /// public record DeployedSecretRow(string Name, SecretArtifact Secret, bool IsRemoved, string DeploymentId); /// /// A deployed_secrets row whose row_json could not be parsed — returned by /// so the mirror can report it /// against its own name instead of aborting the pass. is the parse error /// (exception type and message), never the row's content. /// public record UnreadableDeployedSecretRow(string Name, bool IsRemoved, string Error); /// /// A single row from the site-local, node-local (NOT LocalDb-replicated) mirrored_secrets /// ledger — the names this node has mirrored into its own secret store, and at what revision. /// public record MirroredSecretRow(string Name, long Revision, DateTimeOffset MirroredAt);