refactor(db): migrate career profile aggregate

Transfer the canonical career profile, revision history, and six relational child collections to provider-aware migration ownership. Preserve legacy JSON, ordered child data, indexes, and cascade semantics.
This commit is contained in:
cesnimda
2026-08-30 21:54:54 +02:00
parent a906916acc
commit 90ca23de33
13 changed files with 404 additions and 355 deletions
+137
View File
@@ -118,6 +118,14 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CoverLetterVersions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `InterviewPrepItems`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfiles`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfileVersions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerExperiences`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerEducations`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerSkills`", 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 `CareerLanguages`", script, StringComparison.Ordinal);
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
@@ -878,6 +886,135 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Career_profile_adoption_preserves_canonical_history_and_relational_children()
{
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("20260830132000_AdoptInterviewPrepItemSchema");
await ExecuteAsync(connection, """
CREATE TABLE "CareerProfiles" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "ProfileJson" TEXT NOT NULL,
"LongTailJson" TEXT NOT NULL DEFAULT '', "Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL, "UpdatedAtUtc" TEXT NOT NULL);
CREATE TABLE "CareerProfileVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "CareerProfileId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL, "ProfileJson" TEXT NOT NULL, "Source" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerExperiences" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Title" TEXT NULL, "Company" TEXT NULL,
"Location" TEXT NULL, "Start" TEXT NULL, "End" TEXT NULL, "StartDate" TEXT NULL,
"EndDate" TEXT NULL, "IsCurrent" INTEGER NOT NULL DEFAULT 0,
"BulletsJson" TEXT NOT NULL DEFAULT '[]', "SkillsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerEducations" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Qualification" TEXT NULL,
"QualificationLevel" TEXT NULL, "Institution" TEXT NULL, "Location" TEXT NULL,
"Start" TEXT NULL, "End" TEXT NULL, "StartDate" TEXT NULL, "EndDate" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]', "CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL, "ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerSkills" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Name" TEXT NULL, "Category" TEXT NULL,
"Proficiency" TEXT NULL, "CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL, "ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerProjects" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Name" TEXT NULL, "Role" TEXT NULL,
"Location" TEXT NULL, "Start" TEXT NULL, "End" TEXT NULL, "StartDate" TEXT NULL,
"EndDate" TEXT NULL, "BulletsJson" TEXT NOT NULL DEFAULT '[]',
"SkillsJson" TEXT NOT NULL DEFAULT '[]', "LinksJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerCertifications" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Name" TEXT NULL, "Issuer" TEXT NULL,
"Location" TEXT NULL, "Date" TEXT NULL, "DateNormalized" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]', "CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL, "ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
CREATE TABLE "CareerLanguages" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Name" TEXT NULL, "Level" TEXT NULL,
"Notes" TEXT NULL, "CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE);
INSERT INTO "CareerProfiles" VALUES
(1, 'owner-fixture', '{"summary":"Canonical profile"}',
'{"interests":["friluftsliv"]}', 3,
'2026-08-01T09:00:00+00:00', '2026-08-30T09:00:00+00:00');
INSERT INTO "CareerProfileVersions"
("OwnerUserId", "CareerProfileId", "Version", "ProfileJson", "Source", "CreatedAtUtc")
VALUES ('owner-fixture', 1, 2, '{"summary":"Previous version"}', 'manual',
'2026-08-20T09:00:00+00:00');
INSERT INTO "CareerExperiences"
("Title", "Company", "IsCurrent", "BulletsJson", "SkillsJson", "CareerProfileId",
"OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Systemutvikler', 'Eksempel AS', 1, '["Bygget pålitelig API"]', '[".NET"]',
1, 'owner-fixture', 'experience-1', 10);
INSERT INTO "CareerEducations"
("Qualification", "Institution", "DetailsJson", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Bachelor', 'Høgskolen', '["Programmering"]', 1, 'owner-fixture', 'education-1', 20);
INSERT INTO "CareerSkills"
("Name", "Category", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('C#', 'Backend', 1, 'owner-fixture', 'skill-1', 30);
INSERT INTO "CareerProjects"
("Name", "BulletsJson", "SkillsJson", "LinksJson", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Jobbjakt', '["Dynamisk CV"]', '["React"]', '["https://example.test"]',
1, 'owner-fixture', 'project-1', 40);
INSERT INTO "CareerCertifications"
("Name", "Issuer", "DetailsJson", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Azure', 'Microsoft', '[]', 1, 'owner-fixture', 'certification-1', 50);
INSERT INTO "CareerLanguages"
("Name", "Level", "Notes", "CareerProfileId", "OwnerUserId", "ItemKey", "SortOrder")
VALUES ('Norsk', 'B2', 'Aktivt lærende', 1, 'owner-fixture', 'language-1', 60);
""");
await migrator.MigrateAsync();
Assert.Equal("{\"interests\":[\"friluftsliv\"]}", await ScalarAsync<string>(connection,
"SELECT LongTailJson FROM CareerProfiles WHERE Id = 1;"));
Assert.Equal("Previous version", await ScalarAsync<string>(connection,
"SELECT json_extract(ProfileJson, '$.summary') FROM CareerProfileVersions WHERE Version = 2;"));
Assert.Equal(6L, await ScalarAsync<long>(connection, """
SELECT (SELECT COUNT(*) FROM CareerExperiences) +
(SELECT COUNT(*) FROM CareerEducations) +
(SELECT COUNT(*) FROM CareerSkills) +
(SELECT COUNT(*) FROM CareerProjects) +
(SELECT COUNT(*) FROM CareerCertifications) +
(SELECT COUNT(*) FROM CareerLanguages);
"""));
await migrator.MigrateAsync("20260830132000_AdoptInterviewPrepItemSchema");
Assert.Equal("Eksempel AS", await ScalarAsync<string>(connection,
"SELECT Company FROM CareerExperiences WHERE ItemKey = 'experience-1';"));
await migrator.MigrateAsync();
Assert.Equal(14L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (
'IX_CareerProfiles_OwnerUserId',
'IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version',
'IX_CareerExperiences_CareerProfileId', 'IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerEducations_CareerProfileId', 'IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerSkills_CareerProfileId', 'IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerProjects_CareerProfileId', 'IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerCertifications_CareerProfileId', 'IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder',
'IX_CareerLanguages_CareerProfileId', 'IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder');
"""));
await ExecuteAsync(connection, "DELETE FROM CareerProfiles WHERE Id = 1;");
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CareerProfileVersions;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CareerExperiences;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM CareerLanguages;"));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection)
{
var currentUser = new Mock<ICurrentUserService>();
+5
View File
@@ -385,6 +385,8 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<CareerProfile>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CareerProfile>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<CareerProfile>()
.HasIndex(x => x.OwnerUserId)
.IsUnique();
@@ -392,6 +394,9 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<CareerProfileVersion>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CareerProfileVersion>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<CareerProfileVersion>().Property(x => x.Source).HasMaxLength(100);
modelBuilder.Entity<CareerProfileVersion>()
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.Version });
@@ -11,9 +11,9 @@ namespace JobTrackerApi.Migrations
// longtext OwnerUserId and then indexed (OwnerUserId, CareerProfileId, SortOrder) over it,
// which exceeds MySQL's 3072-byte key limit — a clean MariaDB install died here.
//
// The six CareerProfile child tables are provisioned by the idempotent reconciler in
// StartupInitializationExtensions, which carries correct DDL per provider. Existing databases
// already have this migration recorded as applied, so emptying it changes nothing for them.
// The six CareerProfile child tables were initially provisioned by the reconciler. Explicit
// ownership moved to 20260830133000_AdoptCareerProfileSchema; this historical migration stays
// recorded so existing chains remain compatible.
// docs/infrastructure/database-ownership.md.
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -7,6 +7,8 @@ namespace JobTrackerApi.Migrations
/// <inheritdoc />
public partial class SyncCareerChildKeyLengths : Migration
{
// Historical snapshot-only migration. Provider-safe ownership moved to
// 20260830133000_AdoptCareerProfileSchema.
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
@@ -0,0 +1,213 @@
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
/// <summary>
/// Moves the durable Career Profile aggregate into migration ownership without replacing the
/// canonical profile, append-only history, or ordered relational children.
/// </summary>
[DbContext(typeof(JobTrackerContext))]
[Migration("20260830133000_AdoptCareerProfileSchema")]
public sealed class AdoptCareerProfileSchema : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql(MySqlDdl);
return;
}
migrationBuilder.Sql(SqliteDdl);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Preserve user career history that may predate explicit migration ownership.
}
private const string SqliteDdl = """
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ProfileJson" TEXT NOT NULL,
"LongTailJson" TEXT NOT NULL DEFAULT '',
"Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"CareerProfileId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL,
"ProfileJson" TEXT NOT NULL,
"Source" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "CareerExperiences" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerExperiences" PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NULL, "Company" TEXT NULL, "Location" TEXT NULL,
"Start" TEXT NULL, "End" TEXT NULL, "StartDate" TEXT NULL, "EndDate" TEXT NULL,
"IsCurrent" INTEGER NOT NULL DEFAULT 0,
"BulletsJson" TEXT NOT NULL DEFAULT '[]', "SkillsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerExperiences_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "CareerEducations" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerEducations" PRIMARY KEY AUTOINCREMENT,
"Qualification" TEXT NULL, "QualificationLevel" TEXT NULL, "Institution" TEXT NULL,
"Location" TEXT NULL, "Start" TEXT NULL, "End" TEXT NULL,
"StartDate" TEXT NULL, "EndDate" TEXT NULL, "DetailsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerEducations_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "CareerSkills" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerSkills" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL, "Category" TEXT NULL, "Proficiency" TEXT NULL,
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerSkills_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "CareerProjects" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProjects" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL, "Role" TEXT NULL, "Location" TEXT NULL,
"Start" TEXT NULL, "End" TEXT NULL, "StartDate" TEXT NULL, "EndDate" TEXT NULL,
"BulletsJson" TEXT NOT NULL DEFAULT '[]', "SkillsJson" TEXT NOT NULL DEFAULT '[]',
"LinksJson" TEXT NOT NULL DEFAULT '[]', "CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL, "ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerProjects_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "CareerCertifications" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerCertifications" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL, "Issuer" TEXT NULL, "Location" TEXT NULL,
"Date" TEXT NULL, "DateNormalized" TEXT NULL, "DetailsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerCertifications_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "CareerLanguages" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerLanguages" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL, "Level" TEXT NULL, "Notes" TEXT NULL,
"CareerProfileId" INTEGER NOT NULL, "OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL, "SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerLanguages_CareerProfiles_CareerProfileId"
FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId"
ON "CareerProfiles" ("OwnerUserId");
CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"
ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");
CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_CareerProfileId" ON "CareerExperiences" ("CareerProfileId");
CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder" ON "CareerExperiences" ("OwnerUserId", "CareerProfileId", "SortOrder");
CREATE INDEX IF NOT EXISTS "IX_CareerEducations_CareerProfileId" ON "CareerEducations" ("CareerProfileId");
CREATE INDEX IF NOT EXISTS "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder" ON "CareerEducations" ("OwnerUserId", "CareerProfileId", "SortOrder");
CREATE INDEX IF NOT EXISTS "IX_CareerSkills_CareerProfileId" ON "CareerSkills" ("CareerProfileId");
CREATE INDEX IF NOT EXISTS "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder" ON "CareerSkills" ("OwnerUserId", "CareerProfileId", "SortOrder");
CREATE INDEX IF NOT EXISTS "IX_CareerProjects_CareerProfileId" ON "CareerProjects" ("CareerProfileId");
CREATE INDEX IF NOT EXISTS "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder" ON "CareerProjects" ("OwnerUserId", "CareerProfileId", "SortOrder");
CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_CareerProfileId" ON "CareerCertifications" ("CareerProfileId");
CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder" ON "CareerCertifications" ("OwnerUserId", "CareerProfileId", "SortOrder");
CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_CareerProfileId" ON "CareerLanguages" ("CareerProfileId");
CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder" ON "CareerLanguages" ("OwnerUserId", "CareerProfileId", "SortOrder");
""";
private const string MySqlDdl = """
CREATE TABLE IF NOT EXISTS `CareerProfiles` (
`Id` int NOT NULL AUTO_INCREMENT, `OwnerUserId` varchar(255) NOT NULL,
`ProfileJson` longtext NOT NULL, `LongTailJson` longtext NOT NULL,
`Version` int NOT NULL, `CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL, PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
`Id` int NOT NULL AUTO_INCREMENT, `OwnerUserId` varchar(255) NOT NULL,
`CareerProfileId` int NOT NULL, `Version` int NOT NULL, `ProfileJson` longtext NOT NULL,
`Source` varchar(100) NOT NULL, `CreatedAtUtc` datetime(6) NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId`
FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `CareerExperiences` (
`Id` int NOT NULL AUTO_INCREMENT, `Title` longtext NULL, `Company` longtext NULL,
`Location` longtext NULL, `Start` longtext NULL, `End` longtext NULL,
`StartDate` longtext NULL, `EndDate` longtext NULL, `IsCurrent` tinyint(1) NOT NULL DEFAULT 0,
`BulletsJson` longtext NOT NULL, `SkillsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL, `OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL, `SortOrder` int NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerExperiences_CareerProfiles_CareerProfileId`
FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `CareerEducations` (
`Id` int NOT NULL AUTO_INCREMENT, `Qualification` longtext NULL,
`QualificationLevel` longtext NULL, `Institution` longtext NULL, `Location` longtext NULL,
`Start` longtext NULL, `End` longtext NULL, `StartDate` longtext NULL, `EndDate` longtext NULL,
`DetailsJson` longtext NOT NULL, `CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL, `ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerEducations_CareerProfiles_CareerProfileId`
FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `CareerSkills` (
`Id` int NOT NULL AUTO_INCREMENT, `Name` longtext NULL, `Category` longtext NULL,
`Proficiency` longtext NULL, `CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL, `ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerSkills_CareerProfiles_CareerProfileId`
FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `CareerProjects` (
`Id` int NOT NULL AUTO_INCREMENT, `Name` longtext NULL, `Role` longtext NULL,
`Location` longtext NULL, `Start` longtext NULL, `End` longtext NULL,
`StartDate` longtext NULL, `EndDate` longtext NULL, `BulletsJson` longtext NOT NULL,
`SkillsJson` longtext NOT NULL, `LinksJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL, `OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL, `SortOrder` int NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProjects_CareerProfiles_CareerProfileId`
FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `CareerCertifications` (
`Id` int NOT NULL AUTO_INCREMENT, `Name` longtext NULL, `Issuer` longtext NULL,
`Location` longtext NULL, `Date` longtext NULL, `DateNormalized` longtext NULL,
`DetailsJson` longtext NOT NULL, `CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL, `ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerCertifications_CareerProfiles_CareerProfileId`
FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `CareerLanguages` (
`Id` int NOT NULL AUTO_INCREMENT, `Name` longtext NULL, `Level` longtext NULL,
`Notes` longtext NULL, `CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL, `ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL, PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerLanguages_CareerProfiles_CareerProfileId`
FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE UNIQUE INDEX IF NOT EXISTS `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);
CREATE INDEX IF NOT EXISTS `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);
CREATE INDEX IF NOT EXISTS `IX_CareerExperiences_CareerProfileId` ON `CareerExperiences` (`CareerProfileId`);
CREATE INDEX IF NOT EXISTS `IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder` ON `CareerExperiences` (`OwnerUserId`, `CareerProfileId`, `SortOrder`);
CREATE INDEX IF NOT EXISTS `IX_CareerEducations_CareerProfileId` ON `CareerEducations` (`CareerProfileId`);
CREATE INDEX IF NOT EXISTS `IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder` ON `CareerEducations` (`OwnerUserId`, `CareerProfileId`, `SortOrder`);
CREATE INDEX IF NOT EXISTS `IX_CareerSkills_CareerProfileId` ON `CareerSkills` (`CareerProfileId`);
CREATE INDEX IF NOT EXISTS `IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder` ON `CareerSkills` (`OwnerUserId`, `CareerProfileId`, `SortOrder`);
CREATE INDEX IF NOT EXISTS `IX_CareerProjects_CareerProfileId` ON `CareerProjects` (`CareerProfileId`);
CREATE INDEX IF NOT EXISTS `IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder` ON `CareerProjects` (`OwnerUserId`, `CareerProfileId`, `SortOrder`);
CREATE INDEX IF NOT EXISTS `IX_CareerCertifications_CareerProfileId` ON `CareerCertifications` (`CareerProfileId`);
CREATE INDEX IF NOT EXISTS `IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder` ON `CareerCertifications` (`OwnerUserId`, `CareerProfileId`, `SortOrder`);
CREATE INDEX IF NOT EXISTS `IX_CareerLanguages_CareerProfileId` ON `CareerLanguages` (`CareerProfileId`);
CREATE INDEX IF NOT EXISTS `IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder` ON `CareerLanguages` (`OwnerUserId`, `CareerProfileId`, `SortOrder`);
""";
}
+2 -2
View File
@@ -12,8 +12,8 @@ namespace JobTrackerApi.Models;
// - ItemKey carries the stable item id from the StructuredCvProfile blob, so a row keeps its
// identity across imports/edits and future CV variants can reference "this experience".
// - SortOrder makes ordering explicit (the blob relied on array position).
// - List fields persist as JSON string columns (plain TEXT — reconciler-friendly on both SQLite
// and MySQL) exposed via [NotMapped] accessors. Children are replaced wholesale on save, so
// - List fields persist as JSON string columns exposed via [NotMapped] accessors. Children are
// replaced wholesale on save, so
// fine-grained change tracking of the lists is not needed.
/// <summary>Base fields every CareerProfile child shares.</summary>
@@ -546,163 +546,11 @@ public static class StartupInitializationExtensions
EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;");
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
// authoritative column every existing read path uses; these mirror it so future
// Career Workspace features (variants, history UI) have a real table to build on.
static void EnsureCareerProfileTables(DbConnection c)
// Preserve the one historical additive column repair while migrations own table creation.
static void ReconcileCareerProfileColumns(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ProfileJson" TEXT NOT NULL,
"LongTailJson" TEXT NOT NULL DEFAULT '',
"Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL
);
""");
// LongTailJson was added to the CareerProfile model (Phase 3) but this CREATE and
// the MySQL one were never updated to match, and no column-repair existed — so a
// CareerProfiles table created before this line lacks the column and /api/cv/outline
// (CareerProfileService.LoadStructuredAsync) fails with "Unknown column LongTailJson".
// Additive repair for existing databases.
EnsureColumn(c, "CareerProfiles", "LongTailJson", """ALTER TABLE "CareerProfiles" ADD COLUMN "LongTailJson" TEXT NOT NULL DEFAULT '';""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"CareerProfileId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL,
"ProfileJson" TEXT NOT NULL,
"Source" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
// The six relational children of CareerProfile. Reconciler-owned: their migration
// is a no-op because it was scaffolded against SQLite and produced an unindexable
// longtext OwnerUserId on MariaDB. docs/infrastructure/database-ownership.md.
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerExperiences" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerExperiences" PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NULL,
"Company" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"IsCurrent" INTEGER NOT NULL DEFAULT 0,
"BulletsJson" TEXT NOT NULL DEFAULT '[]',
"SkillsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerExperiences_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_CareerProfileId" ON "CareerExperiences" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder" ON "CareerExperiences" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerEducations" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerEducations" PRIMARY KEY AUTOINCREMENT,
"Qualification" TEXT NULL,
"QualificationLevel" TEXT NULL,
"Institution" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerEducations_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_CareerProfileId" ON "CareerEducations" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder" ON "CareerEducations" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerSkills" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerSkills" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Category" TEXT NULL,
"Proficiency" TEXT NULL,
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerSkills_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_CareerProfileId" ON "CareerSkills" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder" ON "CareerSkills" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProjects" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProjects" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Role" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"BulletsJson" TEXT NOT NULL DEFAULT '[]',
"SkillsJson" TEXT NOT NULL DEFAULT '[]',
"LinksJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerProjects_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_CareerProfileId" ON "CareerProjects" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder" ON "CareerProjects" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerCertifications" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerCertifications" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Issuer" TEXT NULL,
"Location" TEXT NULL,
"Date" TEXT NULL,
"DateNormalized" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerCertifications_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_CareerProfileId" ON "CareerCertifications" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder" ON "CareerCertifications" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerLanguages" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerLanguages" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Level" TEXT NULL,
"Notes" TEXT NULL,
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerLanguages_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_CareerProfileId" ON "CareerLanguages" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder" ON "CareerLanguages" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
EnsureColumn(c, "CareerProfiles", "LongTailJson",
"""ALTER TABLE "CareerProfiles" ADD COLUMN "LongTailJson" TEXT NOT NULL DEFAULT '';""");
}
static void ReconcileAiInteractionUsageColumns(DbConnection c)
@@ -713,7 +561,7 @@ public static class StartupInitializationExtensions
}
ReconcileGmailConnectionColumns(conn);
EnsureCareerProfileTables(conn);
ReconcileCareerProfileColumns(conn);
ReconcileAiInteractionUsageColumns(conn);
// Once the base app tables exist, provision this reconciler-owned schema set and
@@ -849,154 +697,28 @@ public static class StartupInitializationExtensions
EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");
// The six relational children of CareerProfile — reconciler-owned (their
// migration is a no-op). varchar OwnerUserId/ItemKey so the composite index fits
// inside MariaDB's key limit. docs/infrastructure/database-ownership.md.
if (!HasMySqlTable(conn, "CareerExperiences") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerExperiences = conn.CreateCommand();
cmdCareerExperiences.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerExperiences` (
`Id` int NOT NULL AUTO_INCREMENT,
`Title` longtext NULL,
`Company` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`IsCurrent` tinyint(1) NOT NULL DEFAULT 0,
`BulletsJson` longtext NOT NULL,
`SkillsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerExperiences_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerExperiences.ExecuteNonQuery();
}
// Guarded historical shape/index repairs only. Creation belongs to the current
// provider-aware Career Profile adoption migration.
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerExperiences", "Id");
EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerEducations") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerEducations = conn.CreateCommand();
cmdCareerEducations.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerEducations` (
`Id` int NOT NULL AUTO_INCREMENT,
`Qualification` longtext NULL,
`QualificationLevel` longtext NULL,
`Institution` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`DetailsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerEducations_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerEducations.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerEducations", "Id");
EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerSkills") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerSkills = conn.CreateCommand();
cmdCareerSkills.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerSkills` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Category` longtext NULL,
`Proficiency` longtext NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerSkills_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerSkills.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerSkills", "Id");
EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerProjects") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerProjects = conn.CreateCommand();
cmdCareerProjects.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProjects` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Role` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`BulletsJson` longtext NOT NULL,
`SkillsJson` longtext NOT NULL,
`LinksJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProjects_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerProjects.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProjects", "Id");
EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerCertifications") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerCertifications = conn.CreateCommand();
cmdCareerCertifications.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerCertifications` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Issuer` longtext NULL,
`Location` longtext NULL,
`Date` longtext NULL,
`DateNormalized` longtext NULL,
`DetailsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerCertifications_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerCertifications.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerCertifications", "Id");
EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerLanguages") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerLanguages = conn.CreateCommand();
cmdCareerLanguages.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerLanguages` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Level` longtext NULL,
`Notes` longtext NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerLanguages_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerLanguages.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerLanguages", "Id");
EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
@@ -1055,46 +777,11 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
// for every existing read path during the dual-write window.
if (!HasMySqlTable(conn, "CareerProfiles"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfiles` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`ProfileJson` longtext NOT NULL,
`LongTailJson` longtext NOT NULL,
`Version` int NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
// Additive repair for a CareerProfiles table created before LongTailJson was added
// to the model. Without it, /api/cv/outline fails with "Unknown column LongTailJson".
// DEFAULT '' backfills existing rows and matches the non-nullable model property.
EnsureMySqlColumn(conn, "CareerProfiles", "LongTailJson", "ALTER TABLE `CareerProfiles` ADD COLUMN `LongTailJson` longtext NOT NULL DEFAULT '';");
if (!HasMySqlTable(conn, "CareerProfileVersions") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`CareerProfileId` int NOT NULL,
`Version` int NOT NULL,
`ProfileJson` longtext NOT NULL,
`Source` varchar(100) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
// Historical Phase 4/5 migrations were scaffolded against SQLite, so on MariaDB
// they could emit TEXT datetimes and a PK with no AUTO_INCREMENT, then fail while
@@ -1211,11 +898,8 @@ public static class StartupInitializationExtensions
}
}
// 1. Reconcile what already exists, and create the reconciler-owned tables. This has to run
// BEFORE Migrate(): legacy schemas need repairing so migrations do not collide with
// them, and AddCareerProfileRelationalChildren adds children that reference
// CareerProfiles, a reconciler-owned table. Anything that references a migration-owned
// table is skipped here (the parent does not exist yet) and picked up in pass 3.
// 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.
ReconcileSchema();
// 2. Apply one migration at a time, reconciling after each. Some historical migrations
@@ -16,6 +16,14 @@ internal static class StartupSchemaOwnership
"AiInteractions",
"ApplicationChecklistItems",
"Attachments",
"CareerCertifications",
"CareerEducations",
"CareerExperiences",
"CareerLanguages",
"CareerProfiles",
"CareerProfileVersions",
"CareerProjects",
"CareerSkills",
"Companies",
"Correspondences",
"CoverLetterVersions",
@@ -54,14 +62,6 @@ internal static class StartupSchemaOwnership
"AspNetUserRoles",
"AspNetUsers",
"AspNetUserTokens",
"CareerCertifications",
"CareerEducations",
"CareerExperiences",
"CareerLanguages",
"CareerProfiles",
"CareerProfileVersions",
"CareerProjects",
"CareerSkills",
};
// 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
**Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. Twenty 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, and durable interview preparation—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. Fifteen startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194V-206.
**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.
- **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.
+1
View File
@@ -238,3 +238,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-204 | Ownership/migration chain; populated adoption/downgrade/retry; workflow/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move application workflow checklist state to migration ownership without losing automatic or manual progress | PASS — focused ownership/migration 16/16; auto-completed system and pending manual items survive adoption, downgrade and re-upgrade; idempotency/order indexes exist; deleting the parent application cascades through checklist items; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 732/732 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Seventeen reconciler-owned tables remain | JT-019 checklist transfer complete; cover-letter and interview-preparation tables 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-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 |
+13 -13
View File
@@ -40,9 +40,7 @@ Neither a single reconciliation position nor one shared provider sequence works:
- **Pass 1 must come first.** A legacy database has hand-added columns and Identity tables that
predate the migrations; without repairing them (and stamping the legacy migration id into
`__EFMigrationsHistory`) `Migrate()` collides with them. `AddCareerProfileRelationalChildren` also
adds children that reference `CareerProfiles`, a **reconciler-owned** table — so it must exist
before migrations run.
`__EFMigrationsHistory`) `Migrate()` collides with them.
- **The final pass must come after.** On a brand-new database the migration-owned tables do not exist during
pass 1, so every reconciler table that references one (FK into `JobApplications`) is skipped, as
are the index and `AUTO_INCREMENT` repairs.
@@ -67,7 +65,9 @@ already-correct database. Two consequences worth knowing:
Created by EF migrations, never by the reconciler:
`AccountDeletionFiles`, `AccountDeletionRequests`, `AiInteractions`, `AiUsageRecords`, `AiWorkspaceNotes`,
`ApplicationChecklistItems`, `Attachments`, `Companies`, `CoverLetterVersions`,
`ApplicationChecklistItems`, `Attachments`, `CareerCertifications`, `CareerEducations`,
`CareerExperiences`, `CareerLanguages`, `CareerProfiles`, `CareerProfileVersions`, `CareerProjects`,
`CareerSkills`, `Companies`, `CoverLetterVersions`,
`Correspondences`, `CvExtractionRuns`, `CvUploadArtifacts`, `CvVariants`, `CvVariantVersions`,
`EmailDrafts`, `EmailSendAttempts`, `GmailConnections`,
`GmailReviewDecisions`, `ImapConnections`, `InterviewPrepItems`, `InterviewPrepNotes`, `JobApplications`, `JobEvents`, `Jobs`,
@@ -127,6 +127,11 @@ remains cascading.
AI-generated questions, answers, preparation state, sources, and ordering are retained; application
deletion remains cascading.
The Career Profile aggregate (`CareerProfiles`, `CareerProfileVersions`, and all six relational
child tables) moved in `20260830133000_AdoptCareerProfileSchema`. Canonical/long-tail JSON,
append-only history, stable child keys and ordering are retained; bounded MariaDB owner/item keys
keep all aggregate indexes provider-safe.
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.
It used to create `RuleSettings`, which is precisely why a clean install failed with
@@ -134,12 +139,7 @@ It used to create `RuleSettings`, which is precisely why a clean install failed
### Reconciler-owned
Created by `StartupInitializationExtensions`, with a **no-op migration** holding the model snapshot:
`CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`,
`CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`).
The seven ASP.NET Identity tables are also currently reconciler-owned, despite older wording that
The seven ASP.NET Identity tables are currently reconciler-owned, despite older wording that
called them migration-owned: `AspNetRoles`, `AspNetUsers`, `AspNetRoleClaims`, `AspNetUserClaims`,
`AspNetUserLogins`, `AspNetUserRoles`, and `AspNetUserTokens`. Guarded migration bootstraps for
`AspNetUsers` supports standalone traversal but does not yet transfer ownership. The historical
@@ -153,11 +153,11 @@ No-op migrations, each with a comment explaining why:
| Migration | Tables |
|---|---|
| `20260717222917_AddCareerProfileRelationalChildren` | the six CareerProfile children |
| `20260717222917_AddCareerProfileRelationalChildren` | historical no-op; ownership transferred by `20260830133000_AdoptCareerProfileSchema` |
| `20260718074509_AddCvVariants` | historical no-op; ownership transferred by `20260830128000_AdoptCvVariantSchema` |
| `20260718131138_AddAiInteractions` | historical no-op; ownership transferred by `20260830129000_AdoptAiInteractionSchema` |
| `20260719085904_AddApplicationChecklistItems` | historical no-op; ownership transferred by `20260830130000_AdoptApplicationChecklistSchema` |
| `20260719094728_SyncCareerChildKeyLengths` | snapshot sync only |
| `20260719094728_SyncCareerChildKeyLengths` | historical snapshot sync; ownership transferred by `20260830133000_AdoptCareerProfileSchema` |
| `20260719120954_AddCoverLetterVersions` | historical no-op; ownership transferred by `20260830131000_AdoptCoverLetterVersionSchema` |
| `20260719145044_AddInterviewPrepItems` | historical no-op; ownership transferred by `20260830132000_AdoptInterviewPrepItemSchema` |
@@ -170,7 +170,7 @@ skips it on a fresh database and pass 2 creates it:
|---|---|
| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems`, `CoverLetterVersions`, `InterviewPrepItems` | `JobApplications` (migration-owned) |
| `CvVariantVersions` | `CvVariants` |
| `CareerProfileVersions`, the six CareerProfile children | `CareerProfiles` |
| `CareerProfileVersions`, the six CareerProfile children | `CareerProfiles` (migration-owned) |
| `CvExtractionRuns` | `CvUploadArtifacts` |
Index creation goes through one helper, `EnsureMySqlIndex`, which is guarded on **table** existence
+8 -2
View File
@@ -41,6 +41,9 @@ cohesive table groups. It does not attempt to delete the legacy reconciler whole
manual and AI-approved text plus source/action metadata.
- Added `20260830132000_AdoptInterviewPrepItemSchema` for durable interview-practice content,
retaining user and AI questions, answers, prepared state, source metadata, and ordering.
- 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
ownership are retained.
- Corrected the ownership runbook: new tables now default to migrations; the reconciler is legacy
compatibility code to retire one dependency group at a time.
@@ -81,14 +84,17 @@ migration.
- Representative user-authored and AI-generated interview-preparation items survive adoption,
downgrade, and re-upgrade with practice state intact; their ordering index and application cascade
remain effective.
- 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
14 aggregate indexes and parent cascades remain effective.
- Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL.
- Full backend: 734/734 passed after the interview-preparation transfer.
- Full backend: 735/735 passed after the Career Profile aggregate transfer.
- Fresh application startup over a new disposable SQLite database applied
`20260830120000_AdoptSystemEmailSettingsSchema` and reached the healthy listening state.
## Remaining JT-019 work
Fifteen model tables remain startup-created: the Career Profile aggregate and Identity group.
Seven model tables remain startup-created: the ASP.NET Identity group.
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
prove each one redundant.
+2 -1
View File
@@ -56,6 +56,7 @@ Updated: 2026-08-30
- Moved application checklist workflow state into a provider-aware migration; system-key idempotency, manual tasks, ordering, completion state, 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 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.
### In progress
@@ -86,7 +87,7 @@ Updated: 2026-08-30
- Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed.
- Full backend: 734/734 tests passed after the interview-preparation JT-019 transfer.
- Full backend: 735/735 tests passed after the Career Profile aggregate JT-019 transfer.
- 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.
- Focused backend match/intelligence verification: 34/34 passed.