using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Infrastructure.Postgres.Options; using System.Reflection; namespace StellaOps.Infrastructure.Postgres.Migrations; /// /// Extension methods for registering migration services. /// public static class MigrationServiceExtensions { /// /// Adds a startup migration host for the specified module. /// /// Options type containing the connection string. /// Service collection. /// PostgreSQL schema name for this module. /// Module name for logging. /// Assembly containing embedded SQL migrations. /// Function to extract connection string from options. /// Optional configuration for migration behavior. /// Service collection for chaining. public static IServiceCollection AddStartupMigrations( this IServiceCollection services, string schemaName, string moduleName, Assembly migrationsAssembly, Func connectionStringSelector, Action? configureOptions = null) where TOptions : class { var migrationOptions = new StartupMigrationOptions(); configureOptions?.Invoke(migrationOptions); services.AddHostedService(sp => { var options = sp.GetRequiredService>().Value; var connectionString = connectionStringSelector(options); var logger = sp.GetRequiredService().CreateLogger($"Migration.{moduleName}"); var lifetime = sp.GetRequiredService(); return new GenericStartupMigrationHost( connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, migrationOptions); }); return services; } /// /// Adds a startup migration host using PostgresOptions. /// public static IServiceCollection AddStartupMigrations( this IServiceCollection services, string schemaName, string moduleName, Assembly migrationsAssembly, Action? configureOptions = null) { return services.AddStartupMigrations( schemaName, moduleName, migrationsAssembly, options => options.ConnectionString, configureOptions); } /// /// Adds a migration runner as a singleton for manual/CLI migration execution. /// public static IServiceCollection AddMigrationRunner( this IServiceCollection services, string schemaName, string moduleName, Func connectionStringSelector) where TOptions : class { services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; var connectionString = connectionStringSelector(options); var logger = sp.GetRequiredService().CreateLogger($"Migration.{moduleName}"); return new MigrationRunner(connectionString, schemaName, moduleName, logger); }); return services; } /// /// Adds the migration status service for querying migration state. /// public static IServiceCollection AddMigrationStatus( this IServiceCollection services, string schemaName, string moduleName, Assembly migrationsAssembly, Func connectionStringSelector, string? resourcePrefix = null) where TOptions : class { services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; var connectionString = connectionStringSelector(options); var logger = sp.GetRequiredService().CreateLogger($"MigrationStatus.{moduleName}"); return new MigrationStatusService( connectionString, schemaName, moduleName, migrationsAssembly, logger, resourcePrefix); }); return services; } private sealed class GenericStartupMigrationHost : StartupMigrationHost { public GenericStartupMigrationHost( string connectionString, string schemaName, string moduleName, Assembly migrationsAssembly, ILogger logger, IHostApplicationLifetime lifetime, StartupMigrationOptions options) : base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options) { } } } /// /// Service for querying migration status without applying migrations. /// public interface IMigrationStatusService { /// /// Gets the current migration status for the module. /// Task GetStatusAsync(CancellationToken cancellationToken = default); } /// /// Current migration status for a module. /// public sealed record MigrationStatus { /// /// Module name. /// public required string ModuleName { get; init; } /// /// Schema name. /// public required string SchemaName { get; init; } /// /// Number of applied migrations. /// public int AppliedCount { get; init; } /// /// Number of pending startup migrations. /// public int PendingStartupCount { get; init; } /// /// Number of pending release migrations. /// public int PendingReleaseCount { get; init; } /// /// Last applied migration name. /// public string? LastAppliedMigration { get; init; } /// /// When the last migration was applied. /// public DateTimeOffset? LastAppliedAt { get; init; } /// /// List of pending migrations. /// public IReadOnlyList PendingMigrations { get; init; } = []; /// /// Any checksum mismatches detected. /// public IReadOnlyList ChecksumErrors { get; init; } = []; /// /// Whether the database is up to date (no pending startup/release migrations). /// public bool IsUpToDate => PendingStartupCount == 0 && PendingReleaseCount == 0; /// /// Whether there are blocking issues (pending release migrations or checksum errors). /// public bool HasBlockingIssues => PendingReleaseCount > 0 || ChecksumErrors.Count > 0; } /// /// Information about a pending migration. /// public sealed record PendingMigrationInfo(string Name, MigrationCategory Category); /// /// Migration source descriptor (assembly + optional resource prefix). /// public sealed record MigrationAssemblySource( Assembly MigrationsAssembly, string? ResourcePrefix = null); /// /// Implementation of migration status service. /// public sealed class MigrationStatusService : IMigrationStatusService { private readonly string _connectionString; private readonly string _schemaName; private readonly string _moduleName; private readonly IReadOnlyList _migrationSources; private readonly ILogger _logger; public MigrationStatusService( string connectionString, string schemaName, string moduleName, Assembly migrationsAssembly, ILogger logger, string? resourcePrefix = null) : this( connectionString, schemaName, moduleName, [new MigrationAssemblySource(migrationsAssembly, resourcePrefix)], logger) { } public MigrationStatusService( string connectionString, string schemaName, string moduleName, IReadOnlyList migrationSources, ILogger logger) { _connectionString = connectionString; _schemaName = schemaName; _moduleName = moduleName; _migrationSources = migrationSources is null || migrationSources.Count == 0 ? throw new ArgumentException("At least one migration source is required.", nameof(migrationSources)) : migrationSources; _logger = logger; } public async Task GetStatusAsync(CancellationToken cancellationToken = default) { await using var connection = new Npgsql.NpgsqlConnection(_connectionString); await connection.OpenAsync(cancellationToken).ConfigureAwait(false); // Check if migrations table exists var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken).ConfigureAwait(false); var applied = new Dictionary(StringComparer.Ordinal); if (tableExists) { applied = await GetAppliedMigrationsAsync(connection, cancellationToken).ConfigureAwait(false); } // Load all migrations from assembly var allMigrations = LoadMigrationsFromAssembly(); // Find pending and validate checksums var pending = new List(); var checksumErrors = new List(); foreach (var migration in allMigrations) { if (applied.TryGetValue(migration.Name, out var appliedInfo)) { if (!string.Equals(migration.Checksum, appliedInfo.Checksum, StringComparison.Ordinal)) { checksumErrors.Add( $"Checksum mismatch for '{migration.Name}': " + $"expected '{migration.Checksum[..16]}...', found '{appliedInfo.Checksum[..16]}...'"); } } else { pending.Add(new PendingMigrationInfo(migration.Name, migration.Category)); } } var lastApplied = applied .OrderByDescending(kvp => kvp.Value.AppliedAt) .FirstOrDefault(); return new MigrationStatus { ModuleName = _moduleName, SchemaName = _schemaName, AppliedCount = applied.Count, PendingStartupCount = pending.Count(p => p.Category.IsAutomatic()), PendingReleaseCount = pending.Count(p => p.Category.RequiresManualExecution()), LastAppliedMigration = lastApplied.Key, LastAppliedAt = lastApplied.Key is not null ? lastApplied.Value.AppliedAt : null, PendingMigrations = pending, ChecksumErrors = checksumErrors }; } private async Task CheckMigrationsTableExistsAsync( Npgsql.NpgsqlConnection connection, CancellationToken cancellationToken) { await using var command = new Npgsql.NpgsqlCommand( """ SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = @schema AND table_name = 'schema_migrations' ) """, connection); command.Parameters.AddWithValue("schema", _schemaName); var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); return result is true; } private async Task> GetAppliedMigrationsAsync( Npgsql.NpgsqlConnection connection, CancellationToken cancellationToken) { var result = new Dictionary(StringComparer.Ordinal); await using var command = new Npgsql.NpgsqlCommand( $"SELECT migration_name, checksum, applied_at FROM {_schemaName}.schema_migrations", connection); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { result[reader.GetString(0)] = (reader.GetString(1), reader.GetFieldValue(2)); } return result; } private List<(string Name, MigrationCategory Category, string Checksum)> LoadMigrationsFromAssembly() { var migrations = new Dictionary(StringComparer.Ordinal); foreach (var source in _migrationSources) { var resourceNames = source.MigrationsAssembly.GetManifestResourceNames() .Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) .Where(name => string.IsNullOrWhiteSpace(source.ResourcePrefix) || name.Contains(source.ResourcePrefix, StringComparison.OrdinalIgnoreCase)) .OrderBy(name => name, StringComparer.Ordinal); foreach (var resourceName in resourceNames) { using var stream = source.MigrationsAssembly.GetManifestResourceStream(resourceName); if (stream is null) { continue; } using var reader = new StreamReader(stream); var content = reader.ReadToEnd(); var fileName = ExtractFileName(resourceName); var category = MigrationCategoryExtensions.GetCategory(fileName); var checksum = ComputeChecksum(content); if (migrations.TryGetValue(fileName, out var existing)) { if (!string.Equals(existing.Checksum, checksum, StringComparison.Ordinal)) { throw new InvalidOperationException( $"Duplicate migration name '{fileName}' discovered across migration sources for module '{_moduleName}'."); } continue; } migrations[fileName] = (category, checksum); } } return migrations .OrderBy(static pair => pair.Key, StringComparer.Ordinal) .Select(static pair => (pair.Key, pair.Value.Category, pair.Value.Checksum)) .ToList(); } private static string ExtractFileName(string resourceName) { var parts = resourceName.Split('.'); for (var i = parts.Length - 1; i >= 0; i--) { if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase)) { return i > 0 ? $"{parts[i - 1]}.sql" : parts[i]; } } return resourceName; } private static string ComputeChecksum(string content) { var normalized = content.Replace("\r\n", "\n").Replace("\r", "\n"); var bytes = System.Text.Encoding.UTF8.GetBytes(normalized); var hash = System.Security.Cryptography.SHA256.HashData(bytes); return Convert.ToHexStringLower(hash); } }