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);
}
}