mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Modernize March server startup
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using DorkNet.Server.Controllers.Health;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
|
||||
namespace DorkNet.Server.Startup;
|
||||
|
||||
/// <summary>Boot-time schema repair, seed data, and health readiness.</summary>
|
||||
public static class DatabaseBootstrap
|
||||
{
|
||||
public static async Task RunDatabaseBootstrapAsync(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
db.Database.EnsureCreated();
|
||||
ApplySqliteCompatibilityPatches(db);
|
||||
await EnsureSqliteInviteTablesAsync(db);
|
||||
}
|
||||
else
|
||||
{
|
||||
await BootstrapPostgresAsync(app, db);
|
||||
}
|
||||
|
||||
var playerService = scope.ServiceProvider.GetRequiredService<PlayerService>();
|
||||
await playerService.EnsureSystemAccountAsync();
|
||||
await playerService.EnsureAvatarSeedAsync();
|
||||
await playerService.BackfillStarterWalletsAsync();
|
||||
|
||||
var roomService = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
await roomService.EnsureDormsForAllPlayersAsync();
|
||||
await roomService.SeedAsync();
|
||||
|
||||
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) })
|
||||
{
|
||||
var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
|
||||
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
|
||||
}
|
||||
|
||||
var storeService = scope.ServiceProvider.GetRequiredService<StoreService>();
|
||||
await storeService.SeedAsync();
|
||||
|
||||
HealthController.MigrationsComplete = true;
|
||||
}
|
||||
|
||||
private static async Task EnsureSqliteInviteTablesAsync(DorkNetDbContext db)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
|
||||
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_SignupCodes"" PRIMARY KEY AUTOINCREMENT,
|
||||
""Code"" TEXT NOT NULL,
|
||||
""Descriptor"" TEXT NOT NULL DEFAULT '',
|
||||
""CreatedByPlayerId"" INTEGER NOT NULL DEFAULT 0,
|
||||
""CreatedAt"" TEXT NOT NULL,
|
||||
""ExpiresAt"" TEXT NULL,
|
||||
""RedeemedByPlayerId"" INTEGER NULL,
|
||||
""RedeemedAt"" TEXT NULL,
|
||||
""Revoked"" INTEGER NOT NULL DEFAULT 0
|
||||
);");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
|
||||
""Id"" INTEGER NOT NULL CONSTRAINT ""PK_PendingDevices"" PRIMARY KEY AUTOINCREMENT,
|
||||
""DeviceId"" TEXT NOT NULL,
|
||||
""Platform"" INTEGER NOT NULL DEFAULT 0,
|
||||
""PlatformId"" TEXT NOT NULL DEFAULT '',
|
||||
""LastIp"" TEXT NULL,
|
||||
""FirstSeenAt"" TEXT NOT NULL,
|
||||
""LastSeenAt"" TEXT NOT NULL
|
||||
);");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
|
||||
}
|
||||
|
||||
private static async Task BootstrapPostgresAsync(WebApplication app, DorkNetDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var connectLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.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 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();
|
||||
|
||||
var bootstrapLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomDataBlobs.Bytes nullable",
|
||||
@"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" DROP NOT NULL;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreads table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ChatThreads"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""ThreadKey"" varchar(96) NOT NULL,
|
||||
""Name"" varchar(128) NOT NULL DEFAULT '',
|
||||
""CreatorPlayerId"" bigint NOT NULL,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreads ThreadKey unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreads_ThreadKey""
|
||||
ON ""ChatThreads"" (""ThreadKey"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ChatThreadMembers"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""ThreadKey"" text NOT NULL,
|
||||
""PlayerId"" bigint NOT NULL,
|
||||
""JoinedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""SnoozeUntil"" timestamp with time zone NULL,
|
||||
""LastReadMessageId"" bigint NULL
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers (ThreadKey, PlayerId) unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_ThreadKey_PlayerId""
|
||||
ON ""ChatThreadMembers"" (""ThreadKey"", ""PlayerId"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ChatThreadMembers PlayerId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_PlayerId""
|
||||
ON ""ChatThreadMembers"" (""PlayerId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Favorited column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Favorited"" boolean NOT NULL DEFAULT false;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Muted column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Muted"" boolean NOT NULL DEFAULT false;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Relationships.Ignored column",
|
||||
@"ALTER TABLE ""Relationships""
|
||||
ADD COLUMN IF NOT EXISTS ""Ignored"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "StoreItems.Slug widen to 128",
|
||||
@"ALTER TABLE ""StoreItems"" ALTER COLUMN ""Slug"" TYPE varchar(128);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Messages.Type column",
|
||||
@"ALTER TABLE ""Messages""
|
||||
ADD COLUMN IF NOT EXISTS ""Type"" integer NOT NULL DEFAULT 30;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Messages.RoomId column",
|
||||
@"ALTER TABLE ""Messages""
|
||||
ADD COLUMN IF NOT EXISTS ""RoomId"" bigint NULL;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PrivateInstanceInvitees.LatestInviteMessageId column",
|
||||
@"ALTER TABLE ""PrivateInstanceInvitees""
|
||||
ADD COLUMN IF NOT EXISTS ""LatestInviteMessageId"" bigint NULL;");
|
||||
|
||||
var currentRegion = (app.Configuration["Photon:CloudRegion"] ?? "us").ToLowerInvariant().Replace("'", "''");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, $"PrivateInstances.PhotonRegion -> {currentRegion}",
|
||||
$@"UPDATE ""PrivateInstances""
|
||||
SET ""PhotonRegion"" = '{currentRegion}'
|
||||
WHERE ""PhotonRegion"" IS DISTINCT FROM '{currentRegion}';");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "CommunityBoardRows table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""CommunityBoardRows"" (
|
||||
""Id"" integer PRIMARY KEY,
|
||||
""Json"" text NOT NULL DEFAULT '{}',
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""ServerSettings"" (
|
||||
""Id"" integer PRIMARY KEY,
|
||||
""SignupsDisabled"" boolean NOT NULL DEFAULT false,
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengesCompletedRequired column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesCompletedRequired"" boolean NOT NULL DEFAULT true;");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengesJson column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengesJson"" text NOT NULL DEFAULT '';");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.WeeklyChallengeRewardJson column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""WeeklyChallengeRewardJson"" text NOT NULL DEFAULT '';");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "ServerSettings.GlobalFriendsEnabled column",
|
||||
@"ALTER TABLE ""ServerSettings""
|
||||
ADD COLUMN IF NOT EXISTS ""GlobalFriendsEnabled"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Players.IsCommunityTeam column",
|
||||
@"ALTER TABLE ""Players""
|
||||
ADD COLUMN IF NOT EXISTS ""IsCommunityTeam"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "Rooms.HiddenFromBrowse column",
|
||||
@"ALTER TABLE ""Rooms""
|
||||
ADD COLUMN IF NOT EXISTS ""HiddenFromBrowse"" boolean NOT NULL DEFAULT false;");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""RoomRoles"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""RoomId"" bigint NOT NULL,
|
||||
""PlayerId"" bigint NOT NULL,
|
||||
""Role"" integer NOT NULL,
|
||||
""Accepted"" boolean NOT NULL DEFAULT true,
|
||||
""GrantedByPlayerId"" bigint NULL,
|
||||
""GrantedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles (RoomId, PlayerId, Role) unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId_PlayerId_Role""
|
||||
ON ""RoomRoles"" (""RoomId"", ""PlayerId"", ""Role"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "RoomRoles RoomId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId"" ON ""RoomRoles"" (""RoomId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LeaderboardChannelMeta table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""LeaderboardChannelMeta"" (
|
||||
""Channel"" integer PRIMARY KEY,
|
||||
""RoomId"" bigint NOT NULL DEFAULT 0,
|
||||
""Name"" varchar(128) NOT NULL DEFAULT '',
|
||||
""LowerIsBetter"" boolean NOT NULL DEFAULT false,
|
||||
""ValueFormat"" varchar(32) NOT NULL DEFAULT 'count',
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LeaderboardChannelMeta RoomId index",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardChannelMeta_RoomId""
|
||||
ON ""LeaderboardChannelMeta"" (""RoomId"");");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "LoadingScreenTips table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""LoadingScreenTips"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""Title"" varchar(128) NOT NULL DEFAULT '',
|
||||
""Message"" varchar(512) NOT NULL DEFAULT '',
|
||||
""ImageName"" varchar(128) NOT NULL DEFAULT '',
|
||||
""Context"" int NOT NULL DEFAULT 0,
|
||||
""PlatformMask"" int NOT NULL DEFAULT -1,
|
||||
""RoomNamesCsv"" varchar(512) NOT NULL DEFAULT '',
|
||||
""SortOrder"" int NOT NULL DEFAULT 0,
|
||||
""IsActive"" boolean NOT NULL DEFAULT true,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "SignupCodes table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""SignupCodes"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""Code"" text NOT NULL,
|
||||
""Descriptor"" text NOT NULL DEFAULT '',
|
||||
""CreatedByPlayerId"" bigint NOT NULL DEFAULT 0,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""ExpiresAt"" timestamp with time zone NULL,
|
||||
""RedeemedByPlayerId"" bigint NULL,
|
||||
""RedeemedAt"" timestamp with time zone NULL,
|
||||
""Revoked"" boolean NOT NULL DEFAULT false
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "SignupCodes Code unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PendingDevices table",
|
||||
@"CREATE TABLE IF NOT EXISTS ""PendingDevices"" (
|
||||
""Id"" bigserial PRIMARY KEY,
|
||||
""DeviceId"" text NOT NULL,
|
||||
""Platform"" integer NOT NULL DEFAULT 0,
|
||||
""PlatformId"" text NOT NULL DEFAULT '',
|
||||
""LastIp"" text NULL,
|
||||
""FirstSeenAt"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);");
|
||||
await RunPostgresPatchAsync(conn, bootstrapLogger, "PendingDevices DeviceId unique index",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");");
|
||||
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
|
||||
private static async Task RunPostgresPatchAsync(DbConnection conn, Microsoft.Extensions.Logging.ILogger logger, string label, string sql)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
try { await cmd.ExecuteNonQueryAsync(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[schema-patch] {Label} failed", label);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySqliteCompatibilityPatches(DorkNetDbContext db)
|
||||
{
|
||||
if (!db.Database.IsSqlite()) return;
|
||||
AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse",
|
||||
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesCompletedRequired",
|
||||
@"""WeeklyChallengesCompletedRequired"" INTEGER NOT NULL DEFAULT 1");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengesJson",
|
||||
@"""WeeklyChallengesJson"" TEXT NOT NULL DEFAULT ''");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "WeeklyChallengeRewardJson",
|
||||
@"""WeeklyChallengeRewardJson"" TEXT NOT NULL DEFAULT ''");
|
||||
AddSqliteColumnIfMissing(db, "ServerSettings", "GlobalFriendsEnabled",
|
||||
@"""GlobalFriendsEnabled"" INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
private static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var shouldClose = conn.State == ConnectionState.Closed;
|
||||
if (shouldClose) conn.Open();
|
||||
try
|
||||
{
|
||||
var exists = false;
|
||||
using var check = conn.CreateCommand();
|
||||
check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");";
|
||||
using (var reader = check.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
{
|
||||
if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (exists)
|
||||
{
|
||||
Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column);
|
||||
return;
|
||||
}
|
||||
|
||||
using var alter = conn.CreateCommand();
|
||||
alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};";
|
||||
alter.ExecuteNonQuery();
|
||||
Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shouldClose) conn.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user