refactor(db): migrate auth support tables

Move recovery codes, trusted devices, and revocable sessions into an additive provider-aware migration. Preserve existing security records and retain guarded MariaDB repairs for historical schemas.
This commit is contained in:
cesnimda
2026-08-30 16:52:07 +02:00
parent b425f1edc5
commit 2e2d649f6f
9 changed files with 209 additions and 113 deletions
@@ -102,6 +102,9 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `SystemEmailSettings`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `SystemEmailSettings`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `UserRuleSettings`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `UserRuleSettings`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `GmailReviewDecisions`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `GmailReviewDecisions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `TrustedDevices`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `UserSessions`", 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),
@@ -223,6 +226,83 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync()); Assert.Empty(await db.Database.GetPendingMigrationsAsync());
} }
[Fact]
public async Task Authentication_support_adoption_preserves_legacy_rows()
{
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("20260830122000_AdoptGmailReviewDecisionsSchema");
await ExecuteAsync(connection, """
CREATE TABLE "TwoFactorRecoveryCodes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"CodeHash" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UsedAtUtc" TEXT NULL
);
INSERT INTO "TwoFactorRecoveryCodes" ("UserId", "CodeHash", "CreatedAtUtc")
VALUES ('owner-fixture', 'recovery-hash', '2026-08-30T09:00:00+00:00');
CREATE TABLE "TrustedDevices" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"TokenHash" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL
);
INSERT INTO "TrustedDevices"
("UserId", "TokenHash", "DeviceLabel", "CreatedAtUtc", "LastSeenAtUtc", "ExpiresAtUtc")
VALUES
('owner-fixture', 'device-hash', 'Fixture browser', '2026-08-30T09:00:00+00:00',
'2026-08-30T09:30:00+00:00', '2026-09-29T09:00:00+00:00');
CREATE TABLE "UserSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL,
"RevokedAtUtc" TEXT NULL
);
INSERT INTO "UserSessions"
("Id", "UserId", "DeviceLabel", "CreatedAtUtc", "LastSeenAtUtc", "ExpiresAtUtc")
VALUES
('session-fixture', 'owner-fixture', 'Fixture session', '2026-08-30T09:00:00+00:00',
'2026-08-30T09:30:00+00:00', '2026-08-31T09:00:00+00:00');
""");
await migrator.MigrateAsync();
Assert.Equal("recovery-hash", await ScalarAsync<string>(connection,
"SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';"));
Assert.Equal("Fixture browser", await ScalarAsync<string>(connection,
"SELECT DeviceLabel FROM TrustedDevices WHERE TokenHash = 'device-hash';"));
Assert.Equal("Fixture session", await ScalarAsync<string>(connection,
"SELECT DeviceLabel FROM UserSessions WHERE Id = 'session-fixture';"));
await migrator.MigrateAsync("20260830122000_AdoptGmailReviewDecisionsSchema");
Assert.Equal("recovery-hash", await ScalarAsync<string>(connection,
"SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';"));
Assert.Equal("device-hash", await ScalarAsync<string>(connection,
"SELECT TokenHash FROM TrustedDevices WHERE UserId = 'owner-fixture';"));
Assert.Equal("session-fixture", await ScalarAsync<string>(connection,
"SELECT Id FROM UserSessions WHERE UserId = 'owner-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(3L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'index' AND name IN (
'IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc',
'IX_TrustedDevices_UserId',
'IX_UserSessions_UserId');
"""));
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>();
@@ -0,0 +1,102 @@
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
/// <summary>
/// Moves the authentication support tables from startup reconciliation into the migration chain
/// without replacing tables or rows that pre-date migration ownership.
/// </summary>
[DbContext(typeof(JobTrackerContext))]
[Migration("20260830123000_AdoptAuthenticationSupportSchema")]
public sealed class AdoptAuthenticationSupportSchema : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`CodeHash` varchar(255) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UsedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
CREATE INDEX IF NOT EXISTS `IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc`
ON `TwoFactorRecoveryCodes` (`UserId`, `UsedAtUtc`);
CREATE TABLE IF NOT EXISTS `TrustedDevices` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`TokenHash` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
CREATE INDEX IF NOT EXISTS `IX_TrustedDevices_UserId` ON `TrustedDevices` (`UserId`);
CREATE INDEX IF NOT EXISTS `IX_TrustedDevices_TokenHash` ON `TrustedDevices` (`TokenHash`);
CREATE TABLE IF NOT EXISTS `UserSessions` (
`Id` varchar(64) NOT NULL,
`UserId` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
`RevokedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
CREATE INDEX IF NOT EXISTS `IX_UserSessions_UserId` ON `UserSessions` (`UserId`);
""");
return;
}
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"CodeHash" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UsedAtUtc" TEXT NULL
);
CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc"
ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");
CREATE TABLE IF NOT EXISTS "TrustedDevices" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"TokenHash" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_UserId" ON "TrustedDevices" ("UserId");
CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");
CREATE TABLE IF NOT EXISTS "UserSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL,
"RevokedAtUtc" TEXT NULL
);
CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Preserve security records that may have been created before migration ownership.
}
}
@@ -687,56 +687,6 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
} }
static void EnsureTwoFactorRecoveryCodesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"CodeHash" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UsedAtUtc" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");""");
}
static void EnsureTrustedDevicesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "TrustedDevices" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"TokenHash" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_UserId" ON "TrustedDevices" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");""");
}
static void EnsureUserSessionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "UserSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL,
"RevokedAtUtc" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");""");
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md // Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the // Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
// authoritative column every existing read path uses; these mirror it so future // authoritative column every existing read path uses; these mirror it so future
@@ -1078,9 +1028,6 @@ public static class StartupInitializationExtensions
EnsureMicrosoftGraphConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn); EnsureImapConnectionsTable(conn);
EnsureCvTables(conn); EnsureCvTables(conn);
EnsureTwoFactorRecoveryCodesTable(conn);
EnsureTrustedDevicesTable(conn);
EnsureUserSessionsTable(conn);
EnsureCareerProfileTables(conn); EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn); EnsureInterviewPrepNotesTable(conn);
EnsureAiWorkspaceNotesTable(conn); EnsureAiWorkspaceNotesTable(conn);
@@ -1820,62 +1767,16 @@ public static class StartupInitializationExtensions
EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`"); EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`CodeHash` varchar(255) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UsedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id");
EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`"); EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`");
if (!HasMySqlTable(conn, "TrustedDevices"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TrustedDevices` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`TokenHash` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id");
EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`"); EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`");
EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`"); EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`");
if (!HasMySqlTable(conn, "UserSessions"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserSessions` (
`Id` varchar(64) NOT NULL,
`UserId` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
`RevokedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "UserSessions", "IX_UserSessions_UserId", "`UserId`"); EnsureMySqlIndex(conn, "UserSessions", "IX_UserSessions_UserId", "`UserId`");
// Schema reconciliation must never crash app startup: an index that fails // Schema reconciliation must never crash app startup: an index that fails
@@ -23,9 +23,12 @@ internal static class StartupSchemaOwnership
"Jobs", "Jobs",
"RuleSettings", "RuleSettings",
"SystemEmailSettings", "SystemEmailSettings",
"TrustedDevices",
"TwoFactorRecoveryCodes",
"UserNotifications", "UserNotifications",
"UserOperations", "UserOperations",
"UserRuleSettings", "UserRuleSettings",
"UserSessions",
}; };
internal static readonly IReadOnlySet<string> ReconcilerOwnedTables = new HashSet<string>(StringComparer.Ordinal) internal static readonly IReadOnlySet<string> ReconcilerOwnedTables = new HashSet<string>(StringComparer.Ordinal)
@@ -59,9 +62,6 @@ internal static class StartupSchemaOwnership
"InterviewPrepNotes", "InterviewPrepNotes",
"MicrosoftGraphConnections", "MicrosoftGraphConnections",
"TailoredCvDrafts", "TailoredCvDrafts",
"TrustedDevices",
"TwoFactorRecoveryCodes",
"UserSessions",
}; };
// Historical migrations contain guarded compatibility bootstraps for these tables so direct // Historical migrations contain guarded compatibility bootstraps for these tables so direct
+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. `SystemEmailSettings`, `UserRuleSettings`, and `GmailReviewDecisions` 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. Thirty-two startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194V-196. **Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. `SystemEmailSettings`, `UserRuleSettings`, `GmailReviewDecisions`, `TwoFactorRecoveryCodes`, `TrustedDevices`, and `UserSessions` 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. Twenty-nine startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194V-197.
- **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
@@ -228,3 +228,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-194 | Executable 49-table ownership partition; focused ownership/migration chain; legacy-row upgrade; MariaDB script; full backend; fresh application SQLite startup | Repository root / disposable local SQLite | Establish one creation owner per model table and transfer the independent SystemEmailSettings table from startup DDL to EF migration ownership | PASS — ownership/migration 6/6; blank and repeated migration succeed; representative legacy SMTP row survives; provider SQL is present; backend 722/722; fresh runtime applies `20260830120000_AdoptSystemEmailSettingsSchema` and starts healthy | MariaDB SQL is generated but the new migration was not executed on a live MariaDB server; downgrade deliberately preserves ambiguous pre-migration data. Thirty-four reconciler-owned tables remain | JT-019 first migration-backed ownership transfer complete; provider runtime and remaining dependency groups continue incrementally | | V-194 | Executable 49-table ownership partition; focused ownership/migration chain; legacy-row upgrade; MariaDB script; full backend; fresh application SQLite startup | Repository root / disposable local SQLite | Establish one creation owner per model table and transfer the independent SystemEmailSettings table from startup DDL to EF migration ownership | PASS — ownership/migration 6/6; blank and repeated migration succeed; representative legacy SMTP row survives; provider SQL is present; backend 722/722; fresh runtime applies `20260830120000_AdoptSystemEmailSettingsSchema` and starts healthy | MariaDB SQL is generated but the new migration was not executed on a live MariaDB server; downgrade deliberately preserves ambiguous pre-migration data. Thirty-four reconciler-owned tables remain | JT-019 first migration-backed ownership transfer complete; provider runtime and remaining dependency groups continue incrementally |
| V-195 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Transfer the independent UserRuleSettings table from both startup-DDL paths to migration ownership without losing per-user settings | PASS — focused ownership/migration 7/7; representative owner row survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; startup no longer contains the table create; full backend 723/723 | MariaDB SQL generated only; no production migration. Down preserves ambiguous legacy data by design. Thirty-three reconciler-owned tables remain | JT-019 second leaf transfer complete; dependency groups continue incrementally | | V-195 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Transfer the independent UserRuleSettings table from both startup-DDL paths to migration ownership without losing per-user settings | PASS — focused ownership/migration 7/7; representative owner row survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; startup no longer contains the table create; full backend 723/723 | MariaDB SQL generated only; no production migration. Down preserves ambiguous legacy data by design. Thirty-three reconciler-owned tables remain | JT-019 second leaf transfer complete; dependency groups continue incrementally |
| V-196 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Move GmailReviewDecisions to migration ownership and close the missing MariaDB table path | PASS — focused ownership/migration 8/8; representative decision survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; SQLite startup create is removed; full backend 724/724 | MariaDB SQL generated only; no provider account or production migration. Down preserves ambiguous legacy data by design. Thirty-two reconciler-owned tables remain | JT-019 third leaf transfer complete; provider runtime and dependency groups continue incrementally | | V-196 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Move GmailReviewDecisions to migration ownership and close the missing MariaDB table path | PASS — focused ownership/migration 8/8; representative decision survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; SQLite startup create is removed; full backend 724/724 | MariaDB SQL generated only; no provider account or production migration. Down preserves ambiguous legacy data by design. Thirty-two reconciler-owned tables remain | JT-019 third leaf transfer complete; provider runtime and dependency groups continue incrementally |
| V-197 | Ownership/migration chain; populated adoption/downgrade/retry; index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the authentication-support group to migration ownership without invalidating recovery codes, trusted devices, or active sessions | PASS — focused ownership/migration 9/9; representative rows survive adoption, downgrade and re-upgrade; expected indexes exist; generated MariaDB SQL contains all three provider-safe definitions; both startup-create paths are removed; full backend 725/725 | MariaDB SQL generated only; no provider account or production migration. Guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-nine reconciler-owned tables remain | JT-019 authentication-support transfer complete; dependent feature groups continue incrementally |
+10 -4
View File
@@ -67,8 +67,10 @@ 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`, `AiUsageRecords`, `Attachments`, `Companies`, `AccountDeletionFiles`, `AccountDeletionRequests`, `AiUsageRecords`, `Attachments`, `Companies`,
`Correspondences`, `EmailDrafts`, `EmailSendAttempts`, `GmailReviewDecisions`, `JobApplications`, `JobEvents`, `Jobs`, `Correspondences`, `EmailDrafts`, `EmailSendAttempts`, `GmailReviewDecisions`, `JobApplications`,
`RuleSettings`, `SystemEmailSettings`, `UserNotifications`, `UserOperations`, and `UserRuleSettings`. `JobEvents`, `Jobs`, `RuleSettings`, `SystemEmailSettings`, `TrustedDevices`,
`TwoFactorRecoveryCodes`, `UserNotifications`, `UserOperations`, `UserRuleSettings`, and
`UserSessions`.
`SystemEmailSettings` is the first completed ownership transfer: migration `SystemEmailSettings` is the first completed ownership transfer: migration
`20260830120000_AdoptSystemEmailSettingsSchema` creates it for both supported providers and preserves `20260830120000_AdoptSystemEmailSettingsSchema` creates it for both supported providers and preserves
@@ -80,6 +82,11 @@ preserved across adoption, downgrade and retry, and startup no longer creates it
`GmailReviewDecisions` moved in `20260830122000_AdoptGmailReviewDecisionsSchema`. This also supplies `GmailReviewDecisions` moved in `20260830122000_AdoptGmailReviewDecisionsSchema`. This also supplies
the table on MariaDB, where the old reconciler had no creation path. the table on MariaDB, where the old reconciler had no creation path.
The authentication support group (`TwoFactorRecoveryCodes`, `TrustedDevices`, and `UserSessions`)
moved in `20260830123000_AdoptAuthenticationSupportSchema`. These tables deliberately have no
database foreign key to `AspNetUsers` because they are queried during authentication before a
current-user scope exists.
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
@@ -94,8 +101,7 @@ Created by `StartupInitializationExtensions`, with a **no-op migration** holding
`CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`, `CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`,
`CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`), `CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`),
`InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`,
`ApplicationChecklistItems`, `CoverLetterVersions`, `InterviewPrepItems`, `TwoFactorRecoveryCodes`, `ApplicationChecklistItems`, `CoverLetterVersions`, and `InterviewPrepItems`.
`TrustedDevices`, `UserSessions`.
The seven ASP.NET Identity tables are also currently reconciler-owned, despite older wording that The seven ASP.NET Identity tables are also currently reconciler-owned, despite older wording that
called them migration-owned: `AspNetRoles`, `AspNetUsers`, `AspNetRoleClaims`, `AspNetUserClaims`, called them migration-owned: `AspNetRoles`, `AspNetUsers`, `AspNetRoleClaims`, `AspNetUserClaims`,
+10 -5
View File
@@ -1,9 +1,9 @@
# JT-019 schema ownership — first migration-backed transfer # JT-019 schema ownership — incremental migration transfers
## Scope ## Scope
This increment establishes a behaviour-preserving ownership boundary and moves one independent This work establishes a behaviour-preserving ownership boundary and moves small independent or
table. It does not attempt to delete the legacy reconciler wholesale. cohesive table groups. It does not attempt to delete the legacy reconciler wholesale.
## Implemented ## Implemented
@@ -19,6 +19,9 @@ table. It does not attempt to delete the legacy reconciler wholesale.
for the independent per-user rule-settings table. for the independent per-user rule-settings table.
- Added `20260830122000_AdoptGmailReviewDecisionsSchema`, removed the SQLite startup-create path, - Added `20260830122000_AdoptGmailReviewDecisionsSchema`, removed the SQLite startup-create path,
and supplied the previously absent MariaDB table definition. and supplied the previously absent MariaDB table definition.
- Added `20260830123000_AdoptAuthenticationSupportSchema` for recovery codes, trusted devices, and
revocable user sessions; both provider startup-create paths are removed while guarded MariaDB
repair checks remain for historical schemas.
- 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.
@@ -38,14 +41,16 @@ migration.
settings row, upgrades without changing that row. settings row, upgrades without changing that row.
- A representative per-user rules row survives adoption, downgrade, and re-upgrade. - A representative per-user rules row survives adoption, downgrade, and re-upgrade.
- A representative Gmail review decision survives adoption, downgrade, and re-upgrade. - A representative Gmail review decision survives adoption, downgrade, and re-upgrade.
- Representative recovery-code, trusted-device, and user-session rows survive adoption, downgrade,
and re-upgrade, and their indexes are present afterwards.
- Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL. - Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL.
- Full backend: 724/724 passed after all three leaf-table transfers. - Full backend: 725/725 passed after the authentication-support transfer.
- 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
Thirty-two model tables remain startup-created, including the Identity group and several tables Twenty-nine model tables remain startup-created, including the Identity group and several tables
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
@@ -46,6 +46,7 @@ Updated: 2026-08-30
- Began the JT-019 schema-ownership retirement with an executable 49-table ownership partition and transferred the leaf `SystemEmailSettings` table from MariaDB-only startup DDL to an additive provider-aware migration. Fresh SQLite now receives the table; legacy rows are preserved and startup no longer creates it. - Began the JT-019 schema-ownership retirement with an executable 49-table ownership partition and transferred the leaf `SystemEmailSettings` table from MariaDB-only startup DDL to an additive provider-aware migration. Fresh SQLite now receives the table; legacy rows are preserved and startup no longer creates it.
- Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry. - Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry.
- Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path. - Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path.
- Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry.
### In progress ### In progress
@@ -76,7 +77,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: 724/724 tests passed after the third JT-019 ownership transfer. - Full backend: 725/725 tests passed after the authentication-support JT-019 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.