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