using Microsoft.Data.Sqlite; namespace ZB.MOM.WW.ScadaBridge.StoreAndForward; /// /// DDL for the sf_messages table, extracted from /// so it can be applied by whoever owns the /// database file. /// /// Deliberately depends only on Microsoft.Data.Sqlite, not on the LocalDb library. /// The Host applies this DDL to a LocalDb-managed connection before /// RegisterReplicated installs the capture triggers; nothing about the schema /// itself is LocalDb-specific, and the store still calls it so a directly-constructed /// store (tests, tooling) remains self-sufficient. /// /// public static class StoreAndForwardSchema { /// /// Creates the sf_messages table and its indexes when absent, and additively /// upgrades a table created by an older build. Idempotent — safe to run on every /// startup. /// /// An open connection to the database that should hold the table. public static void Apply(SqliteConnection connection) { ArgumentNullException.ThrowIfNull(connection); using (var command = connection.CreateCommand()) { command.CommandText = @" CREATE TABLE IF NOT EXISTS sf_messages ( id TEXT PRIMARY KEY, category INTEGER NOT NULL, target TEXT NOT NULL, payload_json TEXT NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 50, retry_interval_ms INTEGER NOT NULL DEFAULT 30000, created_at TEXT NOT NULL, last_attempt_at TEXT, status INTEGER NOT NULL DEFAULT 0, last_error TEXT, origin_instance TEXT ); CREATE INDEX IF NOT EXISTS idx_sf_messages_status ON sf_messages(status); CREATE INDEX IF NOT EXISTS idx_sf_messages_category ON sf_messages(category); "; command.ExecuteNonQuery(); } // Additively add the execution_id / // source_script columns. CREATE TABLE IF NOT EXISTS above does NOT add // columns to a table that already exists from before these fields, so a // databases created by an older build needs the columns ALTER-ed in. // SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is // probed first and the ALTER skipped when already there. Both columns // are nullable with no default, so any row buffered before this // migration reads back ExecutionId/SourceScript = null (back-compat). AddColumnIfMissing(connection, "execution_id", "TEXT"); AddColumnIfMissing(connection, "source_script", "TEXT"); // Additively add the // parent_execution_id column the same way — a sibling to execution_id. // Nullable with no default, so any row buffered before this migration // reads back ParentExecutionId = null (back-compat). AddColumnIfMissing(connection, "parent_execution_id", "TEXT"); // Additively add the epoch-ms sibling of last_attempt_at. The // ISO-8601 text column stays authoritative for reads / back-compat; this // INTEGER column drives the due predicate so the sweep no longer parses // julianday() per row. AddColumnIfMissing(connection, "last_attempt_at_ms", "INTEGER"); // One-time backfill for rows persisted before the ms column existed: derive // epoch-ms from the text timestamp. The "... IS NULL" guard makes this run // once per legacy DB and never match again — this is the only remaining // julianday() use in the store. using (var backfill = connection.CreateCommand()) { backfill.CommandText = @" UPDATE sf_messages SET last_attempt_at_ms = CAST((julianday(last_attempt_at) - 2440587.5) * 86400000 AS INTEGER) WHERE last_attempt_at IS NOT NULL AND last_attempt_at_ms IS NULL"; backfill.ExecuteNonQuery(); } // Covering index for the due query (status filter + ms ordering column). // Created after the ALTER above so the column exists. using (var dueIndex = connection.CreateCommand()) { dueIndex.CommandText = "CREATE INDEX IF NOT EXISTS idx_sf_messages_status_due ON sf_messages(status, last_attempt_at_ms)"; dueIndex.ExecuteNonQuery(); } } /// /// Adds a column to sf_messages /// only when it is not already present. SQLite lacks ADD COLUMN IF NOT /// EXISTS, so the schema is probed via PRAGMA table_info first. /// Idempotent — safe to run on every . /// private static void AddColumnIfMissing( SqliteConnection connection, string columnName, string columnType) { using (var probe = connection.CreateCommand()) { probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('sf_messages') WHERE name = @name"; probe.Parameters.AddWithValue("@name", columnName); if (Convert.ToInt32(probe.ExecuteScalar()) > 0) { return; } } using var alter = connection.CreateCommand(); // Column name + type are caller-controlled constants, never user input — // safe to interpolate (parameters are not permitted in DDL). alter.CommandText = $"ALTER TABLE sf_messages ADD COLUMN {columnName} {columnType}"; alter.ExecuteNonQuery(); } }