using System.Globalization; using Microsoft.Data.Sqlite; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; /// Thrown when the auth store cannot be migrated to the supported schema. public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message); /// /// Creates the API-key store schema and records the applied version. Idempotent: it /// is safe to run repeatedly. Refuses to run against a database whose on-disk version /// is newer than this build supports. /// public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStoreMigrator { /// Applies the schema migration to the auth store. /// Cancellation token. /// /// The on-disk schema version is newer than . /// public async Task MigrateAsync(CancellationToken cancellationToken) { await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using SqliteTransaction transaction = (SqliteTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false); int existingVersion = await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false); if (existingVersion > SqliteAuthSchema.CurrentVersion) { throw new AuthStoreMigrationException( $"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}."); } await ApplySchemaAsync(connection, transaction, cancellationToken).ConfigureAwait(false); await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } private static async Task ReadExistingSchemaVersionAsync( SqliteConnection connection, SqliteTransaction transaction, CancellationToken cancellationToken) { await using SqliteCommand tableExistsCommand = connection.CreateCommand(); tableExistsCommand.Transaction = transaction; tableExistsCommand.CommandText = """ SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $table_name; """; tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable); long tableCount = (long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L); if (tableCount == 0) { return 0; } await using SqliteCommand versionCommand = connection.CreateCommand(); versionCommand.Transaction = transaction; versionCommand.CommandText = """ SELECT version FROM schema_version WHERE id = 1; """; object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); return version is null || version == DBNull.Value ? 0 : Convert.ToInt32(version, CultureInfo.InvariantCulture); } // Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is // idempotent against an already-provisioned database), followed by any additive column // migrations that a pre-existing table would be missing. The applied version is stamped // separately by WriteSchemaVersionAsync. private static async Task ApplySchemaAsync( SqliteConnection connection, SqliteTransaction transaction, CancellationToken cancellationToken) { await ExecuteNonQueryAsync( connection, transaction, string.Join( "\n", SqliteAuthSchema.CreateSchemaVersionTable, SqliteAuthSchema.CreateApiKeysTable, SqliteAuthSchema.CreateApiKeyAuditTable, SqliteAuthSchema.CreateIndexes), cancellationToken).ConfigureAwait(false); // v3: add the nullable expires_utc column to a pre-v3 api_keys table. On a fresh database the // CREATE above already includes the column, so the existence check makes this a no-op. await EnsureApiKeysColumnAsync( connection, transaction, SqliteAuthSchema.ExpiresUtcColumn, SqliteAuthSchema.AddExpiresUtcColumn, cancellationToken).ConfigureAwait(false); } // Adds a column to api_keys only if it is not already present. SQLite has no // "ADD COLUMN IF NOT EXISTS", so we probe PRAGMA table_info first. The reader is fully consumed // and disposed before the ALTER runs on the same connection/transaction. private static async Task EnsureApiKeysColumnAsync( SqliteConnection connection, SqliteTransaction transaction, string columnName, string addColumnDdl, CancellationToken cancellationToken) { bool exists = false; await using (SqliteCommand pragma = connection.CreateCommand()) { pragma.Transaction = transaction; // table_info takes an identifier, not a bindable parameter; the table name is a fixed // internal constant, never caller input. pragma.CommandText = $"PRAGMA table_info({SqliteAuthSchema.ApiKeysTable});"; await using SqliteDataReader reader = await pragma.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { // PRAGMA table_info columns: cid(0), name(1), type(2), ... if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) { exists = true; break; } } } if (!exists) { await ExecuteNonQueryAsync(connection, transaction, addColumnDdl, cancellationToken).ConfigureAwait(false); } } private static async Task WriteSchemaVersionAsync( SqliteConnection connection, SqliteTransaction transaction, CancellationToken cancellationToken) { await using SqliteCommand versionCommand = connection.CreateCommand(); versionCommand.Transaction = transaction; versionCommand.CommandText = """ INSERT INTO schema_version (id, version, applied_utc) VALUES (1, $version, $applied_utc) ON CONFLICT(id) DO UPDATE SET version = excluded.version, applied_utc = excluded.applied_utc; """; versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion); versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O")); await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } private static async Task ExecuteNonQueryAsync( SqliteConnection connection, SqliteTransaction transaction, string commandText, CancellationToken cancellationToken) { await using SqliteCommand command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = commandText; await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } }