using Microsoft.EntityFrameworkCore; using DorkNet.Server.Data; using DorkNet.Server.Services; using Serilog; namespace DorkNet.Server.Startup; /// Boot-time database orchestration: schema migration, /// idempotent data upgrades, and canonical seed application. Replaces /// the inline block previously in Program.cs. /// /// Provider-specific paths: /// /// SQLite — Database.Migrate() after /// reconciles any legacy /// migration-history state. /// Postgres — EnsureCreated behind a transaction-scoped /// advisory lock so concurrent replica boots don't race on /// CREATE TABLE. Connect retries cover the /// container-orchestrated boot window where Postgres DNS isn't /// resolvable yet. /// /// After the schema step both branches run the same idempotent /// post-init sequence: system-account seed, avatar-seed self-heal, /// starter-wallet backfill, dorm backfill, /// data transforms, canonical room seeding + overrides, and per-feature /// seeders. The /healthz probe flips to 200 only after all of /// this completes — Coolify's rolling deploy holds traffic at the LB /// until then. public static class DatabaseBootstrap { public static async Task RunDatabaseBootstrapAsync(this WebApplication app) { using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); if (db.Database.IsSqlite()) { BaselineExistingSchemaIfNeeded(db); db.Database.Migrate(); } else { // Postgres path: schema generated from the EF Core model via // EnsureCreated. Wrapped in a transaction-scoped advisory lock // so concurrent replicas booting against the same database // don't race on CREATE TABLE — only the first replica to take // the lock runs EnsureCreated, the rest see "schema already // exists" and proceed. // // Lock key 0x444F524B (ascii "DORK") is hard-coded; it just // needs to be the same int64 across replicas and not collide // with anything else in the database. pg_advisory_xact_lock // releases automatically on COMMIT, so a crashed replica can't // wedge the next boot. var conn = db.Database.GetDbConnection(); // Retry initial Postgres connect — on container-orchestrated boots // (Coolify, docker-compose) the Postgres service's DNS often isn't // resolvable yet when this app starts, surfacing as SocketException // errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore. // Postgres itself can also be up-but-not-accepting-connections for // a few seconds while it replays WAL. 15 attempts × ~2s backoff // covers the typical 5-30s warmup window without looping forever // on a permanently broken config. var connectLogger = app.Services.GetRequiredService() .CreateLogger("DorkNet.Server.Bootstrap"); for (var attempt = 1; ; attempt++) { try { await conn.OpenAsync(); break; } catch (Exception ex) when (attempt < 15 && (ex is System.Net.Sockets.SocketException || ex is Npgsql.NpgsqlException || ex is System.Data.Common.DbException)) { var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5)); connectLogger.LogWarning( "[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s", attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds); await Task.Delay(delay); } } await using var tx = await conn.BeginTransactionAsync(); await using (var lockCmd = conn.CreateCommand()) { lockCmd.Transaction = tx; lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);"; await lockCmd.ExecuteNonQueryAsync(); } db.Database.EnsureCreated(); await tx.CommitAsync(); await conn.CloseAsync(); } // Coach system account at Player.Id=1. The RR-Original room seeder // below assigns CreatorPlayerId=1 to every canonical room // (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs // to exist before we insert rooms. Idempotent: the call no-ops once // the row is there. Must run BEFORE roomService.SeedAsync(). var playerService = scope.ServiceProvider.GetRequiredService(); await playerService.EnsureSystemAccountAsync(); // Self-heal the AvatarEntity seed on every startup so accounts // created with old/broken starter values get the current safe // GUIDs without needing a one-shot EF migration. await playerService.EnsureAvatarSeedAsync(); // Grant the starter RecCenterTokens balance to any account that // doesn't yet have a CurrencyType=2 row. Pre-existing accounts // booted into a 0-token wallet and saw "Not enough tokens" on // every store tile — this brings them up to the same starting // state new accounts get. Idempotent. await playerService.BackfillStarterWalletsAsync(); // Backfill the per-player dorm room + DormStateEntity for any // legacy account that signed in via a path that didn't run // EnsurePersonalDormAsync. Without these rows the watch's // /api/rooms/v4/details/{dormId} → cdn.{apex}/room/{blobName} // chain hits an empty CurrentDataBlobName. var roomService = scope.ServiceProvider.GetRequiredService(); await roomService.EnsureDormsForAllPlayersAsync(); // Idempotent data-transform pass for things that can't be expressed // as an EF migration against the entity model. MUST run before // SeedAsync() so the seed sees the post-rename state. var legacyLogger = app.Services.GetRequiredService() .CreateLogger("DorkNet.Server.LegacyUpgrades"); await LegacyUpgrades.RunAsync(db, app.Configuration, legacyLogger); // Seed canonical Rec Room Original rooms so the watch's "Trending" // tab has content on first launch. Idempotent — bails if any // rooms already exist. await roomService.SeedAsync(); // Canonical room overrides: rename BloodMoon → Crescendo, fold // the paintball-map standalone rooms into Paintball as sub-rooms, // fold LaserTagHangar into LaserTag, hide MakerRoom + EventRoom // from browse, and pull down the user-supplied thumbnails. using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }) { var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images"); await roomService.ApplyCanonicalOverridesAsync(http, imagesDir); } // Seed default curated playlists, club category tags, store catalog. // Must run after SeedAsync so they can reference RR-Originals. var playlistService = scope.ServiceProvider.GetRequiredService(); await playlistService.SeedCuratedAsync(); var clubService = scope.ServiceProvider.GetRequiredService(); await clubService.SeedDefaultsAsync(); var storeService = scope.ServiceProvider.GetRequiredService(); await storeService.SeedAsync(); // Tell the /healthz probe migrations are done. Coolify's rolling // deploy holds traffic at the LB until this flips and /healthz // returns 200. Controllers.Health.HealthController.MigrationsComplete = true; } /// SQLite migration-history reconciliation. Handles two /// cutover scenarios so legacy DBs boot cleanly without manual SQL: /// /// Legacy EnsureCreated — schema exists, no /// __EFMigrationsHistory. (Pre-migrations DorkNet builds /// used EnsureCreated for SQLite too.) /// Stale history after consolidation — history /// references migration IDs that no longer exist in the assembly. /// Happens once when the per-feature migrations are collapsed into /// a single Initial (see Data/MIGRATIONS.md). /// /// In both cases the on-disk schema is canonical; we just need history /// to reflect the assembly's current migration list so Migrate() /// no-ops. Fresh DBs short-circuit (no Players table yet) and /// Migrate() does the full CreateTable run normally. private static void BaselineExistingSchemaIfNeeded(DorkNetDbContext db) { using var conn = db.Database.GetDbConnection(); conn.Open(); using (var probe = conn.CreateCommand()) { probe.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='Players';"; var hasPlayers = probe.ExecuteScalar() is not null; if (!hasPlayers) return; // Fresh DB — let Migrate() do its full thing. } var assemblyMigrations = db.Database.GetMigrations().ToList(); if (assemblyMigrations.Count == 0) return; var historyExists = false; using (var historyProbe = conn.CreateCommand()) { historyProbe.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory';"; historyExists = historyProbe.ExecuteScalar() is not null; } var existingIds = new HashSet(StringComparer.Ordinal); if (historyExists) { using var readCmd = conn.CreateCommand(); readCmd.CommandText = @"SELECT ""MigrationId"" FROM ""__EFMigrationsHistory"";"; using var reader = readCmd.ExecuteReader(); while (reader.Read()) existingIds.Add(reader.GetString(0)); if (existingIds.SetEquals(assemblyMigrations)) return; } else { using var createHistory = conn.CreateCommand(); createHistory.CommandText = """ CREATE TABLE "__EFMigrationsHistory" ( "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, "ProductVersion" TEXT NOT NULL ); """; createHistory.ExecuteNonQuery(); } using (var wipe = conn.CreateCommand()) { wipe.CommandText = @"DELETE FROM ""__EFMigrationsHistory"";"; wipe.ExecuteNonQuery(); } var productVersion = typeof(DbContext).Assembly.GetName().Version?.ToString() ?? "10.0.0"; foreach (var id in assemblyMigrations) { using var insert = conn.CreateCommand(); insert.CommandText = """ INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ($id, $ver); """; var pId = insert.CreateParameter(); pId.ParameterName = "$id"; pId.Value = id; insert.Parameters.Add(pId); var pVr = insert.CreateParameter(); pVr.ParameterName = "$ver"; pVr.Value = productVersion; insert.Parameters.Add(pVr); insert.ExecuteNonQuery(); } // A previous failed migrate may have left a stale row in // __EFMigrationsLock — drop the table so Migrate re-creates it // cleanly. Safe — the lock is per-application-instance and // we're single-instance here. using (var dropLock = conn.CreateCommand()) { dropLock.CommandText = "DROP TABLE IF EXISTS __EFMigrationsLock;"; dropLock.ExecuteNonQuery(); } var stale = existingIds.Except(assemblyMigrations).Count(); var added = assemblyMigrations.Except(existingIds).Count(); Log.Information( "[migrations] Reconciled SQLite history: dropped {Stale} stale ids, baselined {Added} assembly migrations ({First} … {Last}).", stale, added, assemblyMigrations.First(), assemblyMigrations.Last()); } }