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:
cesnimda
2026-08-30 22:02:35 +02:00
parent 90ca23de33
commit 058d13de47
10 changed files with 311 additions and 261 deletions
@@ -126,6 +126,11 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProjects`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProjects`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerCertifications`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerCertifications`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerLanguages`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerLanguages`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetRoles`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserClaims`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserLogins`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserRoles`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserTokens`", script, StringComparison.Ordinal);
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal); Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
Assert.All( Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value), Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
@@ -1015,6 +1020,89 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync()); Assert.Empty(await db.Database.GetPendingMigrationsAsync());
} }
[Fact]
public async Task Identity_adoption_preserves_accounts_credentials_roles_logins_claims_and_tokens()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
var migrator = db.GetService<IMigrator>();
await migrator.MigrateAsync("20260830133000_AdoptCareerProfileSchema");
await ExecuteAsync(connection, """
CREATE TABLE "AspNetRoles" (
"Id" TEXT NOT NULL PRIMARY KEY, "Name" TEXT NULL,
"NormalizedName" TEXT NULL, "ConcurrencyStamp" TEXT NULL);
CREATE TABLE "AspNetRoleClaims" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "RoleId" TEXT NOT NULL,
"ClaimType" TEXT NULL, "ClaimValue" TEXT NULL,
FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE);
CREATE TABLE "AspNetUserClaims" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "UserId" TEXT NOT NULL,
"ClaimType" TEXT NULL, "ClaimValue" TEXT NULL,
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE);
CREATE TABLE "AspNetUserLogins" (
"LoginProvider" TEXT NOT NULL, "ProviderKey" TEXT NOT NULL,
"ProviderDisplayName" TEXT NULL, "UserId" TEXT NOT NULL,
PRIMARY KEY ("LoginProvider", "ProviderKey"),
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE);
CREATE TABLE "AspNetUserRoles" (
"UserId" TEXT NOT NULL, "RoleId" TEXT NOT NULL,
PRIMARY KEY ("UserId", "RoleId"),
FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE,
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE);
CREATE TABLE "AspNetUserTokens" (
"UserId" TEXT NOT NULL, "LoginProvider" TEXT NOT NULL, "Name" TEXT NOT NULL,
"Value" TEXT NULL, PRIMARY KEY ("UserId", "LoginProvider", "Name"),
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE);
INSERT INTO "AspNetUsers"
("Id", "UserName", "NormalizedUserName", "Email", "NormalizedEmail",
"EmailConfirmed", "PasswordHash", "PhoneNumberConfirmed", "TwoFactorEnabled",
"LockoutEnabled", "AccessFailedCount", "AiEnabled", "ExternalAiProcessingAllowed",
"DeletionStatus", "EmailFollowUpRemindersEnabled", "UiLanguage")
VALUES ('user-fixture', 'connor', 'CONNOR', 'connor@example.test', 'CONNOR@EXAMPLE.TEST',
1, 'preserved-password-hash', 0, 1, 1, 0, 1, 0, 'active', 1, 'nb');
INSERT INTO "AspNetRoles" VALUES ('role-admin', 'Admin', 'ADMIN', 'role-stamp');
INSERT INTO "AspNetRoleClaims" ("RoleId", "ClaimType", "ClaimValue")
VALUES ('role-admin', 'permission', 'admin.manage');
INSERT INTO "AspNetUserClaims" ("UserId", "ClaimType", "ClaimValue")
VALUES ('user-fixture', 'locale', 'nb');
INSERT INTO "AspNetUserLogins" VALUES ('google', 'subject-1', 'Google', 'user-fixture');
INSERT INTO "AspNetUserRoles" VALUES ('user-fixture', 'role-admin');
INSERT INTO "AspNetUserTokens" VALUES ('user-fixture', 'authenticator', 'refresh', 'encrypted-token');
""");
await migrator.MigrateAsync();
Assert.Equal("preserved-password-hash", await ScalarAsync<string>(connection,
"SELECT PasswordHash FROM AspNetUsers WHERE Id = 'user-fixture';"));
Assert.Equal("nb", await ScalarAsync<string>(connection,
"SELECT UiLanguage FROM AspNetUsers WHERE Id = 'user-fixture';"));
Assert.Equal("admin.manage", await ScalarAsync<string>(connection,
"SELECT ClaimValue FROM AspNetRoleClaims WHERE RoleId = 'role-admin';"));
Assert.Equal("encrypted-token", await ScalarAsync<string>(connection,
"SELECT Value FROM AspNetUserTokens WHERE UserId = 'user-fixture';"));
await migrator.MigrateAsync("20260830133000_AdoptCareerProfileSchema");
Assert.Equal("subject-1", await ScalarAsync<string>(connection,
"SELECT ProviderKey FROM AspNetUserLogins WHERE UserId = 'user-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(8L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (
'RoleNameIndex', 'IX_AspNetRoleClaims_RoleId', 'EmailIndex', 'UserNameIndex',
'IX_AspNetUsers_MicrosoftTenantId_MicrosoftObjectId',
'IX_AspNetUserClaims_UserId', 'IX_AspNetUserLogins_UserId', 'IX_AspNetUserRoles_RoleId');
"""));
await ExecuteAsync(connection, "DELETE FROM AspNetUsers WHERE Id = 'user-fixture';");
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserClaims;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserLogins;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserRoles;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserTokens;"));
await ExecuteAsync(connection, "DELETE FROM AspNetRoles WHERE Id = 'role-admin';");
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetRoleClaims;"));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection) private static JobTrackerContext Context(SqliteConnection connection)
{ {
var currentUser = new Mock<ICurrentUserService>(); var currentUser = new Mock<ICurrentUserService>();
@@ -39,12 +39,12 @@ public sealed class StartupSchemaOwnershipTests
} }
[Fact] [Fact]
public void Compatibility_bootstraps_remain_reconciler_owned_until_migrated() public void Compatibility_bootstraps_do_not_create_a_second_current_owner()
{ {
Assert.All( Assert.All(
StartupSchemaOwnership.MigrationCompatibilityBootstrapTables, StartupSchemaOwnership.MigrationCompatibilityBootstrapTables,
table => Assert.Contains(table, StartupSchemaOwnership.ReconcilerOwnedTables)); table => Assert.True(
Assert.Empty(StartupSchemaOwnership.MigrationCompatibilityBootstrapTables StartupSchemaOwnership.MigrationOwnedTables.Contains(table) ^
.Intersect(StartupSchemaOwnership.MigrationOwnedTables, StringComparer.Ordinal)); StartupSchemaOwnership.ReconcilerOwnedTables.Contains(table)));
} }
} }
@@ -0,0 +1,173 @@
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
/// <summary>
/// Establishes one migration owner for the ASP.NET Identity aggregate while preserving all
/// existing users, credentials, roles, claims, external logins, and tokens.
/// </summary>
[DbContext(typeof(JobTrackerContext))]
[Migration("20260830134000_AdoptIdentitySchema")]
public sealed class AdoptIdentitySchema : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase)
? MySqlDdl
: SqliteDdl);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Identity data may predate this migration; downgrade must never remove accounts.
}
private const string SqliteDdl = """
CREATE TABLE IF NOT EXISTS "AspNetRoles" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
"Name" TEXT NULL, "NormalizedName" TEXT NULL, "ConcurrencyStamp" TEXT NULL);
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,
"MicrosoftTenantId" TEXT NULL, "MicrosoftObjectId" TEXT NULL,
"TotpSecretEncrypted" TEXT NULL, "TotpPendingSecretEncrypted" TEXT NULL,
"TotpEnabledAtUtc" TEXT NULL, "PendingEmail" TEXT NULL,
"PendingEmailRequestedAtUtc" TEXT NULL, "AiEnabled" INTEGER NOT NULL DEFAULT 1,
"ExternalAiProcessingAllowed" INTEGER NOT NULL DEFAULT 0,
"StripeCustomerId" TEXT NULL, "StripeSubscriptionId" TEXT NULL,
"StripeSubscriptionStatus" TEXT NULL, "StripeLastEventCreatedUtc" TEXT NULL,
"DeletionRequestedAtUtc" TEXT NULL, "DeletionStatus" TEXT NOT NULL DEFAULT 'active',
"EmailFollowUpRemindersEnabled" INTEGER NOT NULL DEFAULT 1, "UiLanguage" TEXT NULL);
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);
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);
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);
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);
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);
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 UNIQUE INDEX IF NOT EXISTS "IX_AspNetUsers_MicrosoftTenantId_MicrosoftObjectId"
ON "AspNetUsers" ("MicrosoftTenantId", "MicrosoftObjectId");
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");
""";
private const string MySqlDdl = """
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, `MicrosoftTenantId` varchar(36) NULL,
`MicrosoftObjectId` varchar(36) NULL, `TotpSecretEncrypted` longtext NULL,
`TotpPendingSecretEncrypted` longtext NULL, `TotpEnabledAtUtc` datetime(6) NULL,
`PendingEmail` varchar(320) NULL, `PendingEmailRequestedAtUtc` datetime(6) NULL,
`AiEnabled` tinyint(1) NOT NULL DEFAULT 1,
`ExternalAiProcessingAllowed` tinyint(1) NOT NULL DEFAULT 0,
`StripeCustomerId` varchar(255) NULL, `StripeSubscriptionId` varchar(255) NULL,
`StripeSubscriptionStatus` varchar(64) NULL, `StripeLastEventCreatedUtc` datetime(6) NULL,
`DeletionRequestedAtUtc` datetime(6) NULL,
`DeletionStatus` varchar(32) NOT NULL DEFAULT 'active',
`EmailFollowUpRemindersEnabled` tinyint(1) NOT NULL DEFAULT 1,
`UiLanguage` varchar(16) 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 UNIQUE INDEX IF NOT EXISTS `IX_AspNetUsers_MicrosoftTenantId_MicrosoftObjectId`
ON `AspNetUsers` (`MicrosoftTenantId`, `MicrosoftObjectId`);
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`);
""";
}
@@ -267,118 +267,13 @@ public static class StartupInitializationExtensions
var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var useSqliteBootstrap = provider is not "mysql" and not "mariadb"; 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. // The schema reconciler. Runs TWICE: once before Migrate() and once after.
// //
// Before: legacy databases need their Identity tables and hand-added columns present, and // Before: legacy databases need hand-added columns repaired and the legacy
// the legacy migration-history stamp written, or Migrate() collides with them. // 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 // 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 // pass, so index, column, and AUTO_INCREMENT repairs were skipped. The second pass finds
// JobApplications, index and AUTO_INCREMENT repairs) was skipped. The second pass finds them // the migration-created tables and finishes compatibility repair.
// and finishes the job.
// //
// Every statement in here is existence-guarded, so running it twice is a no-op scan on an // 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. // 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. // ReconcileSchema runs twice; the second pass may inherit an already-open connection.
if (conn.State != System.Data.ConnectionState.Open) conn.Open(); 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` isnt available,
// so create the ASP.NET Core Identity tables directly if they dont 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", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName 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;"); EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
@@ -564,8 +346,8 @@ public static class StartupInitializationExtensions
ReconcileCareerProfileColumns(conn); ReconcileCareerProfileColumns(conn);
ReconcileAiInteractionUsageColumns(conn); ReconcileAiInteractionUsageColumns(conn);
// Once the base app tables exist, provision this reconciler-owned schema set and // Once the base app tables exist, stamp the historical migration before later
// stamp its historical migration before later migrations rebuild JobApplications. // migrations rebuild JobApplications.
var isLegacy = var isLegacy =
HasMigration(conn, "20260310174114_AddCorrespondence") && HasMigration(conn, "20260310174114_AddCorrespondence") &&
!HasMigration(conn, legacyMigrationId); !HasMigration(conn, legacyMigrationId);
@@ -649,8 +431,6 @@ public static class StartupInitializationExtensions
DbConnection conn = db.Database.GetDbConnection(); DbConnection conn = db.Database.GetDbConnection();
// ReconcileSchema runs twice; the second pass may inherit an already-open connection. // ReconcileSchema runs twice; the second pass may inherit an already-open connection.
if (conn.State != System.Data.ConnectionState.Open) conn.Open(); if (conn.State != System.Data.ConnectionState.Open) conn.Open();
EnsureIdentityTablesMySql(conn);
static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column) 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 // 1. Reconcile what already exists. This runs before Migrate() so legacy schemas are
// runs before Migrate() so legacy schemas are repaired before migrations inspect them. // repaired before migrations inspect them.
ReconcileSchema(); ReconcileSchema();
// 2. Apply one migration at a time, reconciling after each. Some historical migrations // 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) // historical migrations can add a later column (for example Companies.Source)
// immediately before the migration that owns it, producing a duplicate-column // 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 // 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(); migrationDb.Database.Migrate();
} }
} }
@@ -935,9 +715,8 @@ public static class StartupInitializationExtensions
throw; throw;
} }
// 3. Reconcile again, now that the migration-owned tables exist: creates the // 3. Reconcile again, now that migration-owned tables exist, applying the index and
// reconciler-owned tables that reference them and applies the index / AUTO_INCREMENT // AUTO_INCREMENT repairs pass 1 had to skip. Existing databases receive a no-op scan.
// repairs that pass 1 had to skip. Idempotent, so on an existing database it is a scan.
ReconcileSchema(); ReconcileSchema();
// Optional: seed an initial admin user for local username/password login. // Optional: seed an initial admin user for local username/password login.
@@ -15,6 +15,13 @@ internal static class StartupSchemaOwnership
"AiWorkspaceNotes", "AiWorkspaceNotes",
"AiInteractions", "AiInteractions",
"ApplicationChecklistItems", "ApplicationChecklistItems",
"AspNetRoleClaims",
"AspNetRoles",
"AspNetUserClaims",
"AspNetUserLogins",
"AspNetUserRoles",
"AspNetUsers",
"AspNetUserTokens",
"Attachments", "Attachments",
"CareerCertifications", "CareerCertifications",
"CareerEducations", "CareerEducations",
@@ -53,19 +60,10 @@ internal static class StartupSchemaOwnership
"UserSessions", "UserSessions",
}; };
internal static readonly IReadOnlySet<string> ReconcilerOwnedTables = new HashSet<string>(StringComparer.Ordinal) internal static readonly IReadOnlySet<string> ReconcilerOwnedTables = new HashSet<string>(StringComparer.Ordinal);
{
"AspNetRoleClaims",
"AspNetRoles",
"AspNetUserClaims",
"AspNetUserLogins",
"AspNetUserRoles",
"AspNetUsers",
"AspNetUserTokens",
};
// Historical migrations contain guarded compatibility bootstraps for these tables so direct // 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 = internal static readonly IReadOnlySet<string> MigrationCompatibilityBootstrapTables =
new HashSet<string>(StringComparer.Ordinal) { "AspNetUsers" }; new HashSet<string>(StringComparer.Ordinal) { "AspNetUsers" };
} }
+1 -1
View File
@@ -558,7 +558,7 @@ SEC-008 implements the same durable state machine with `<final>.uploading` and `
### P3-1 — Reduce dual schema ownership incrementally ### P3-1 — Reduce dual schema ownership incrementally
**Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. Twenty-eight formerly reconciler-created tables—including authentication support, email-provider connections, CV persistence/history, job-workspace AI notes, append-only AI interactions, workflow checklists, cover-letter history, durable interview preparation, and the complete Career Profile aggregate—have moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite runtime proof and generated MariaDB SQL. The Gmail decision migration also closes its missing MariaDB creation path. Only the seven ASP.NET Identity tables remain startup-created; see `docs/verification/jt-019-schema-ownership.md` and V-194V-207. **Status (2026-08-30): complete.** All 49 model tables have an executable, disjoint creation-owner classification and are migration-owned. All 35 formerly startup-created tables—including authentication/Identity, email-provider connections, CV persistence/history, job-workspace AI notes, workflow documents, durable interview preparation, and the complete Career Profile aggregate—moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite chain proof, and generated MariaDB SQL. The reconciler now performs guarded compatibility repair only. See `docs/verification/jt-019-schema-ownership.md` and V-194V-208.
- **Findings/scope:** JT-019; inventory reconciler operations and move stable schema ownership to EF migrations in small steps. - **Findings/scope:** JT-019; inventory reconciler operations and move stable schema ownership to EF migrations in small steps.
- **Dependencies:** provider upgrade fixtures and P2-2 restore safety. - **Dependencies:** provider upgrade fixtures and P2-2 restore safety.
+1
View File
@@ -239,3 +239,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-205 | Ownership/migration chain; populated adoption/downgrade/retry; document/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move append-only cover-letter revision history to migration ownership without losing recoverable documents | PASS — focused ownership/migration 17/17; manual and AI revisions plus source/action metadata survive adoption, downgrade and re-upgrade; owner/job/version index exists; deleting the parent application cascades through history; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 733/733 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Sixteen reconciler-owned tables remain | JT-019 cover-letter history transfer complete; interview-preparation items and career-profile aggregate continue incrementally | | V-205 | Ownership/migration chain; populated adoption/downgrade/retry; document/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move append-only cover-letter revision history to migration ownership without losing recoverable documents | PASS — focused ownership/migration 17/17; manual and AI revisions plus source/action metadata survive adoption, downgrade and re-upgrade; owner/job/version index exists; deleting the parent application cascades through history; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 733/733 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Sixteen reconciler-owned tables remain | JT-019 cover-letter history transfer complete; interview-preparation items and career-profile aggregate continue incrementally |
| V-206 | Ownership/migration chain; populated adoption/downgrade/retry; practice-state/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move durable interview-preparation items to migration ownership without losing user or AI preparation content | PASS — focused ownership/migration 18/18; user-authored and AI-generated questions, answers, prepared state and source metadata survive adoption, downgrade and re-upgrade; owner/job/sort index exists; deleting the parent application cascades through preparation items; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 734/734 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Fifteen reconciler-owned tables remain | JT-019 feature-table transfers complete; the Career Profile aggregate and Identity group remain dependency-aware batches | | V-206 | Ownership/migration chain; populated adoption/downgrade/retry; practice-state/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move durable interview-preparation items to migration ownership without losing user or AI preparation content | PASS — focused ownership/migration 18/18; user-authored and AI-generated questions, answers, prepared state and source metadata survive adoption, downgrade and re-upgrade; owner/job/sort index exists; deleting the parent application cascades through preparation items; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 734/734 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Fifteen reconciler-owned tables remain | JT-019 feature-table transfers complete; the Career Profile aggregate and Identity group remain dependency-aware batches |
| V-207 | Ownership/migration chain; populated aggregate adoption/downgrade/retry; JSON/text/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the canonical Career Profile, append-only history, and six relational child types to migration ownership without losing career data | PASS — focused ownership/migration 19/19; canonical and long-tail JSON, Norwegian text, version history, experience, education, skill, project, certification and language rows survive adoption, downgrade and re-upgrade; all 14 aggregate indexes exist; deleting the profile cascades through history and children; generated MariaDB SQL uses bounded indexed keys and provider-safe types; startup creates none of the eight tables; full backend 735/735 | MariaDB SQL generated only; no production migration. Guarded historical LongTailJson/index/auto-increment repairs remain temporarily. Seven reconciler-owned Identity tables remain | JT-019 feature and career transfers complete; isolate the Identity ownership boundary next | | V-207 | Ownership/migration chain; populated aggregate adoption/downgrade/retry; JSON/text/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the canonical Career Profile, append-only history, and six relational child types to migration ownership without losing career data | PASS — focused ownership/migration 19/19; canonical and long-tail JSON, Norwegian text, version history, experience, education, skill, project, certification and language rows survive adoption, downgrade and re-upgrade; all 14 aggregate indexes exist; deleting the profile cascades through history and children; generated MariaDB SQL uses bounded indexed keys and provider-safe types; startup creates none of the eight tables; full backend 735/735 | MariaDB SQL generated only; no production migration. Guarded historical LongTailJson/index/auto-increment repairs remain temporarily. Seven reconciler-owned Identity tables remain | JT-019 feature and career transfers complete; isolate the Identity ownership boundary next |
| V-208 | Ownership/migration chain; populated Identity adoption/downgrade/retry; credential/preference/FK/index assertions; MariaDB script; full backend; fresh application startup | Repository root / disposable local SQLite | Complete JT-019 by moving the seven ASP.NET Identity tables to migration ownership without invalidating authentication data | PASS — focused ownership/migration 20/20; password hash, Bokmål preference, 2FA state, role assignment, role/user claims, external login and token survive adoption, downgrade and re-upgrade; all eight Identity indexes exist; user and role deletion cascades remain effective; generated MariaDB SQL is provider-safe; startup creates no model tables; full backend 736/736; a fresh application applies the complete chain, reaches Identity role initialization and listens normally | MariaDB SQL generated only; no production migration. The historical AspNetUsers compatibility bootstrap and guarded column/index repairs remain for chain traversal and legacy upgrades. Launch settings supplied an existing policy-invalid development admin password, producing a non-fatal seed warning | JT-019 complete; future work may retire individual repair statements only after provider-backed historical fixtures prove them redundant |
+11 -10
View File
@@ -65,7 +65,8 @@ already-correct database. Two consequences worth knowing:
Created by EF migrations, never by the reconciler: Created by EF migrations, never by the reconciler:
`AccountDeletionFiles`, `AccountDeletionRequests`, `AiInteractions`, `AiUsageRecords`, `AiWorkspaceNotes`, `AccountDeletionFiles`, `AccountDeletionRequests`, `AiInteractions`, `AiUsageRecords`, `AiWorkspaceNotes`,
`ApplicationChecklistItems`, `Attachments`, `CareerCertifications`, `CareerEducations`, `ApplicationChecklistItems`, `AspNetRoleClaims`, `AspNetRoles`, `AspNetUserClaims`, `AspNetUserLogins`,
`AspNetUserRoles`, `AspNetUsers`, `AspNetUserTokens`, `Attachments`, `CareerCertifications`, `CareerEducations`,
`CareerExperiences`, `CareerLanguages`, `CareerProfiles`, `CareerProfileVersions`, `CareerProjects`, `CareerExperiences`, `CareerLanguages`, `CareerProfiles`, `CareerProfileVersions`, `CareerProjects`,
`CareerSkills`, `Companies`, `CoverLetterVersions`, `CareerSkills`, `Companies`, `CoverLetterVersions`,
`Correspondences`, `CvExtractionRuns`, `CvUploadArtifacts`, `CvVariants`, `CvVariantVersions`, `Correspondences`, `CvExtractionRuns`, `CvUploadArtifacts`, `CvVariants`, `CvVariantVersions`,
@@ -132,6 +133,11 @@ child tables) moved in `20260830133000_AdoptCareerProfileSchema`. Canonical/long
append-only history, stable child keys and ordering are retained; bounded MariaDB owner/item keys append-only history, stable child keys and ordering are retained; bounded MariaDB owner/item keys
keep all aggregate indexes provider-safe. keep all aggregate indexes provider-safe.
The ASP.NET Identity aggregate moved in `20260830134000_AdoptIdentitySchema`. Accounts, password
hashes, security/2FA state, UI preferences, roles, claims, external logins, tokens, indexes, and
cascades are retained. The earlier guarded `AspNetUsers` bootstrap remains only so historical
standalone migration traversal can reach later additive user-column migrations.
The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT` The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT`
primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them. primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them.
It used to create `RuleSettings`, which is precisely why a clean install failed with It used to create `RuleSettings`, which is precisely why a clean install failed with
@@ -139,12 +145,8 @@ It used to create `RuleSettings`, which is precisely why a clean install failed
### Reconciler-owned ### Reconciler-owned
The seven ASP.NET Identity tables are currently reconciler-owned, despite older wording that None. The reconciler contains guarded historical repair logic, but it no longer creates EF model
called them migration-owned: `AspNetRoles`, `AspNetUsers`, `AspNetRoleClaims`, `AspNetUserClaims`, tables. Compatibility bootstraps inside old migrations are chain prerequisites, not current owners.
`AspNetUserLogins`, `AspNetUserRoles`, and `AspNetUserTokens`. Guarded migration bootstraps for
`AspNetUsers` supports standalone traversal but does not yet transfer ownership. The historical
`AiInteractions` bootstrap remains in the migration chain, but its current owner is the later
provider-aware adoption migration.
`StartupSchemaOwnership` is the executable inventory. Its tests require every EF model table to `StartupSchemaOwnership` is the executable inventory. Its tests require every EF model table to
have exactly one creation owner and keep compatibility bootstraps out of the migration-owned set. have exactly one creation owner and keep compatibility bootstraps out of the migration-owned set.
@@ -222,9 +224,8 @@ everything inside it.
Standalone EF tooling is also supported for a blank SQLite database. The historical initial Standalone EF tooling is also supported for a blank SQLite database. The historical initial
migration now supplies the stable JobApplication columns required by later SQLite rebuilds, and migration now supplies the stable JobApplication columns required by later SQLite rebuilds, and
guarded compatibility bootstraps provide the reconciler-owned source tables used by later additive guarded compatibility bootstraps provide source tables used by later additive migrations.
migrations. Application startup may subsequently reconcile the remaining Identity and auxiliary Application startup may subsequently apply guarded historical column/index repairs without losing rows.
tables without losing rows.
## Production upgrade ## Production upgrade
+11 -2
View File
@@ -44,6 +44,8 @@ cohesive table groups. It does not attempt to delete the legacy reconciler whole
- Added `20260830133000_AdoptCareerProfileSchema` for the canonical profile, append-only history, - Added `20260830133000_AdoptCareerProfileSchema` for the canonical profile, append-only history,
and all six ordered relational child types. Legacy JSON, Norwegian text, stable keys, and cascade and all six ordered relational child types. Legacy JSON, Norwegian text, stable keys, and cascade
ownership are retained. ownership are retained.
- Added `20260830134000_AdoptIdentitySchema` for the complete ASP.NET Identity aggregate, preserving
accounts, password hashes, 2FA state, preferences, roles, claims, external logins, and tokens.
- Corrected the ownership runbook: new tables now default to migrations; the reconciler is legacy - Corrected the ownership runbook: new tables now default to migrations; the reconciler is legacy
compatibility code to retire one dependency group at a time. compatibility code to retire one dependency group at a time.
@@ -87,14 +89,21 @@ migration.
- A populated Career Profile with history, long-tail JSON, Norwegian text, experience, education, - A populated Career Profile with history, long-tail JSON, Norwegian text, experience, education,
skills, projects, certifications, and languages survives adoption, downgrade, and re-upgrade; all skills, projects, certifications, and languages survives adoption, downgrade, and re-upgrade; all
14 aggregate indexes and parent cascades remain effective. 14 aggregate indexes and parent cascades remain effective.
- A populated Identity account with password hash, Bokmål preference, 2FA state, role assignment,
role/user claims, external login, and token survives adoption, downgrade, and re-upgrade; all eight
Identity indexes and user/role cascades remain effective.
- Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL. - Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL.
- Full backend: 735/735 passed after the Career Profile aggregate transfer. - Full backend: 736/736 passed after the Identity aggregate transfer.
- Fresh application startup over a new disposable SQLite data root applied the complete chain,
queried/seeded Identity roles, and reached the healthy listening state with no schema failure.
- Fresh application startup over a new disposable SQLite database applied - Fresh application startup over a new disposable SQLite database applied
`20260830120000_AdoptSystemEmailSettingsSchema` and reached the healthy listening state. `20260830120000_AdoptSystemEmailSettingsSchema` and reached the healthy listening state.
## Remaining JT-019 work ## Remaining JT-019 work
Seven model tables remain startup-created: the ASP.NET Identity group. No EF model tables remain startup-created. JT-019's creation-ownership transfer is complete; the
startup reconciler now performs compatibility repair only and can be reduced further when historical
provider fixtures prove individual repairs obsolete.
with parent dependencies. Transfer them in small dependency-aware migrations with blank, populated, with parent dependencies. Transfer them in small dependency-aware migrations with blank, populated,
retry and MariaDB runtime proof. Column/index repairs must remain until historical upgrade fixtures retry and MariaDB runtime proof. Column/index repairs must remain until historical upgrade fixtures
prove each one redundant. prove each one redundant.
+2 -1
View File
@@ -57,6 +57,7 @@ Updated: 2026-08-30
- Moved append-only cover-letter revision history into a provider-aware migration; manual and AI text, source/action metadata, ordering, and application cascades are preserved. - Moved append-only cover-letter revision history into a provider-aware migration; manual and AI text, source/action metadata, ordering, and application cascades are preserved.
- Moved durable interview-preparation items into a provider-aware migration; user and AI content, answers, practice state, sources, ordering, and application cascades are preserved. - Moved durable interview-preparation items into a provider-aware migration; user and AI content, answers, practice state, sources, ordering, and application cascades are preserved.
- Moved the complete Career Profile aggregate into a provider-aware migration; canonical and long-tail JSON, version history, all six relational child types, stable ordering, indexes, and cascades are preserved. - Moved the complete Career Profile aggregate into a provider-aware migration; canonical and long-tail JSON, version history, all six relational child types, stable ordering, indexes, and cascades are preserved.
- Completed JT-019 by moving all seven ASP.NET Identity tables into provider-aware migration ownership; accounts, credentials, 2FA state, preferences, roles, claims, external logins, tokens, indexes, and cascades are preserved. Startup schema code is now repair-only.
### In progress ### In progress
@@ -87,7 +88,7 @@ Updated: 2026-08-30
- Focused frontend: 2 suites, 6 tests passed. - Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed. - Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed. - Next production build and TypeScript: passed.
- Full backend: 735/735 tests passed after the Career Profile aggregate JT-019 transfer. - Full backend: 736/736 tests passed after completing the Identity/JT-019 ownership transfer.
- Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors. - Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors.
- Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate. - Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate.
- Focused backend match/intelligence verification: 34/34 passed. - Focused backend match/intelligence verification: 34/34 passed.