Simplify plugin database contexts (#7611)
What changed, and why it matters
This commit is a code cleanup and developer-experience improvement for how BTCPay Server plugins manage their PostgreSQL databases. It introduces a shared helper and base class so plugin authors no longer need to manually wire up database connections, retry logic, and migration history tables. There is no obvious security bug introduced, but any change to database setup code can affect reliability and migration behavior, so it warrants a careful look rather than being dismissed as purely cosmetic.
Treat as a normal refactor review. Verify that the centralized `UseBTCPayServerDatabase` extension preserves the previous migration-history table naming and search-path behavior for existing plugins, and that the new `DbContextMigrationExecutor` runs plugin migrations in the expected order relative to core migrations. Confirm the `WasabiWalletFileParser` null check does not change valid parsing behavior. No immediate security response is indicated.
Security signals we found
Database configuration logic moved into shared extension; reduces copy-paste errors in plugins
Custom CREATE DATABASE generator preserved with hardcoded TEMPLATE template0, LC_CTYPE C, LC_COLLATE C, ENCODING UTF8
New design-time default connection string uses fixed host 127.0.0.1:39372 and database btcpay_plugin_design_time
BasePluginDbContext throws if instantiated outside DI/design-time, reducing accidental direct construction
WasabiWalletFileParser adds null check for ExtPubKey before parsing
Evidence from the diff
The change refactors plugin database context setup. A new UseBTCPayServerDatabase extension centralizes Npgsql configuration (retry policy, Postgres 14 version pinning, migrations history table placement, and a custom CREATE DATABASE SQL generator that forces C locale/UTF8). A new BasePluginDbContext<TContext> class gives plugins a design-time-safe context base, and AddPluginDbContext<TDbContext> registers the factory, scoped context, and a startup DbContextMigrationExecutor<TDbContext>. Existing BaseDbContextFactory<T> is simplified to delegate to the new extension. The diff also includes nullable-reference-type annotations and a small null-check fix in WasabiWalletFileParser.
Changed components
BTCPayServer.Abstractions/Contracts/BaseDbContextFactory.csBTCPayServer.Abstractions/Contracts/BasePluginDbContext.csBTCPayServer.Abstractions/Extensions/DbContextOptionsBuilderExtensions.csBTCPayServer/Data/MigrationExecutor.csBTCPayServer/Extensions.csBTCPayServer/Services/WalletFileParsing/WasabiWalletFileParser.csBTCPayServer.Tests/PluginDbContextTests.csdocs/developers/plugins/data-migrations.mddocs/maintainers/coding-conventions.mdInspect captured patch +400 / −136
### BTCPayServer.Abstractions/Contracts/BaseDbContextFactory.cs
@@ -1,13 +1,9 @@
using System;
+using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Metadata;
-using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Options;
-using Npgsql;
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Operations;
namespace BTCPayServer.Abstractions.Contracts
{
@@ -24,71 +20,13 @@ public BaseDbContextFactory(IOptions<DatabaseOptions> options, string migrationT
public T CreateContext() => CreateContext(null);
public abstract T CreateContext(Action<NpgsqlDbContextOptionsBuilder> npgsqlOptionsAction = null);
- class CustomNpgsqlMigrationsSqlGenerator : NpgsqlMigrationsSqlGenerator
- {
-#pragma warning disable EF1001 // Internal EF Core API usage.
- public CustomNpgsqlMigrationsSqlGenerator(MigrationsSqlGeneratorDependencies dependencies, Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal.INpgsqlSingletonOptions opts) : base(dependencies, opts)
-#pragma warning restore EF1001 // Internal EF Core API usage.
- {
- }
-
- protected override void Generate(NpgsqlCreateDatabaseOperation operation, IModel model, MigrationCommandListBuilder builder)
- {
- builder
- .Append("CREATE DATABASE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name));
-
- // POSTGRES gotcha: Indexed Text column (even if PK) are not used if we are not using C locale
- builder
- .Append(" TEMPLATE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier("template0"));
-
- builder
- .Append(" LC_CTYPE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier("C"));
-
- builder
- .Append(" LC_COLLATE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier("C"));
-
- builder
- .Append(" ENCODING ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier("UTF8"));
-
- if (operation.Tablespace != null)
- {
- builder
- .Append(" TABLESPACE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Tablespace));
- }
-
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- EndStatement(builder, suppressTransaction: true);
- }
- }
-
public void ConfigureBuilder(DbContextOptionsBuilder builder) => ConfigureBuilder(builder, null);
public void ConfigureBuilder(DbContextOptionsBuilder builder, Action<NpgsqlDbContextOptionsBuilder> npgsqlOptionsAction = null)
{
- builder
- .UseNpgsql(_options.Value.ConnectionString, o =>
- {
- o.EnableRetryOnFailure(10);
- o.SetPostgresVersion(12, 0);
- npgsqlOptionsAction?.Invoke(o);
- var mainSearchPath = GetSearchPath(_options.Value.ConnectionString);
- var schemaPrefix = string.IsNullOrEmpty(_migrationTableName) ? "__EFMigrationsHistory" : _migrationTableName;
- o.MigrationsHistoryTable(schemaPrefix, mainSearchPath);
- })
- .ReplaceService<IMigrationsSqlGenerator, CustomNpgsqlMigrationsSqlGenerator>();
- }
-
- private string GetSearchPath(string connectionString)
- {
- var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString);
- var searchPaths = connectionStringBuilder.SearchPath?.Split(',');
- return searchPaths is not { Length: > 0 } ? null : searchPaths[0];
+ builder.UseBTCPayServerDatabase(
+ _options.Value.ConnectionString,
+ _migrationTableName,
+ npgsqlOptionsAction);
}
T IDbContextFactory<T>.CreateDbContext()
### BTCPayServer.Abstractions/Contracts/BasePluginDbContext.cs
@@ -0,0 +1,63 @@
+#nullable enable
+using System;
+using BTCPayServer.Abstractions.Extensions;
+using Microsoft.EntityFrameworkCore;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
+
+namespace BTCPayServer.Abstractions.Contracts;
+
+/// <summary>
+/// Identifies a plugin database context and its migration-history table.
+/// </summary>
+[AttributeUsage(AttributeTargets.Class, Inherited = false)]
+public sealed class PluginDatabaseAttribute(string? migrationHistoryTableName) : Attribute
+{
+ public string? MigrationHistoryTableName { get; } = migrationHistoryTableName;
+ public static string? GetMigrationHistoryTableName(Type type)
+ => ((PluginDatabaseAttribute?)Attribute.GetCustomAttribute(
+ type,
+ typeof(PluginDatabaseAttribute)))?.MigrationHistoryTableName;
+}
+
+/// <summary>
+/// Provides runtime-safe design-time configuration for a plugin database context.
+/// </summary>
+public abstract class BasePluginDbContext<TContext> : DbContext
+ where TContext : BasePluginDbContext<TContext>
+{
+ private const string DefaultDesignTimeConnectionString =
+ "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=btcpay_plugin_design_time";
+
+ protected BasePluginDbContext()
+ {
+ }
+
+ protected BasePluginDbContext(DbContextOptions<TContext> options)
+ : base(options)
+ {
+ }
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ {
+ base.OnConfiguring(optionsBuilder);
+ if (optionsBuilder.IsConfigured)
+ {
+ optionsBuilder.UseNpgsql(ConfigureNpgsql);
+ return;
+ }
+
+ if (!EF.IsDesignTime)
+ {
+ throw new InvalidOperationException(
+ $"{typeof(TContext).FullName} must be created through dependency injection or IDbContextFactory<{typeof(TContext).Name}>.");
+ }
+ optionsBuilder.UseBTCPayServerDatabase(
+ DefaultDesignTimeConnectionString,
+ PluginDatabaseAttribute.GetMigrationHistoryTableName(typeof(TContext)),
+ ConfigureNpgsql);
+ }
+
+ protected virtual void ConfigureNpgsql(NpgsqlDbContextOptionsBuilder optionsBuilder)
+ {
+ }
+}
### BTCPayServer.Abstractions/Extensions/DbContextOptionsBuilderExtensions.cs
@@ -0,0 +1,96 @@
+#nullable enable
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Operations;
+
+namespace BTCPayServer.Abstractions.Extensions;
+
+public static class DbContextOptionsBuilderExtensions
+{
+ /// <summary>
+ /// Configures an EF Core context with BTCPay Server's PostgreSQL conventions.
+ /// </summary>
+ public static DbContextOptionsBuilder UseBTCPayServerDatabase(
+ this DbContextOptionsBuilder builder,
+ string connectionString,
+ string? migrationHistoryTableName = null,
+ Action<NpgsqlDbContextOptionsBuilder>? npgsqlOptionsAction = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(connectionString);
+
+ return builder
+ .UseNpgsql(connectionString, options =>
+ {
+ options.EnableRetryOnFailure(10);
+ options.SetPostgresVersion(14, 0);
+ npgsqlOptionsAction?.Invoke(options);
+ var historyTableName = string.IsNullOrEmpty(migrationHistoryTableName)
+ ? "__EFMigrationsHistory"
+ : migrationHistoryTableName;
+ options.MigrationsHistoryTable(historyTableName, GetSearchPath(connectionString));
+ })
+ .ReplaceService<IMigrationsSqlGenerator, CustomNpgsqlMigrationsSqlGenerator>();
+ }
+
+ private static string? GetSearchPath(string connectionString)
+ {
+ var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString);
+ var searchPaths = connectionStringBuilder.SearchPath?.Split(',');
+ return searchPaths is not { Length: > 0 } ? null : searchPaths[0];
+ }
+
+ private sealed class CustomNpgsqlMigrationsSqlGenerator : NpgsqlMigrationsSqlGenerator
+ {
+#pragma warning disable EF1001 // Internal EF Core API usage.
+ public CustomNpgsqlMigrationsSqlGenerator(
+ MigrationsSqlGeneratorDependencies dependencies,
+ Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal.INpgsqlSingletonOptions options)
+ : base(dependencies, options)
+#pragma warning restore EF1001 // Internal EF Core API usage.
+ {
+ }
+
+ protected override void Generate(
+ NpgsqlCreateDatabaseOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ builder
+ .Append("CREATE DATABASE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name));
+
+ // Indexed text columns are not used if PostgreSQL is not using the C locale.
+ builder
+ .Append(" TEMPLATE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier("template0"));
+
+ builder
+ .Append(" LC_CTYPE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier("C"));
+
+ builder
+ .Append(" LC_COLLATE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier("C"));
+
+ builder
+ .Append(" ENCODING ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier("UTF8"));
+
+ if (operation.Tablespace != null)
+ {
+ builder
+ .Append(" TABLESPACE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Tablespace));
+ }
+
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ EndStatement(builder, suppressTransaction: true);
+ }
+ }
+}
### BTCPayServer.Tests/PluginDbContextTests.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Npgsql;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
+using Xunit;
+
+namespace BTCPayServer.Tests;
+
+[Trait("Integration", "Integration")]
+public class PluginDbContextTests(ITestOutputHelper helper) : UnitTestBase(helper)
+{
+ [Fact]
+ public void CanCreatePluginDbContextAtDesignTimeOnly()
+ {
+ using (var context = new TestPluginDbContext())
+ {
+ var exception = Assert.Throws<InvalidOperationException>(() => context.Database.GetDbConnection());
+ Assert.Contains("must be created through dependency injection", exception.Message);
+ }
+
+ var wasDesignTime = EF.IsDesignTime;
+ try
+ {
+ EF.IsDesignTime = true;
+ using var context = new TestPluginDbContext();
+ var connectionString = new NpgsqlConnectionStringBuilder(context.Database.GetConnectionString());
+ Assert.Equal("127.0.0.1", connectionString.Host);
+ Assert.Equal(39372, connectionString.Port);
+ Assert.Equal("btcpay_plugin_design_time", connectionString.Database);
+ Assert.Equal(42, context.Database.GetCommandTimeout());
+ var history = context.Database.GetService<IHistoryRepository>();
+ Assert.Contains("TestPluginMigrations", history.GetCreateScript());
+ }
+ finally
+ {
+ EF.IsDesignTime = wasDesignTime;
+ }
+ }
+
+ [Fact]
+ public async Task CanRegisterAndMigratePluginDbContext()
+ {
+ var database = CreateDBTester();
+ var services = new ServiceCollection();
+ services.AddSingleton<ILoggerFactory>(LoggerFactory);
+ services.AddSingleton<IOptions<DatabaseOptions>>(Options.Create(new DatabaseOptions
+ {
+ ConnectionString = database.ConnectionString
+ }));
+ services.AddPluginDbContext<TestPluginDbContext>();
+ services.AddMigration<TestPluginDbContext, SeedWidgetsMigration>();
+
+ await using var provider = services.BuildServiceProvider();
+ var factory = provider.GetRequiredService<IDbContextFactory<TestPluginDbContext>>();
+ await using var scope = provider.CreateAsyncScope();
+ Assert.IsType<TestPluginDbContext>(scope.ServiceProvider.GetRequiredService<TestPluginDbContext>());
+ var executors = provider.GetServices<IMigrationExecutor>().ToArray();
+ Assert.Collection(
+ executors,
+ executor => Assert.IsType<DbContextMigrationExecutor<TestPluginDbContext>>(executor),
+ executor => Assert.IsType<MigrationExecutor<TestPluginDbContext>>(executor));
+ foreach (var executor in executors)
+ await executor.Execute(CancellationToken.None);
+ foreach (var executor in executors)
+ await executor.Execute(CancellationToken.None);
+
+ await using var context = await factory.CreateDbContextAsync();
+ Assert.Equal(42, context.Database.GetCommandTimeout());
+ var history = context.Database.GetService<IHistoryRepository>();
+ var appliedMigrations = await history.GetAppliedMigrationsAsync();
+ Assert.Contains(appliedMigrations, migration => migration.MigrationId == CreatePluginWidgetsMigration.Id);
+ Assert.Contains(appliedMigrations, migration => migration.MigrationId == SeedWidgetsMigration.Id);
+ Assert.Equal(1, await context.Database.ExecuteSqlRawAsync("DELETE FROM \"PluginWidgets\""));
+ }
+
+ [PluginDatabase("TestPluginMigrations")]
+ public class TestPluginDbContext(DbContextOptions<TestPluginDbContext> options)
+ : BasePluginDbContext<TestPluginDbContext>(options)
+ {
+ public TestPluginDbContext()
+ : this(new DbContextOptions<TestPluginDbContext>())
+ {
+ }
+
+ protected override void ConfigureNpgsql(NpgsqlDbContextOptionsBuilder optionsBuilder)
+ {
+ optionsBuilder.CommandTimeout(42);
+ }
+ }
+
+ [DbContext(typeof(TestPluginDbContext))]
+ [Migration(Id)]
+ public class CreatePluginWidgetsMigration : Migration
+ {
+ public const string Id = "20260929000000_CreatePluginWidgets";
+
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ "PluginWidgets",
+ table => new
+ {
+ Id = table.Column<string>(nullable: false)
+ },
+ constraints: table => table.PrimaryKey("PK_PluginWidgets", row => row.Id));
+ }
+ }
+
+ public class SeedWidgetsMigration() : MigrationBase<TestPluginDbContext>(Id)
+ {
+ public const string Id = "20260929000001_SeedWidgets";
+
+ public override Task MigrateAsync(TestPluginDbContext dbContext, CancellationToken cancellationToken)
+ {
+ return dbContext.Database.ExecuteSqlRawAsync(
+ "INSERT INTO \"PluginWidgets\" (\"Id\") VALUES ('widget')",
+ cancellationToken);
+ }
+ }
+}
### BTCPayServer/Data/MigrationExecutor.cs
@@ -15,6 +15,17 @@ public interface IMigrationExecutor
Task Execute(CancellationToken cancellationToken);
}
+public class DbContextMigrationExecutor<TDbContext>(IDbContextFactory<TDbContext> dbContextFactory) : IMigrationExecutor
+ where TDbContext : DbContext
+{
+ public async Task Execute(CancellationToken cancellationToken)
+ {
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ dbContext.Database.SetCommandTimeout(TimeSpan.FromDays(1.0));
+ await dbContext.Database.MigrateAsync(cancellationToken);
+ }
+}
+
public class MigrationExecutor<TDbContext>(
ILoggerFactory loggerFactory,
IDbContextFactory<TDbContext> dbContextFactory,
### BTCPayServer/Extensions.cs
@@ -1,8 +1,10 @@
+#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -50,13 +52,15 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.Payment;
using NBitcoin.RPC;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
using InvoiceCryptoInfo = BTCPayServer.Services.Invoices.InvoiceCryptoInfo;
namespace BTCPayServer
@@ -169,7 +173,7 @@ public static bool TryParseXpub(this DerivationSchemeParser derivationSchemePars
// Extract fingerprint and account key path from export formats that contain them.
// Possible formats: [fingerprint/account_key_path]xpub, [fingerprint]xpub, xpub
HDFingerprint? rootFingerprint = null;
- KeyPath accountKeyPath = null;
+ KeyPath? accountKeyPath = null;
var derivationRegex = new Regex(@"^(?:\[(\w+)(?:\/(.*?))?\])?(\w+)$", RegexOptions.IgnoreCase);
var match = derivationRegex.Match(xpub.Trim());
if (match.Success)
@@ -195,7 +199,7 @@ public static bool TryParseXpub(this DerivationSchemeParser derivationSchemePars
{
derivationSchemeSettings.AccountKeySettings[0].RootFingerprint = rootFingerprint;
}
- if (accountKeyPath != null && derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath == null)
+ if (accountKeyPath is not null && derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath == null)
{
derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath = accountKeyPath;
}
@@ -231,12 +235,10 @@ public static Task<BufferizedFormFile> Bufferize(this IFormFile formFile)
/// </summary>
/// <param name="uriString">The Uri string.</param>
/// <returns>Unescaped back slash Uri string.</returns>
- public static string UnescapeBackSlashUriString(string uriString)
+ public static string? UnescapeBackSlashUriString(string? uriString)
{
if (uriString == null)
- {
return null;
- }
return uriString.Replace("%2f", "%2F").Replace("%2F", "/");
}
public static bool IsValidEmail(this string email)
@@ -249,15 +251,15 @@ public static bool IsValidEmail(this string email)
return MailboxAddressValidator.TryParse(email, out var ma) && ma.ToString() == ma.Address;
}
- public static bool TryGetPayjoinEndpoint(this BitcoinUrlBuilder bip21, out Uri endpoint)
+ public static bool TryGetPayjoinEndpoint(this BitcoinUrlBuilder bip21, [MaybeNullWhen(false)] out Uri endpoint)
{
endpoint = bip21.UnknownParameters.TryGetValue($"{PayjoinClient.BIP21EndpointKey}", out var uri) ? new Uri(uri, UriKind.Absolute) : null;
return endpoint != null;
}
[Obsolete("Use GetServerUri(this ILightningClient client, string connectionString) instead")]
- public static Uri GetServerUri(this ILightningClient client) => GetServerUri(client, client.ToString());
- public static Uri GetServerUri(this ILightningClient client, string connectionString)
+ public static Uri? GetServerUri(this ILightningClient client) => GetServerUri(client, client.ToString()!);
+ public static Uri? GetServerUri(this ILightningClient client, string connectionString)
{
if (client is IExtendedLightningClient { ServerUri: { } uri })
return uri;
@@ -266,7 +268,7 @@ public static Uri GetServerUri(this ILightningClient client, string connectionSt
}
[Obsolete("Use GetDisplayName(this ILightningClient client, string connectionString) instead")]
- public static string GetDisplayName(this ILightningClient client) => GetDisplayName(client, client.ToString());
+ public static string GetDisplayName(this ILightningClient client) => GetDisplayName(client, client.ToString()!);
public static string GetDisplayName(this ILightningClient client, string connectionString)
=> client switch
@@ -280,7 +282,7 @@ _ when ExtractValues(connectionString).TryGetValue("type", out var t) => t,
_ => client.GetType().Name
};
- private static bool TryParseLegacy(string str, out Dictionary<string, string> connectionString)
+ private static bool TryParseLegacy(string str, [MaybeNullWhen(false)] out Dictionary<string, string> connectionString)
{
if (str.StartsWith("/"))
{
@@ -289,7 +291,7 @@ private static bool TryParseLegacy(string str, out Dictionary<string, string> co
Dictionary<string, string> dictionary = new Dictionary<string, string>();
connectionString = null;
- if (!Uri.TryCreate(str, UriKind.Absolute, out Uri result))
+ if (!Uri.TryCreate(str, UriKind.Absolute, out var result))
{
return false;
}
@@ -366,7 +368,7 @@ static Dictionary<string, string> ExtractValues(string connectionString)
}
[Obsolete("Use IsSafe(this ILightningClient client, string connectionString) instead")]
- public static bool IsSafe(this ILightningClient client) => IsSafe(client, client.ToString());
+ public static bool IsSafe(this ILightningClient client) => IsSafe(client, client.ToString()!);
public static bool IsSafe(this ILightningClient client, string connectionString) => IsSafeLightningConnectionString(connectionString);
public static bool IsSafeLightningConnectionString(string connectionString)
{
@@ -482,20 +484,52 @@ public static IServiceCollection AddMigration<TDbContext, TMigration>(this IServ
return services;
}
- public static IServiceCollection AddPolicyDefinitions(this IServiceCollection services, params PolicyDefinition[] definitions)
+ /// <summary>
+ /// Registers a plugin-owned database context and applies its migrations during startup.
+ /// Call this before registering data migrations for the context.
+ /// </summary>
+ public static IServiceCollection AddPluginDbContext<TDbContext>(this IServiceCollection services)
+ where TDbContext : BasePluginDbContext<TDbContext>
+ => services.AddPluginDbContext<TDbContext>(migrationHistoryTableName: null);
+
+ /// <summary>
+ /// Registers a plugin-owned database context and applies its migrations during startup.
+ /// Call this before registering data migrations for the context.
+ /// </summary>
+ public static IServiceCollection AddPluginDbContext<TDbContext>(
+ this IServiceCollection services,
+ string? migrationHistoryTableName,
+ Action<NpgsqlDbContextOptionsBuilder>? npgsqlOptionsAction = null)
+ where TDbContext : DbContext
+ {
+ services.AddDbContextFactory<TDbContext>((provider, builder) =>
+ {
+ var options = provider.GetRequiredService<IOptions<DatabaseOptions>>();
+ builder.UseBTCPayServerDatabase(
+ options.Value.ConnectionString,
+ migrationHistoryTableName ?? PluginDatabaseAttribute.GetMigrationHistoryTableName(typeof(TDbContext)),
+ npgsqlOptionsAction);
+ });
+ services.TryAddEnumerable(
+ ServiceDescriptor.Singleton<IMigrationExecutor, DbContextMigrationExecutor<TDbContext>>());
+ return services;
+ }
+
+ public static IServiceCollection AddPolicyDefinitions(this IServiceCollection services, params PolicyDefinition?[]? definitions)
{
if (definitions == null)
return services;
foreach (var definition in definitions)
{
- if (definition != null)
+ if (definition is not null)
services.AddSingleton(definition);
}
var strings = definitions
+ .OfType<PolicyDefinition>()
.SelectMany(d => new[] {d.Display?.Title, d.Display?.Description, d.ScopeDisplay?.Title, d.ScopeDisplay?.Description})
.Where(d => d is not null)
.ToArray();
- services.AddDefaultTranslations(strings);
+ services.AddDefaultTranslations(strings!);
return services;
}
@@ -514,7 +548,7 @@ public static async Task CloseSocket(this WebSocket webSocket)
finally { try { webSocket.Dispose(); } catch { } }
}
- public static async Task<GetMempoolInfoResponse> GetMempoolInfo(this RPCClient rpc, CancellationToken cancellationToken)
+ public static async Task<GetMempoolInfoResponse?> GetMempoolInfo(this RPCClient rpc, CancellationToken cancellationToken)
{
var mempoolInfo = await rpc.SendCommandAsync(new RPCRequest("getmempoolinfo", [])
{
@@ -591,7 +625,7 @@ public static IEnumerable<BitcoinLikePaymentData> GetAllBitcoinPaymentData(this
return transactions.Select(t => t.Result).Where(t => t != null).ToDictionary(o => o.Transaction.GetHash());
}
- public static async Task<PSBT> UpdatePSBT(this ExplorerClientProvider explorerClientProvider, DerivationSchemeSettings derivationSchemeSettings, PSBT psbt)
+ public static async Task<PSBT?> UpdatePSBT(this ExplorerClientProvider explorerClientProvider, DerivationSchemeSettings derivationSchemeSettings, PSBT psbt)
{
var result = await explorerClientProvider.GetExplorerClient(psbt.Network.NetworkSet.CryptoCode).UpdatePSBTAsync(new UpdatePSBTRequest()
{
@@ -617,7 +651,7 @@ public static void SetHeaderOnStarting(this HttpResponse resp, string name, stri
});
}
- public static void SetHeader(this HttpResponse resp, string name, string value)
+ public static void SetHeader(this HttpResponse resp, string name, string? value)
{
var existing = resp.Headers[name].FirstOrDefault();
if (existing != null && value == null)
### BTCPayServer/Services/WalletFileParsing/WasabiWalletFileParser.cs
@@ -25,7 +25,7 @@ public bool TryParse(BTCPayNetwork network, string data, [MaybeNullWhen(false)]
var derivationSchemeParser = network.GetDerivationSchemeParser();
var result = new DerivationSchemeSettings();
- if (jobj is null || !derivationSchemeParser.TryParseXpub(jobj.ExtPubKey, ref result))
+ if (jobj is null || jobj.ExtPubKey is null || !derivationSchemeParser.TryParseXpub(jobj.ExtPubKey, ref result))
return false;
if (jobj.MasterFingerprint is not null)
### docs/developers/plugins/data-migrations.md
@@ -4,14 +4,18 @@ Own plugin data explicitly. Do not add plugin tables or migrations to BTCPay Ser
## Database context
-Use a plugin-specific EF Core `DbContext` and factory. Do not construct the runtime connection string yourself.
-
-The [Payroll plugin](https://github.com/rockstardev/BTCPayServerPlugins.RockstarDev/tree/master/Plugins/BTCPayServer.RockstarDev.Plugins.Payroll) provides a complete example. Its context accepts `DbContextOptions` so the factory and dependency injection can configure it:
+Use a plugin-specific EF Core `DbContext` derived from `BasePluginDbContext<TContext>`. The `PluginDatabase` attribute specifies the exact name of the plugin's migration-history table, so keep it stable and unique. The parameterless constructor allows `dotnet ef` to create the context, while the options constructor allows BTCPay Server to configure it at runtime:
```csharp
+[PluginDatabase("YourPlugin_Migrations")]
public class PluginDbContext(DbContextOptions<PluginDbContext> options)
- : DbContext(options)
+ : BasePluginDbContext<PluginDbContext>(options)
{
+ public PluginDbContext()
+ : this(new DbContextOptions<PluginDbContext>())
+ {
+ }
+
public DbSet<Widget> Widgets { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -22,41 +26,32 @@ public class PluginDbContext(DbContextOptions<PluginDbContext> options)
}
```
-Create a factory derived from `BaseDbContextFactory<T>`. It supplies BTCPay Server's PostgreSQL connection, retry behavior, and migration-history configuration. The name passed to the base constructor identifies your plugin's migration history table, so keep it stable and unique:
+Register the context from the plugin's `Execute` method:
```csharp
-public class PluginDbContextFactory(IOptions<DatabaseOptions> options)
- : BaseDbContextFactory<PluginDbContext>(options, "YourPlugin")
-{
- public override PluginDbContext CreateContext(
- Action<NpgsqlDbContextOptionsBuilder> npgsqlOptionsAction = null)
- {
- var builder = new DbContextOptionsBuilder<PluginDbContext>();
- ConfigureBuilder(builder, npgsqlOptionsAction);
- return new PluginDbContext(builder.Options);
- }
-}
+serviceCollection.AddPluginDbContext<PluginDbContext>();
```
-Register both the factory and the context from the plugin's `Execute` method:
+`AddPluginDbContext` configures BTCPay Server's PostgreSQL connection and retry behavior, registers the context as scoped, registers `IDbContextFactory<PluginDbContext>`, and runs the context's EF migrations during startup. A context's `HasDefaultSchema` setting does not change the schema of the migration-history table; the table uses the first search path from BTCPay Server's PostgreSQL connection.
+
+Inject `PluginDbContext` into scoped services. In singleton or background services, inject `IDbContextFactory<PluginDbContext>` and create and dispose a context for each unit of work:
```csharp
-serviceCollection.AddSingleton<PluginDbContextFactory>();
-serviceCollection.AddDbContext<PluginDbContext>((provider, builder) =>
+public class WidgetProcessor(IDbContextFactory<PluginDbContext> contextFactory)
{
- var factory = provider.GetRequiredService<PluginDbContextFactory>();
- factory.ConfigureBuilder(builder);
-});
-serviceCollection.AddHostedService<PluginMigrationRunner>();
+ public async Task Process(CancellationToken cancellationToken)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
+ // Use the context for this unit of work.
+ }
+}
```
-Inject `PluginDbContext` into scoped services. In singleton or background services, inject `PluginDbContextFactory` and call `CreateContext()` for each unit of work. A startup migration runner can do the same and call `context.Database.MigrateAsync(cancellationToken)`. See Payroll's [`PluginDbContextFactory`](https://github.com/rockstardev/BTCPayServerPlugins.RockstarDev/blob/master/Plugins/BTCPayServer.RockstarDev.Plugins.Payroll/Data/PluginDbContextFactory.cs), [service registration](https://github.com/rockstardev/BTCPayServerPlugins.RockstarDev/blob/master/Plugins/BTCPayServer.RockstarDev.Plugins.Payroll/Program.cs), and [`PluginMigrationRunner`](https://github.com/rockstardev/BTCPayServerPlugins.RockstarDev/blob/master/Plugins/BTCPayServer.RockstarDev.Plugins.Payroll/Data/PluginMigrationRunner.cs).
-
-For `dotnet ef`, add an `IDesignTimeDbContextFactory<PluginDbContext>` that builds the context with a development PostgreSQL connection. EF uses this factory only while generating migrations; the runtime factory still supplies the server's configured connection. See Payroll's [`DesignTimeDbContextFactory`](https://github.com/rockstardev/BTCPayServerPlugins.RockstarDev/blob/master/Plugins/BTCPayServer.RockstarDev.Plugins.Payroll/Data/DesignTimeDbContextFactory.cs).
+`dotnet ef` requires the Docker Compose test environment to be running. See [local development and testing](../../maintainers/local-development.md#test-environment) for startup instructions.
## Create a migration
-Use the .NET target framework and EF Core package versions declared by the [`BTCPayServer.Data.csproj`](https://github.com/btcpayserver/btcpayserver/blob/master/BTCPayServer.Data/BTCPayServer.Data.csproj) project referenced by your plugin. From the plugin repository root:
+Use the .NET target framework declared by [`Build/Common.csproj`](https://github.com/btcpayserver/btcpayserver/blob/master/Build/Common.csproj) and the EF Core and Npgsql package versions declared by [`BTCPayServer.Abstractions.csproj`](https://github.com/btcpayserver/btcpayserver/blob/master/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj) in the BTCPay Server version referenced by your plugin. Use the matching `Microsoft.EntityFrameworkCore.Design` version. From the plugin repository root:
1. Update the plugin model.
2. Generate the migration, specifying the plugin project, context, and output directory:
@@ -73,30 +68,21 @@ Use the .NET target framework and EF Core package versions declared by the [`BTC
5. Remove the `Down()` method.
6. Review the migration, model snapshot, and generated SQL implications, then commit the migration and snapshot with the model change.
-The Payroll plugin keeps its project under `Plugins/BTCPayServer.RockstarDev.Plugins.Payroll`, so its equivalent generation command is:
-
-```sh
-dotnet ef migrations add <migration-name> \
- --project Plugins/BTCPayServer.RockstarDev.Plugins.Payroll \
- --context PluginDbContext \
- --output-dir Data/Migrations
-```
-
Plugin migrations target PostgreSQL. Do not use `migrationBuilder.IsNpgsql()`, and follow PostgreSQL naming conventions. Never edit or remove a migration already shipped to users; add a forward migration instead. Test both installation into an empty database and an upgrade from the previous plugin schema when a change has meaningful data or compatibility risk.
## Run migrations
-Run generated EF migrations at startup through the registered `PluginMigrationRunner`, which creates a context from `PluginDbContextFactory` and calls `context.Database.MigrateAsync(cancellationToken)`.
+`AddPluginDbContext` runs generated EF migrations during BTCPay Server's migration startup phase. Do not add a separate hosted migration runner.
-For startup data migrations outside the EF schema history, use the migration registration contracts exposed by BTCPay Server, such as `AddMigration<TDbContext, TMigration>`. The generic context must have a registered `IDbContextFactory<TDbContext>`. Keep migration identifiers unique within that context and ordered; date-prefixed identifiers are recommended for raw SQL migrations.
-
-If you use those contracts with the plugin context, register the same factory through the interface as well:
+For data migrations implemented with `MigrationBase<PluginDbContext>`, register the context before the migrations so that EF creates or updates the schema first:
```csharp
-serviceCollection.AddSingleton<IDbContextFactory<PluginDbContext>>(provider =>
- provider.GetRequiredService<PluginDbContextFactory>());
+serviceCollection.AddPluginDbContext<PluginDbContext>();
+serviceCollection.AddMigration<PluginDbContext, NormalizeWidgetsMigration>();
```
-Use repositories or focused data services around the context so controllers and background services do not leak context lifetimes. Do not retain a scoped context in a singleton; create a scope or use the registered factory for each unit of work.
+Migration executors run in registration order. Always call `AddPluginDbContext` before any `AddMigration` calls for the same context.
+
+Use repositories or focused data services around the context so controllers and background services do not leak context lifetimes. Do not retain a scoped context in a singleton; create a scope or use `IDbContextFactory<PluginDbContext>` for each unit of work.
-See [`BaseDbContextFactory<T>`](https://github.com/btcpayserver/btcpayserver/blob/master/BTCPayServer.Abstractions/Contracts/BaseDbContextFactory.cs) and the core [database migration conventions](https://github.com/btcpayserver/btcpayserver/blob/master/docs/maintainers/README.md#database-migrations).
+Existing plugins can continue using [`BaseDbContextFactory<T>`](https://github.com/btcpayserver/btcpayserver/blob/master/BTCPayServer.Abstractions/Contracts/BaseDbContextFactory.cs) and a custom migration runner. New plugins should prefer `AddPluginDbContext`. See the core [database migration conventions](https://github.com/btcpayserver/btcpayserver/blob/master/docs/maintainers/README.md#database-migrations).
### docs/maintainers/coding-conventions.md
@@ -47,12 +47,17 @@ new component selectors.
## Changelog
-Record user-visible features, fixes, regressions, deprecations, removals,
-security-relevant behavior, and compatibility changes in `Changelog.md`. Skip
-internal refactors, test-only changes, tooling changes unless users or release
-operators are affected, and entries already covered by an earlier patch
-release. Put removals and deprecations under **Miscellaneous** unless another
-existing section is a better fit.
+Do not edit `Changelog.md` in feature, fix, refactor, or documentation pull
+requests merely because a change is user-visible. Only update it when
+changelog or release-note maintenance is an explicit motivation of the pull
+request.
+
+When maintaining the changelog, record user-visible features, fixes,
+regressions, deprecations, removals, security-relevant behavior, and
+compatibility changes. Skip internal refactors, test-only changes, tooling
+changes unless users or release operators are affected, and entries already
+covered by an earlier patch release. Put removals and deprecations under
+**Miscellaneous** unless another existing section is a better fit.
Use concise imperative bullets under the existing sections, preserve product
terminology, wrap identifiers in backticks, and include PR numbers andWhy this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.