diff --git a/src/Commons/Hearth.Database/DatabaseUpgrade.cs b/src/Commons/Hearth.Database/DatabaseUpgrade.cs new file mode 100644 index 0000000..9009b56 --- /dev/null +++ b/src/Commons/Hearth.Database/DatabaseUpgrade.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using DbUp; +using Microsoft.Extensions.Hosting; +using Npgsql; + +namespace Hearth.Database; + +// A hosted service so building the app (e.g. a schema export in CI) needs no database. +// Not safe for two processes upgrading the same schema at once; Hearth.Api runs one replica with Recreate until the worker split. +internal sealed class DatabaseUpgrade( + string connectionString, + string schema, + Assembly scripts +) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + using (var connection = new NpgsqlConnection(connectionString)) + { + connection.Open(); + using var command = new NpgsqlCommand($"CREATE SCHEMA IF NOT EXISTS {schema}", connection); + command.ExecuteNonQuery(); + } + + var result = DeployChanges.To + .PostgresqlDatabase(connectionString) + .JournalToPostgresqlTable(schema, "schema_versions") + .WithScriptsEmbeddedInAssembly(scripts) + .WithTransactionPerScript() + .LogToConsole() + .Build() + .PerformUpgrade(); + + if (!result.Successful) + { + throw new InvalidOperationException($"Database upgrade of schema '{schema}' failed.", result.Error); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Commons/Hearth.Database/Extensions/ServiceCollectionExtensions.cs b/src/Commons/Hearth.Database/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c4b0adc --- /dev/null +++ b/src/Commons/Hearth.Database/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Hearth.Database.Extensions; + +public static class ServiceCollectionExtensions +{ + private const string ConnectionStringName = "Postgres"; + + // Registers a module's DbContext and upgrades its schema at startup with the SQL scripts embedded in the context's assembly. + public static IServiceCollection AddHearthDatabase(this IServiceCollection services, string schema) + where TContext : DbContext + { + services.AddPooledDbContextFactory((provider, options) => options + .UseNpgsql(ConnectionString(provider))); + services.AddScoped(provider => provider + .GetRequiredService>() + .CreateDbContext()); + + services.AddHostedService(provider => new DatabaseUpgrade( + ConnectionString(provider), + schema, + typeof(TContext).Assembly + )); + + return services; + } + + private static string ConnectionString(IServiceProvider provider) => + provider + .GetRequiredService() + .GetConnectionString(ConnectionStringName) + ?? throw new InvalidOperationException($"ConnectionStrings:{ConnectionStringName} is not configured."); +} diff --git a/src/Commons/Hearth.Database/Hearth.Database.csproj b/src/Commons/Hearth.Database/Hearth.Database.csproj new file mode 100644 index 0000000..55ccb5f --- /dev/null +++ b/src/Commons/Hearth.Database/Hearth.Database.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 51ec396..64daa14 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -4,8 +4,12 @@ true + + + + diff --git a/src/Hearth.slnx b/src/Hearth.slnx index 19a0f6f..591ab84 100644 --- a/src/Hearth.slnx +++ b/src/Hearth.slnx @@ -11,6 +11,7 @@ + diff --git a/src/Hosts/Hearth.AppHost/AppHost.cs b/src/Hosts/Hearth.AppHost/AppHost.cs index 94e40ed..05bcb53 100644 --- a/src/Hosts/Hearth.AppHost/AppHost.cs +++ b/src/Hosts/Hearth.AppHost/AppHost.cs @@ -1,5 +1,15 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.AddProject("api"); +var postgres = builder + .AddPostgres("postgres") + .WithDataVolume() + .WithLifetime(ContainerLifetime.Persistent); + +var database = postgres.AddDatabase("hearth"); + +builder + .AddProject("api") + .WithReference(database, "Postgres") + .WaitFor(database); builder.Build().Run(); diff --git a/src/Hosts/Hearth.AppHost/Hearth.AppHost.csproj b/src/Hosts/Hearth.AppHost/Hearth.AppHost.csproj index 1c0bb5c..7b75008 100644 --- a/src/Hosts/Hearth.AppHost/Hearth.AppHost.csproj +++ b/src/Hosts/Hearth.AppHost/Hearth.AppHost.csproj @@ -6,6 +6,10 @@ 388fbdf7-df04-4c75-858e-1bdc720d2a91 + + + +