From 98835dbae007f96a75dbc225f9534adb8ef9005d Mon Sep 17 00:00:00 2001 From: Musa Misto <64855513+MusaMisto@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:54:16 +0300 Subject: [PATCH] fix(pgsql): opt into Npgsql dynamic JSON so ProfileData reads work again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production login returns HTTP 500: InvalidCastException: Reading as 'IEnumerable' is not supported for fields having DataTypeName 'jsonb' ---> NotSupportedException: Type 'IEnumerable`1' required dynamic JSON serialization, which requires an explicit opt-in; call 'EnableDynamicJson' Regression from #129, which moved Npgsql 5.0.10 -> 8.0.11 to fix the EF Core TypeLoadException. Npgsql 8 removed the implicit dynamic JSON serializer: mapping an arbitrary POCO collection to a json/jsonb column now requires EnableDynamicJson() on the data source. Three properties rely on it via StoreAsJson() — Tenant.ProfileData, TenantMembership.ProfileData and Account.ProfileData — and account.profile_data is jsonb in production. The blast radius is wider than login: UseApiKeyAsRequestContext materialises Account on every apikey-authenticated request, so ANY such request 500s. Startup.cs now builds a single NpgsqlDataSource with EnableDynamicJson() and hands it to UseNpgsql. It is built once outside the AddDbContext lambda — NpgsqlDataSource owns the connection pool, so constructing one per DbContext instance would leak pools. Verified A/B against the REAL production database, same request, same data: ghcr.io/simplify9/mtm:8.0.3 GET /api/accounts -> 500 (8 exceptions) POST .../login -> 500 this build GET /api/accounts -> 200 (0 exceptions, real account data) POST .../login -> 400 (handler reached, bad password rejected) No schema changes were applied during the test — the 2021 migrations are already present, so startup migration is a no-op. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VHkpqv6dB6wjALyFe9ocMU --- SW.Mtm.Web/Startup.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/SW.Mtm.Web/Startup.cs b/SW.Mtm.Web/Startup.cs index 73c629d..3100924 100644 --- a/SW.Mtm.Web/Startup.cs +++ b/SW.Mtm.Web/Startup.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; +using Npgsql; using Pomelo.EntityFrameworkCore.MySql.Infrastructure; using Pomelo.EntityFrameworkCore.MySql.Storage; using SW.CqApi; @@ -51,11 +52,27 @@ public void ConfigureServices(IServiceCollection services) if (mtmOptions.DatabaseType.ToLower() == RelationalDbType.PgSql.ToString().ToLower()) { + // Npgsql 8 removed the implicit dynamic JSON serializer. Properties + // mapped with StoreAsJson() (Tenant.ProfileData, + // TenantMembership.ProfileData, Account.ProfileData) are POCO + // collections stored in json/jsonb columns, and reading them now + // throws unless dynamic JSON is opted into explicitly: + // InvalidCastException: Reading as + // 'IEnumerable' is not supported for fields + // having DataTypeName 'jsonb' + // Built ONCE here, not inside the AddDbContext lambda: + // NpgsqlDataSource owns the connection pool, so building one per + // DbContext instance would leak pools. + var pgDataSourceBuilder = new NpgsqlDataSourceBuilder( + Configuration.GetConnectionString(MtmDbContext.ConnectionString)); + pgDataSourceBuilder.EnableDynamicJson(); + var pgDataSource = pgDataSourceBuilder.Build(); + services.AddDbContext(c => { c.EnableSensitiveDataLogging(true); c.UseSnakeCaseNamingConvention(); - c.UseNpgsql(Configuration.GetConnectionString(MtmDbContext.ConnectionString), b => + c.UseNpgsql(pgDataSource, b => { b.MigrationsHistoryTable("_ef_migrations_history", PgSql.MtmDbContext.Schema); b.MigrationsAssembly(typeof(PgSql.DbType).Assembly.FullName);