refactor(db): migrate Identity schema
Complete schema ownership by moving all ASP.NET Identity tables to an additive provider-aware migration. Preserve credentials, security state, preferences, roles, claims, external logins, tokens, indexes, and cascades.
This commit is contained in:
@@ -267,118 +267,13 @@ public static class StartupInitializationExtensions
|
||||
var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
|
||||
var useSqliteBootstrap = provider is not "mysql" and not "mariadb";
|
||||
|
||||
static void EnsureIdentityTablesMySql(DbConnection c)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
CREATE TABLE IF NOT EXISTS `AspNetRoles` (
|
||||
`Id` varchar(255) NOT NULL,
|
||||
`Name` varchar(256) NULL,
|
||||
`NormalizedName` varchar(256) NULL,
|
||||
`ConcurrencyStamp` longtext NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `AspNetUsers` (
|
||||
`Id` varchar(255) NOT NULL,
|
||||
`UserName` varchar(256) NULL,
|
||||
`NormalizedUserName` varchar(256) NULL,
|
||||
`Email` varchar(256) NULL,
|
||||
`NormalizedEmail` varchar(256) NULL,
|
||||
`EmailConfirmed` tinyint(1) NOT NULL,
|
||||
`PasswordHash` longtext NULL,
|
||||
`SecurityStamp` longtext NULL,
|
||||
`ConcurrencyStamp` longtext NULL,
|
||||
`PhoneNumber` longtext NULL,
|
||||
`PhoneNumberConfirmed` tinyint(1) NOT NULL,
|
||||
`TwoFactorEnabled` tinyint(1) NOT NULL,
|
||||
`LockoutEnd` datetime(6) NULL,
|
||||
`LockoutEnabled` tinyint(1) NOT NULL,
|
||||
`AccessFailedCount` int NOT NULL,
|
||||
`FirstName` longtext NULL,
|
||||
`LastName` longtext NULL,
|
||||
`DisplayName` longtext NULL,
|
||||
`ProfileCvText` longtext NULL,
|
||||
`ProfileCvStructureJson` longtext NULL,
|
||||
`CurrentCvUploadArtifactId` int NULL,
|
||||
`CurrentCvExtractionRunId` int NULL,
|
||||
`CurrentCvProfileVersion` int NULL,
|
||||
`AvatarImageDataUrl` longtext NULL,
|
||||
`GoogleSubject` longtext NULL,
|
||||
`GoogleEmail` longtext NULL,
|
||||
`GoogleLinkedAt` datetime(6) NULL,
|
||||
`MicrosoftSubject` longtext NULL,
|
||||
`MicrosoftEmail` longtext NULL,
|
||||
`MicrosoftLinkedAt` datetime(6) NULL,
|
||||
`TotpSecretEncrypted` longtext NULL,
|
||||
`TotpPendingSecretEncrypted` longtext NULL,
|
||||
`TotpEnabledAtUtc` datetime(6) NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `AspNetRoleClaims` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`RoleId` varchar(255) NOT NULL,
|
||||
`ClaimType` longtext NULL,
|
||||
`ClaimValue` longtext NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_AspNetRoleClaims_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `AspNetUserClaims` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`UserId` varchar(255) NOT NULL,
|
||||
`ClaimType` longtext NULL,
|
||||
`ClaimValue` longtext NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_AspNetUserClaims_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `AspNetUserLogins` (
|
||||
`LoginProvider` varchar(255) NOT NULL,
|
||||
`ProviderKey` varchar(255) NOT NULL,
|
||||
`ProviderDisplayName` longtext NULL,
|
||||
`UserId` varchar(255) NOT NULL,
|
||||
PRIMARY KEY (`LoginProvider`, `ProviderKey`),
|
||||
CONSTRAINT `FK_AspNetUserLogins_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `AspNetUserRoles` (
|
||||
`UserId` varchar(255) NOT NULL,
|
||||
`RoleId` varchar(255) NOT NULL,
|
||||
PRIMARY KEY (`UserId`, `RoleId`),
|
||||
CONSTRAINT `FK_AspNetUserRoles_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `FK_AspNetUserRoles_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `AspNetUserTokens` (
|
||||
`UserId` varchar(255) NOT NULL,
|
||||
`LoginProvider` varchar(255) NOT NULL,
|
||||
`Name` varchar(255) NOT NULL,
|
||||
`Value` longtext NULL,
|
||||
PRIMARY KEY (`UserId`, `LoginProvider`, `Name`),
|
||||
CONSTRAINT `FK_AspNetUserTokens_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `RoleNameIndex` ON `AspNetRoles` (`NormalizedName`);
|
||||
CREATE INDEX IF NOT EXISTS `IX_AspNetRoleClaims_RoleId` ON `AspNetRoleClaims` (`RoleId`);
|
||||
CREATE INDEX IF NOT EXISTS `EmailIndex` ON `AspNetUsers` (`NormalizedEmail`);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `UserNameIndex` ON `AspNetUsers` (`NormalizedUserName`);
|
||||
CREATE INDEX IF NOT EXISTS `IX_AspNetUserClaims_UserId` ON `AspNetUserClaims` (`UserId`);
|
||||
CREATE INDEX IF NOT EXISTS `IX_AspNetUserLogins_UserId` ON `AspNetUserLogins` (`UserId`);
|
||||
CREATE INDEX IF NOT EXISTS `IX_AspNetUserRoles_RoleId` ON `AspNetUserRoles` (`RoleId`);
|
||||
";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// The schema reconciler. Runs TWICE: once before Migrate() and once after.
|
||||
//
|
||||
// Before: legacy databases need their Identity tables and hand-added columns present, and
|
||||
// the legacy migration-history stamp written, or Migrate() collides with them.
|
||||
// Before: legacy databases need hand-added columns repaired and the legacy
|
||||
// migration-history stamp written, or Migrate() can collide with them.
|
||||
// After: on a brand-new database the migration-owned tables did not exist during the first
|
||||
// pass, so everything that depends on them (reconciler-owned tables that FK into
|
||||
// JobApplications, index and AUTO_INCREMENT repairs) was skipped. The second pass finds them
|
||||
// and finishes the job.
|
||||
// pass, so index, column, and AUTO_INCREMENT repairs were skipped. The second pass finds
|
||||
// the migration-created tables and finishes compatibility repair.
|
||||
//
|
||||
// Every statement in here is existence-guarded, so running it twice is a no-op scan on an
|
||||
// already-correct database. docs/infrastructure/database-ownership.md.
|
||||
@@ -398,119 +293,6 @@ public static class StartupInitializationExtensions
|
||||
// ReconcileSchema runs twice; the second pass may inherit an already-open connection.
|
||||
if (conn.State != System.Data.ConnectionState.Open) conn.Open();
|
||||
|
||||
static void EnsureIdentityTables(DbConnection c)
|
||||
{
|
||||
// EF migrations are used for the app schema. In some environments `dotnet ef` isn’t available,
|
||||
// so create the ASP.NET Core Identity tables directly if they don’t exist yet.
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AspNetRoles" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
|
||||
"Name" TEXT NULL,
|
||||
"NormalizedName" TEXT NULL,
|
||||
"ConcurrencyStamp" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AspNetUsers" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY,
|
||||
"UserName" TEXT NULL,
|
||||
"NormalizedUserName" TEXT NULL,
|
||||
"Email" TEXT NULL,
|
||||
"NormalizedEmail" TEXT NULL,
|
||||
"EmailConfirmed" INTEGER NOT NULL,
|
||||
"PasswordHash" TEXT NULL,
|
||||
"SecurityStamp" TEXT NULL,
|
||||
"ConcurrencyStamp" TEXT NULL,
|
||||
"PhoneNumber" TEXT NULL,
|
||||
"PhoneNumberConfirmed" INTEGER NOT NULL,
|
||||
"TwoFactorEnabled" INTEGER NOT NULL,
|
||||
"LockoutEnd" TEXT NULL,
|
||||
"LockoutEnabled" INTEGER NOT NULL,
|
||||
"AccessFailedCount" INTEGER NOT NULL,
|
||||
"FirstName" TEXT NULL,
|
||||
"LastName" TEXT NULL,
|
||||
"DisplayName" TEXT NULL,
|
||||
"ProfileCvText" TEXT NULL,
|
||||
"ProfileCvStructureJson" TEXT NULL,
|
||||
"CurrentCvUploadArtifactId" INTEGER NULL,
|
||||
"CurrentCvExtractionRunId" INTEGER NULL,
|
||||
"CurrentCvProfileVersion" INTEGER NULL,
|
||||
"AvatarImageDataUrl" TEXT NULL,
|
||||
"GoogleSubject" TEXT NULL,
|
||||
"GoogleEmail" TEXT NULL,
|
||||
"GoogleLinkedAt" TEXT NULL,
|
||||
"MicrosoftSubject" TEXT NULL,
|
||||
"MicrosoftEmail" TEXT NULL,
|
||||
"MicrosoftLinkedAt" TEXT NULL,
|
||||
"TotpSecretEncrypted" TEXT NULL,
|
||||
"TotpPendingSecretEncrypted" TEXT NULL,
|
||||
"TotpEnabledAtUtc" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT,
|
||||
"RoleId" TEXT NOT NULL,
|
||||
"ClaimType" TEXT NULL,
|
||||
"ClaimValue" TEXT NULL,
|
||||
CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AspNetUserClaims" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT,
|
||||
"UserId" TEXT NOT NULL,
|
||||
"ClaimType" TEXT NULL,
|
||||
"ClaimValue" TEXT NULL,
|
||||
CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AspNetUserLogins" (
|
||||
"LoginProvider" TEXT NOT NULL,
|
||||
"ProviderKey" TEXT NOT NULL,
|
||||
"ProviderDisplayName" TEXT NULL,
|
||||
"UserId" TEXT NOT NULL,
|
||||
CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"),
|
||||
CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AspNetUserRoles" (
|
||||
"UserId" TEXT NOT NULL,
|
||||
"RoleId" TEXT NOT NULL,
|
||||
CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"),
|
||||
CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
|
||||
"UserId" TEXT NOT NULL,
|
||||
"LoginProvider" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Value" TEXT NULL,
|
||||
CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"),
|
||||
CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");""");
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");""");
|
||||
}
|
||||
|
||||
EnsureIdentityTables(conn);
|
||||
EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
|
||||
@@ -564,8 +346,8 @@ public static class StartupInitializationExtensions
|
||||
ReconcileCareerProfileColumns(conn);
|
||||
ReconcileAiInteractionUsageColumns(conn);
|
||||
|
||||
// Once the base app tables exist, provision this reconciler-owned schema set and
|
||||
// stamp its historical migration before later migrations rebuild JobApplications.
|
||||
// Once the base app tables exist, stamp the historical migration before later
|
||||
// migrations rebuild JobApplications.
|
||||
var isLegacy =
|
||||
HasMigration(conn, "20260310174114_AddCorrespondence") &&
|
||||
!HasMigration(conn, legacyMigrationId);
|
||||
@@ -649,8 +431,6 @@ public static class StartupInitializationExtensions
|
||||
DbConnection conn = db.Database.GetDbConnection();
|
||||
// ReconcileSchema runs twice; the second pass may inherit an already-open connection.
|
||||
if (conn.State != System.Data.ConnectionState.Open) conn.Open();
|
||||
EnsureIdentityTablesMySql(conn);
|
||||
|
||||
|
||||
static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column)
|
||||
{
|
||||
@@ -898,8 +678,8 @@ public static class StartupInitializationExtensions
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Reconcile what already exists and create the remaining Identity-owned tables. This
|
||||
// runs before Migrate() so legacy schemas are repaired before migrations inspect them.
|
||||
// 1. Reconcile what already exists. This runs before Migrate() so legacy schemas are
|
||||
// repaired before migrations inspect them.
|
||||
ReconcileSchema();
|
||||
|
||||
// 2. Apply one migration at a time, reconciling after each. Some historical migrations
|
||||
@@ -925,7 +705,7 @@ public static class StartupInitializationExtensions
|
||||
// historical migrations can add a later column (for example Companies.Source)
|
||||
// immediately before the migration that owns it, producing a duplicate-column
|
||||
// failure on a clean database. Apply the chain first, then use the common final
|
||||
// reconciliation pass for provider-safe repairs and reconciler-owned tables.
|
||||
// reconciliation pass for provider-safe legacy repairs.
|
||||
migrationDb.Database.Migrate();
|
||||
}
|
||||
}
|
||||
@@ -935,9 +715,8 @@ public static class StartupInitializationExtensions
|
||||
throw;
|
||||
}
|
||||
|
||||
// 3. Reconcile again, now that the migration-owned tables exist: creates the
|
||||
// reconciler-owned tables that reference them and applies the index / AUTO_INCREMENT
|
||||
// repairs that pass 1 had to skip. Idempotent, so on an existing database it is a scan.
|
||||
// 3. Reconcile again, now that migration-owned tables exist, applying the index and
|
||||
// AUTO_INCREMENT repairs pass 1 had to skip. Existing databases receive a no-op scan.
|
||||
ReconcileSchema();
|
||||
|
||||
// Optional: seed an initial admin user for local username/password login.
|
||||
|
||||
@@ -15,6 +15,13 @@ internal static class StartupSchemaOwnership
|
||||
"AiWorkspaceNotes",
|
||||
"AiInteractions",
|
||||
"ApplicationChecklistItems",
|
||||
"AspNetRoleClaims",
|
||||
"AspNetRoles",
|
||||
"AspNetUserClaims",
|
||||
"AspNetUserLogins",
|
||||
"AspNetUserRoles",
|
||||
"AspNetUsers",
|
||||
"AspNetUserTokens",
|
||||
"Attachments",
|
||||
"CareerCertifications",
|
||||
"CareerEducations",
|
||||
@@ -53,19 +60,10 @@ internal static class StartupSchemaOwnership
|
||||
"UserSessions",
|
||||
};
|
||||
|
||||
internal static readonly IReadOnlySet<string> ReconcilerOwnedTables = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"AspNetRoleClaims",
|
||||
"AspNetRoles",
|
||||
"AspNetUserClaims",
|
||||
"AspNetUserLogins",
|
||||
"AspNetUserRoles",
|
||||
"AspNetUsers",
|
||||
"AspNetUserTokens",
|
||||
};
|
||||
internal static readonly IReadOnlySet<string> ReconcilerOwnedTables = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Historical migrations contain guarded compatibility bootstraps for these tables so direct
|
||||
// EF tooling can traverse the chain. Their current creation owner remains the reconciler.
|
||||
// EF tooling can traverse the chain. The later adoption migration is the current owner.
|
||||
internal static readonly IReadOnlySet<string> MigrationCompatibilityBootstrapTables =
|
||||
new HashSet<string>(StringComparer.Ordinal) { "AspNetUsers" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user